dwara_core/wasm/source.rs
1//! Registry plugin source resolution (DW-165).
2//!
3//! A plugin entry with `source:` names a remote `.wasm` artifact
4//! (registry URL + mandatory SHA-256 digest + optional Ed25519
5//! signature/key material). This module resolves every such entry to a
6//! VERIFIED local file BEFORE the wasm load step (`PluginLifecycle::
7//! load`), so a registry plugin enters the runtime through exactly the
8//! same read/compile/health path as a local `wasm:` plugin — there is
9//! no partial load: an artifact that cannot be resolved or fails
10//! verification never reaches wasmtime, and the plugin is marked
11//! `Crashed` at publish (fail-closed for its routes, DW-157/158
12//! vocabulary).
13//!
14//! ## Resolution pipeline (per `source:` plugin)
15//!
16//! 1. **Scheme check** — only `https://` is fetchable. `oci://`
17//! references parse in config but are rejected with a clear
18//! validation error in this version (see the registry spec in the
19//! user docs); the resolver repeats the rejection defensively.
20//! 2. **Digest format check** — `source.digest` must be a 64-char
21//! SHA-256 hex string (case-insensitive; normalized lowercase).
22//! 3. **Cache lookup** — the artifact is content-addressed:
23//! `<cache_dir>/<digest>.wasm` (`source.cache_path` overrides the
24//! location, not the verification; it must be a relative path with
25//! no `..` segments — containment, see [`PluginSourceConfig::
26//! cache_path_violation`]). A present cache file whose bytes do
27//! not hash to the pinned digest is a tampered/corrupt entry and
28//! FAILS CLOSED with a digest-mismatch error naming the expected
29//! and actual digests — it is never silently re-fetched (an
30//! operator must see that the file named for one digest holds
31//! another). A cache hit still verifies the signature.
32//! 4. **Download** (cache miss only, and only while the per-publish
33//! budget has time left) — one HTTPS GET, HTTP/1.1, redirects NOT
34//! followed, response size capped. See the downloader note below
35//! for why this is a hand-rolled client.
36//! 5. **Digest verification** — downloaded bytes must hash to the
37//! pinned digest; mismatch fails closed naming both digests.
38//! 6. **Signature verification** — when `signature`/`public_key` (or
39//! pinned `plugin_registry.public_keys`) are configured, an
40//! Ed25519 signature over the artifact bytes must verify. A plugin
41//! with no signature is REJECTED when the registry pins keys, and
42//! a `public_key` without a `signature` is rejected as a config
43//! mistake (a key that verifies nothing).
44//! 7. **Atomic cache write** — tmp file in the same directory, fsync,
45//! rename into place. A crash mid-write can never leave a partial
46//! artifact under the digest name; the next resolve either sees the
47//! complete file or downloads again.
48//!
49//! ## Downloader (why a hand-rolled client)
50//!
51//! dwara-core has no reqwest/ureq; the repo precedent for outbound
52//! HTTP is the events domain's hand-rolled HTTP/1.1 client
53//! (`events::webhook`, tokio-based). That client is private to
54//! `events`, which the wasm domain must not import (`check_deps.py`:
55//! wasm <- config, plugins), and it is async while resolution runs
56//! inside the SYNCHRONOUS `DataPlane::reload_plugins` (called from
57//! async reload loops — a nested `block_on` would panic). So this
58//! module carries its own minimal SYNCHRONOUS HTTPS GET: std
59//! `TcpStream` + rustls `ClientConnection` (both already direct
60//! dependencies), public webpki roots, the same egress posture as
61//! webhooks (operator-configured URL, SSRF egress filter applied at
62//! connect time when enabled, redirects not followed).
63//!
64//! ## Time budget (review finding: no unbounded holds)
65//!
66//! Two layers bound every resolution, because a registry that drips
67//! one byte per read would otherwise defeat a per-read timeout (each
68//! read resets it) and hold a thread forever:
69//!
70//! - a per-READ timeout (10s connect, 30s per read), and
71//! - a per-PUBLISH total budget ([`RESOLUTION_BUDGET`], 60s across
72//! ALL source plugins of one publish), checked before each download
73//! and inside the fetcher's read loop. A cache hit needs no network
74//! and is never budget-gated. Budget exhaustion fails the affected
75//! plugin closed with a step-named error ([`SourceError::
76//! ResolutionBudgetExceeded`] / a `DownloadFailed` naming the
77//! budget), never a hang.
78//!
79//! The binary's reload path additionally runs the whole resolution on
80//! `tokio::task::spawn_blocking` (DNS via getaddrinfo and the sync
81//! TLS reads are blocking syscalls), so the async reload task awaits
82//! instead of holding a worker thread; see `dwara-bin`'s reload
83//! driver.
84//!
85//! ## Cache layout
86//!
87//! ```text
88//! <cache_dir>/ (plugin_registry.cache_dir, default ./plugin-cache)
89//! <sha256-digest>.wasm one immutable artifact per digest
90//! .<sha256-digest>.wasm.tmp<pid> transient write target (never read)
91//! ```
92//!
93//! Artifacts are content-addressed: two plugins pinning the same
94//! digest share one file, and a restart (or a second gateway process)
95//! re-resolves from the cache without network access — the
96//! restart-safety test pins this by pointing `url` at a dead port and
97//! resolving from cache alone.
98
99use std::collections::HashMap;
100use std::io::{Read, Write};
101use std::net::TcpStream;
102use std::path::{Path, PathBuf};
103use std::sync::Arc;
104use std::time::{Duration, Instant};
105
106use ed25519_dalek::{Signature, VerifyingKey};
107
108use crate::config::ssrf::SsrfFilter;
109use crate::config::{Gateway, PluginRegistryConfig, PluginSourceConfig};
110
111/// Default cache directory when `plugin_registry.cache_dir` is absent
112/// (relative to the gateway working directory, the same posture as the
113/// ACME `state_dir` default).
114pub const DEFAULT_CACHE_DIR: &str = "./plugin-cache";
115
116/// Hard cap on one downloaded artifact (bytes). proxy-wasm filter
117/// modules are small; this binds a hostile registry answer.
118pub const MAX_ARTIFACT_BYTES: usize = 64 * 1024 * 1024;
119
120/// Cap on the response head while locating the blank line (bytes).
121const MAX_HEAD_BYTES: usize = 16 * 1024;
122
123/// TCP connect timeout per address.
124const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
125
126/// Per-read timeout on the established connection. This bounds ONE
127/// read/write, not the exchange: a registry that drips one byte per
128/// read resets it every time. The wall-clock bound on the whole
129/// resolution is [`RESOLUTION_BUDGET`], enforced in the read loop.
130const IO_TIMEOUT: Duration = Duration::from_secs(30);
131
132/// Total wall-clock budget for registry-source resolution per publish,
133/// shared across ALL `source:` plugins (review finding: a drip-feeding
134/// or hung registry must not hold the reload path unboundedly, and N
135/// plugins must not stack N budgets). Checked before each download and
136/// between reads in the fetch loop; cache hits are never budget-gated
137/// (they need no network). Exhaustion fails the affected plugin closed
138/// with a step-named error; the next publish retries.
139pub const RESOLUTION_BUDGET: Duration = Duration::from_secs(60);
140
141/// `User-Agent` stamped on registry fetches.
142const USER_AGENT: &str = "dwara-plugin-registry";
143
144/// Per-plugin resolution results keyed by plugin name (only
145/// `source:` plugins appear). Built by [`resolve_sources`]; consumed
146/// by `PluginLifecycle::load`.
147pub type SourceResolutions = HashMap<String, Result<PathBuf, SourceError>>;
148
149/// Why a `source:` plugin could not be resolved. Every variant names
150/// the plugin and the exact pipeline step that failed; `Display`
151/// messages are operator-facing (they reach the crashed-plugin error
152/// string on the status surface and the publish-time log line).
153#[derive(Clone, Debug, PartialEq, Eq)]
154pub enum SourceError {
155 /// The URL uses `oci://`, which is not supported in this version.
156 OciUnsupported { plugin: String, url: String },
157 /// The URL is not a usable `https://` reference.
158 InvalidUrl {
159 plugin: String,
160 url: String,
161 reason: String,
162 },
163 /// `source.digest` is not a 64-char hex SHA-256.
164 InvalidDigest {
165 plugin: String,
166 digest: String,
167 reason: String,
168 },
169 /// The cache file exists but cannot be read (permissions, IO).
170 CacheUnreadable {
171 plugin: String,
172 path: String,
173 error: String,
174 },
175 /// Artifact bytes do not hash to the pinned digest. `origin`
176 /// distinguishes the cached copy from a fresh download.
177 DigestMismatch {
178 plugin: String,
179 origin: &'static str,
180 expected: String,
181 actual: String,
182 },
183 /// Ed25519 verification failed (bad key/sig material, wrong key,
184 /// or a missing signature where the registry pins keys).
185 SignatureRejected { plugin: String, reason: String },
186 /// The artifact was not cached and the download failed.
187 DownloadFailed {
188 plugin: String,
189 url: String,
190 detail: String,
191 },
192 /// The verified artifact could not be written to the cache.
193 CacheWriteFailed {
194 plugin: String,
195 path: String,
196 error: String,
197 },
198 /// `source.cache_path` escapes the plugin cache directory. The
199 /// resolver creates directories and writes artifact bytes at the
200 /// operator-chosen path, so containment is enforced before any
201 /// lookup or download happens.
202 InvalidCachePath {
203 plugin: String,
204 path: String,
205 reason: String,
206 },
207 /// The per-publish resolution budget ran out before this plugin's
208 /// download could start (a hung or drip-feeding registry). The
209 /// plugin fails closed; the next publish retries.
210 ResolutionBudgetExceeded { plugin: String, budget_secs: u64 },
211}
212
213impl std::fmt::Display for SourceError {
214 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
215 match self {
216 SourceError::OciUnsupported { plugin, url } => write!(
217 f,
218 "plugin '{plugin}': source.url '{url}' uses oci://, which is not \
219 supported in this version; publish the artifact over https:// or \
220 pre-fetch it with `dwara-cli plugin install` and reference the \
221 local file with `wasm`"
222 ),
223 SourceError::InvalidUrl {
224 plugin,
225 url,
226 reason,
227 } => write!(
228 f,
229 "plugin '{plugin}': source.url '{url}' is not usable: {reason} \
230 (must be an https:// URL)"
231 ),
232 SourceError::InvalidDigest {
233 plugin,
234 digest,
235 reason,
236 } => write!(
237 f,
238 "plugin '{plugin}': source.digest is invalid ({reason}); got \
239 '{digest}' — expected a 64-char hex-encoded SHA-256"
240 ),
241 SourceError::CacheUnreadable {
242 plugin,
243 path,
244 error,
245 } => write!(
246 f,
247 "plugin '{plugin}': cached artifact at {path} could not be read \
248 (cache lookup step): {error}"
249 ),
250 SourceError::DigestMismatch {
251 plugin,
252 origin,
253 expected,
254 actual,
255 } => write!(
256 f,
257 "plugin '{plugin}': digest verification failed for the {origin} \
258 artifact: source.digest pins {expected} but the bytes hash to \
259 {actual}; the artifact was NOT loaded"
260 ),
261 SourceError::SignatureRejected { plugin, reason } => write!(
262 f,
263 "plugin '{plugin}': Ed25519 signature verification failed: {reason}"
264 ),
265 SourceError::DownloadFailed {
266 plugin,
267 url,
268 detail,
269 } => write!(
270 f,
271 "plugin '{plugin}': download from '{url}' failed (download step): \
272 {detail}; the artifact was not cached — pre-fetch it with \
273 `dwara-cli plugin install` into the plugin cache and reload"
274 ),
275 SourceError::CacheWriteFailed {
276 plugin,
277 path,
278 error,
279 } => write!(
280 f,
281 "plugin '{plugin}': verified artifact could not be cached at \
282 {path} (cache write step): {error}"
283 ),
284 SourceError::InvalidCachePath {
285 plugin,
286 path,
287 reason,
288 } => write!(
289 f,
290 "plugin '{plugin}': source.cache_path '{path}' is not usable \
291 ({reason}); it must be a relative path under the plugin cache \
292 dir (plugin_registry.cache_dir) with no parent segments — an \
293 escaping cache_path would write the artifact outside the cache"
294 ),
295 SourceError::ResolutionBudgetExceeded {
296 plugin,
297 budget_secs,
298 } => write!(
299 f,
300 "plugin '{plugin}': registry source resolution exceeded the \
301 {budget_secs}s per-publish budget before its download step; the \
302 plugin was not resolved (fail-closed) — the next publish retries; \
303 pre-fetch the artifact with `dwara-cli plugin install` if the \
304 registry is slow"
305 ),
306 }
307 }
308}
309
310impl std::error::Error for SourceError {}
311
312/// The download step as a callable: production wires [`https_get`]
313/// (real TLS, SSRF-checked, deadline-bounded); the integration tests
314/// inject bytes so the download-side verification pipeline runs
315/// without a TLS server. `#[doc(hidden)]`: a test seam, not public
316/// API.
317#[doc(hidden)]
318pub type FetchFn = fn(&str, &SsrfFilter, Instant) -> Result<Vec<u8>, String>;
319
320/// Resolve every `source:` plugin in the gateway config to a verified
321/// local artifact path. Local `wasm:`/`native` plugins are absent from
322/// the map. Successes are logged (cache vs download); failures are
323/// logged by the lifecycle when they mark the plugin Crashed (this
324/// function stays quiet on errors so the crash log line is the single
325/// publish-time record). The whole resolution runs under the
326/// [`RESOLUTION_BUDGET`] per-publish budget.
327pub fn resolve_sources(gateway: &Gateway) -> SourceResolutions {
328 resolve_sources_with_fetch(gateway, https_get, Instant::now() + RESOLUTION_BUDGET)
329}
330
331/// Resolve with an injected download step and an explicit deadline.
332/// `#[doc(hidden)]`: the seam the integration tests use to exercise
333/// the download-side digest/signature verification (injected bytes)
334/// and the budget gate (an already-past deadline) deterministically,
335/// without a TLS registry.
336#[doc(hidden)]
337pub fn resolve_sources_with_fetch(
338 gateway: &Gateway,
339 fetch: FetchFn,
340 deadline: Instant,
341) -> SourceResolutions {
342 let cache_root = PathBuf::from(
343 gateway
344 .plugin_registry
345 .as_ref()
346 .and_then(|r| r.cache_dir.clone())
347 .unwrap_or_else(|| DEFAULT_CACHE_DIR.to_string()),
348 );
349 let ssrf = gateway
350 .ssrf_filter
351 .as_ref()
352 .map(SsrfFilter::from_config)
353 .unwrap_or_else(SsrfFilter::disabled);
354 let mut out = HashMap::new();
355 for plugin in &gateway.plugins {
356 let Some(source) = &plugin.source else {
357 continue;
358 };
359 let result = resolve_one(
360 &plugin.name,
361 source,
362 gateway.plugin_registry.as_ref(),
363 &cache_root,
364 &ssrf,
365 fetch,
366 deadline,
367 );
368 if let Ok((path, via)) = &result {
369 tracing::info!(
370 code = "plugin_source_resolved",
371 plugin = %plugin.name,
372 artifact = %path.display(),
373 via,
374 "DW-165: registry plugin source resolved and verified"
375 );
376 }
377 out.insert(plugin.name.clone(), result.map(|(path, _)| path));
378 }
379 out
380}
381
382/// Resolve one `source:` entry through the documented pipeline.
383/// `fetch` is the download step (production: [`https_get`]; tests
384/// inject bytes); `deadline` is the per-publish resolution budget.
385fn resolve_one(
386 plugin: &str,
387 source: &PluginSourceConfig,
388 registry: Option<&PluginRegistryConfig>,
389 cache_root: &Path,
390 ssrf: &SsrfFilter,
391 fetch: FetchFn,
392 deadline: Instant,
393) -> Result<(PathBuf, &'static str), SourceError> {
394 // Step 1: scheme. https only; oci is a documented v1 rejection.
395 if source.url.starts_with("oci://") {
396 return Err(SourceError::OciUnsupported {
397 plugin: plugin.to_string(),
398 url: source.url.clone(),
399 });
400 }
401 if !source.url.starts_with("https://") {
402 return Err(SourceError::InvalidUrl {
403 plugin: plugin.to_string(),
404 url: source.url.clone(),
405 reason: "must use the https:// scheme".to_string(),
406 });
407 }
408
409 // Step 2: digest format (case-insensitive hex, normalized).
410 let digest = source.digest.to_ascii_lowercase();
411 if digest.len() != 64 || !digest.bytes().all(|b| b.is_ascii_hexdigit()) {
412 let reason = if digest.len() != 64 {
413 format!("wrong length: {} chars, expected 64", digest.len())
414 } else {
415 "contains non-hex characters".to_string()
416 };
417 return Err(SourceError::InvalidDigest {
418 plugin: plugin.to_string(),
419 digest: source.digest.clone(),
420 reason,
421 });
422 }
423
424 // Step 3: cache lookup (content-addressed under the digest).
425 // Containment first (review finding): cache_path is
426 // operator-chosen and the resolver create_dir_all + writes bytes
427 // there — it must stay inside the cache dir. Validation rejects
428 // this earlier with a field-named issue; the resolver repeats the
429 // rejection defensively (the same posture as every other check).
430 if let Some(p) = &source.cache_path {
431 if let Some(reason) = PluginSourceConfig::cache_path_violation(p) {
432 return Err(SourceError::InvalidCachePath {
433 plugin: plugin.to_string(),
434 path: p.clone(),
435 reason: reason.to_string(),
436 });
437 }
438 }
439 let cache_path: PathBuf = match &source.cache_path {
440 // Relative TO THE CACHE ROOT (containment): a validated
441 // cache_path never escapes it, and the artifact always lands
442 // under the operator's cache dir.
443 Some(p) => cache_root.join(p),
444 None => cache_root.join(format!("{digest}.wasm")),
445 };
446 if cache_path.is_file() {
447 let bytes = std::fs::read(&cache_path).map_err(|e| SourceError::CacheUnreadable {
448 plugin: plugin.to_string(),
449 path: cache_path.display().to_string(),
450 error: e.to_string(),
451 })?;
452 let actual = super::lifecycle::sha256_hex(&bytes);
453 if actual != digest {
454 // Tampered or corrupt cache entry: fail closed, never a
455 // silent re-download (see module docs).
456 return Err(SourceError::DigestMismatch {
457 plugin: plugin.to_string(),
458 origin: "cached",
459 expected: digest,
460 actual,
461 });
462 }
463 verify_signature(plugin, source, registry, &bytes)?;
464 return Ok((cache_path, "cache"));
465 }
466
467 // Budget gate (review finding): network work only starts while
468 // the per-publish budget has time left. Cache hits above are
469 // never budget-gated — they need no network.
470 if Instant::now() >= deadline {
471 return Err(SourceError::ResolutionBudgetExceeded {
472 plugin: plugin.to_string(),
473 budget_secs: RESOLUTION_BUDGET.as_secs(),
474 });
475 }
476
477 // Step 4/5: download + digest verification.
478 let bytes =
479 fetch(&source.url, ssrf, deadline).map_err(|detail| SourceError::DownloadFailed {
480 plugin: plugin.to_string(),
481 url: source.url.clone(),
482 detail,
483 })?;
484 let actual = super::lifecycle::sha256_hex(&bytes);
485 if actual != digest {
486 return Err(SourceError::DigestMismatch {
487 plugin: plugin.to_string(),
488 origin: "downloaded",
489 expected: digest,
490 actual,
491 });
492 }
493
494 // Step 6: signature verification (before the bytes are cached or
495 // loaded — an unsigned-when-required artifact never lands).
496 verify_signature(plugin, source, registry, &bytes)?;
497
498 // Step 7: atomic cache write (tmp + fsync + rename).
499 atomic_write(&cache_path, &bytes).map_err(|e| SourceError::CacheWriteFailed {
500 plugin: plugin.to_string(),
501 path: cache_path.display().to_string(),
502 error: e.to_string(),
503 })?;
504 Ok((cache_path, "download"))
505}
506
507/// Verify the configured Ed25519 signature rules over `artifact`:
508///
509/// - `signature` + `public_key` set: the signature must verify against
510/// the per-plugin key.
511/// - `plugin_registry.public_keys` non-empty: the signature is
512/// REQUIRED and must verify against at least one pinned key (in
513/// addition to any per-plugin key).
514/// - `public_key` WITHOUT `signature`: rejected (review finding — a
515/// key that verifies nothing is a config mistake, not a mode).
516/// - No signature and no pinned keys: digest-only mode (trusted
517/// registry), documented as such.
518fn verify_signature(
519 plugin: &str,
520 source: &PluginSourceConfig,
521 registry: Option<&PluginRegistryConfig>,
522 artifact: &[u8],
523) -> Result<(), SourceError> {
524 let rejected = |reason: String| {
525 Err(SourceError::SignatureRejected {
526 plugin: plugin.to_string(),
527 reason,
528 })
529 };
530 let pinned: &[String] = registry.map(|r| r.public_keys.as_slice()).unwrap_or(&[]);
531
532 let Some(sig_hex) = &source.signature else {
533 if source.public_key.is_some() {
534 return rejected(
535 "source.public_key is set but source.signature is not; a key \
536 without a signature verifies nothing — sign the artifact or \
537 remove the key"
538 .to_string(),
539 );
540 }
541 if pinned.is_empty() {
542 return Ok(()); // digest-only mode
543 }
544 return rejected(
545 "the registry pins public keys (plugin_registry.public_keys) but \
546 source.signature is not set; unsigned artifacts are rejected"
547 .to_string(),
548 );
549 };
550
551 // Per-plugin key first (when configured).
552 if let Some(pk_hex) = &source.public_key {
553 verify_with_key(plugin, artifact, pk_hex, sig_hex, "source.public_key")?;
554 } else if pinned.is_empty() {
555 return rejected(
556 "source.signature is set but neither source.public_key nor \
557 plugin_registry.public_keys is configured"
558 .to_string(),
559 );
560 }
561
562 // Pinned registry keys: the signature must match one of them.
563 if !pinned.is_empty() {
564 let ok = pinned
565 .iter()
566 .any(|pk_hex| verify_with_key_quiet(artifact, pk_hex, sig_hex).is_some());
567 if !ok {
568 return rejected(format!(
569 "the signature does not verify against any of the {} pinned \
570 plugin_registry.public_keys keys",
571 pinned.len()
572 ));
573 }
574 }
575 Ok(())
576}
577
578/// Verify `sig_hex` over `artifact` with `pk_hex`, mapping every
579/// failure (bad key/sig material included — defense in depth behind
580/// validation) to a [`SourceError::SignatureRejected`] naming the key
581/// source (`field`).
582fn verify_with_key(
583 plugin: &str,
584 artifact: &[u8],
585 pk_hex: &str,
586 sig_hex: &str,
587 field: &str,
588) -> Result<(), SourceError> {
589 let reason = match verify_with_key_quiet(artifact, pk_hex, sig_hex) {
590 Some(()) => return Ok(()),
591 None => format!("the signature does not verify against {field}"),
592 };
593 Err(SourceError::SignatureRejected {
594 plugin: plugin.to_string(),
595 reason,
596 })
597}
598
599/// The quiet verifier: `Some(())` on success, `None` on any failure
600/// (bad hex, wrong length, bad key bytes, failed check). Callers
601/// translate to step-naming errors.
602fn verify_with_key_quiet(artifact: &[u8], pk_hex: &str, sig_hex: &str) -> Option<()> {
603 let key_bytes = decode_hex(pk_hex)?;
604 let sig_bytes = decode_hex(sig_hex)?;
605 let key_arr: [u8; 32] = key_bytes.try_into().ok()?;
606 let sig_arr: [u8; 64] = sig_bytes.try_into().ok()?;
607 let key = VerifyingKey::from_bytes(&key_arr).ok()?;
608 let sig = Signature::from_bytes(&sig_arr);
609 key.verify_strict(artifact, &sig).ok()
610}
611
612/// Decode a hex string (even length, ASCII hex digits only).
613fn decode_hex(s: &str) -> Option<Vec<u8>> {
614 if !s.len().is_multiple_of(2) || !s.bytes().all(|b| b.is_ascii_hexdigit()) {
615 return None;
616 }
617 let bytes = s.as_bytes();
618 let mut out = Vec::with_capacity(s.len() / 2);
619 for pair in bytes.chunks(2) {
620 let hi = (pair[0] as char).to_digit(16)?;
621 let lo = (pair[1] as char).to_digit(16)?;
622 out.push((hi * 16 + lo) as u8);
623 }
624 Some(out)
625}
626
627/// Write `bytes` to `path` atomically: tmp file in the SAME directory
628/// (rename must not cross filesystems), fsync before rename so a crash
629/// never leaves a truncated artifact under the final name, then
630/// rename. Restart-safe: a reader sees either the old complete file or
631/// the new complete file, never a partial write.
632fn atomic_write(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
633 if let Some(parent) = path.parent() {
634 if !parent.as_os_str().is_empty() {
635 std::fs::create_dir_all(parent)?;
636 }
637 }
638 let file_name = path
639 .file_name()
640 .map(|s| s.to_string_lossy().into_owned())
641 .unwrap_or_else(|| "artifact".to_string());
642 let tmp = path.with_file_name(format!(".{file_name}.tmp{}", std::process::id()));
643 {
644 let mut f = std::fs::File::create(&tmp)?;
645 f.write_all(bytes)?;
646 f.sync_all()?;
647 }
648 std::fs::rename(&tmp, path)?;
649 Ok(())
650}
651
652// --- minimal synchronous HTTPS GET -----------------------------------------
653//
654// See the module docs for why this exists (and why it is sync). The
655// shape mirrors events::webhook's client where the concerns overlap:
656// operator-configured URL (no SSRF boundary against the config
657// author), connect-time SSRF filter when enabled, public webpki
658// roots, HTTP/1.1, redirects not followed, one bounded exchange —
659// bounded by BOTH the per-read timeout and the per-publish `deadline`
660// (checked in the read loop; a drip-feeding registry cannot outlast
661// it by resetting the per-read timer).
662
663/// Perform one HTTPS GET and return the response body. `detail`
664/// errors are operator-facing strings (they land inside
665/// [`SourceError::DownloadFailed`]).
666fn https_get(url: &str, ssrf: &SsrfFilter, deadline: Instant) -> Result<Vec<u8>, String> {
667 let uri: hyper::Uri = url
668 .parse()
669 .map_err(|e| format!("URL does not parse: {e}"))?;
670 if uri.scheme_str() != Some("https") {
671 return Err("only https:// URLs are fetched".to_string());
672 }
673 let host = uri
674 .host()
675 .ok_or_else(|| "URL has no host".to_string())?
676 .to_string();
677 let port = uri.port_u16().unwrap_or(443);
678 let path_and_query = uri
679 .path_and_query()
680 .map(|p| p.as_str().to_string())
681 .unwrap_or_else(|| "/".to_string());
682 // ServerName wants the bare host (IPv6 brackets stripped for
683 // dialing/TLS, bracketed for the Host header).
684 let dial_host = host
685 .strip_prefix('[')
686 .and_then(|h| h.strip_suffix(']'))
687 .unwrap_or(&host)
688 .to_string();
689
690 // Resolve + (when enabled) SSRF-check at connect time, then dial
691 // the first address that accepts (DNS rebinding mitigation: the
692 // check runs against what we actually connect to).
693 let addrs: Vec<std::net::SocketAddr> =
694 std::net::ToSocketAddrs::to_socket_addrs(&(dial_host.as_str(), port))
695 .map_err(|e| format!("DNS resolution failed: {e}"))?
696 .collect();
697 if ssrf.is_enabled() {
698 for addr in &addrs {
699 if let Err(reason) = ssrf.check(addr.ip()) {
700 return Err(format!("SSRF egress filter rejected target: {reason}"));
701 }
702 }
703 }
704 let mut last_err = String::from("no addresses resolved");
705 let mut stream: Option<TcpStream> = None;
706 for addr in &addrs {
707 // Never spend more of the budget on a connect than the budget
708 // has left.
709 let connect_budget =
710 CONNECT_TIMEOUT.min(deadline.saturating_duration_since(Instant::now()));
711 if connect_budget.is_zero() {
712 return Err(deadline_detail());
713 }
714 match TcpStream::connect_timeout(addr, connect_budget) {
715 Ok(s) => {
716 stream = Some(s);
717 break;
718 }
719 Err(e) => last_err = e.to_string(),
720 }
721 }
722 let tcp = stream.ok_or_else(|| format!("connect failed: {last_err}"))?;
723 tcp.set_nodelay(true).ok();
724 tcp.set_write_timeout(Some(IO_TIMEOUT)).ok();
725
726 // TLS (rustls, sync): public roots, no client cert, HTTP/1.1 ALPN.
727 let name = rustls::pki_types::ServerName::try_from(dial_host.clone())
728 .map_err(|_| format!("host '{dial_host}' is not a usable TLS server name"))?;
729 let mut config = rustls::ClientConfig::builder()
730 .with_root_certificates(webpki_root_store())
731 .with_no_client_auth();
732 config.alpn_protocols = vec![b"http/1.1".to_vec()];
733 let conn = rustls::ClientConnection::new(Arc::new(config), name)
734 .map_err(|e| format!("TLS setup failed: {e}"))?;
735 let mut tls = rustls::StreamOwned::new(conn, tcp);
736
737 let host_header = if port == 443 {
738 host.clone()
739 } else {
740 format!("{host}:{port}")
741 };
742 let req = format!(
743 "GET {path_and_query} HTTP/1.1\r\nhost: {host_header}\r\n\
744 user-agent: {USER_AGENT}\r\naccept: application/wasm, \
745 application/octet-stream\r\nconnection: close\r\n\r\n"
746 );
747 tls.write_all(req.as_bytes())
748 .map_err(|e| format!("request write failed: {e}"))?;
749 tls.flush()
750 .map_err(|e| format!("request flush failed: {e}"))?;
751
752 // Read to EOF (Connection: close), capped at head cap + artifact
753 // cap and by the per-publish deadline: the per-read timeout below
754 // is clamped to the budget's remainder and re-checked every
755 // iteration, so a registry dripping one byte per read cannot reset
756 // its way past the budget. A peer that closes without a TLS
757 // close_notify (common for plain static hosts) surfaces as
758 // UnexpectedEof/reset AFTER bytes arrived — treated as
759 // end-of-stream; the framing parse below is what decides whether
760 // the body is complete.
761 let mut raw = Vec::with_capacity(8 * 1024);
762 let mut chunk = [0u8; 16 * 1024];
763 loop {
764 if raw.len() > MAX_HEAD_BYTES + MAX_ARTIFACT_BYTES {
765 return Err(format!(
766 "response exceeds the {} MiB artifact cap",
767 MAX_ARTIFACT_BYTES / (1024 * 1024)
768 ));
769 }
770 let remaining = deadline.checked_duration_since(Instant::now());
771 let Some(remaining) = remaining else {
772 return Err(deadline_detail());
773 };
774 // Per-read timeout, clamped to the remaining budget (the sock
775 // is owned by the TLS stream now).
776 tls.sock
777 .set_read_timeout(Some(IO_TIMEOUT.min(remaining)))
778 .ok();
779 match tls.read(&mut chunk) {
780 Ok(0) => break,
781 Ok(n) => raw.extend_from_slice(&chunk[..n]),
782 Err(e)
783 if matches!(
784 e.kind(),
785 std::io::ErrorKind::UnexpectedEof | std::io::ErrorKind::ConnectionReset
786 ) && !raw.is_empty() =>
787 {
788 break;
789 }
790 Err(e) => return Err(format!("response read failed: {e}")),
791 }
792 }
793 parse_response(&raw)
794}
795
796/// The step-naming detail for a budget exhaustion inside the fetch
797/// (flows into `SourceError::DownloadFailed`).
798fn deadline_detail() -> String {
799 format!(
800 "resolution deadline exceeded: the {}s per-publish budget for \
801 registry source resolution ran out mid-download",
802 RESOLUTION_BUDGET.as_secs()
803 )
804}
805
806/// Parse a raw HTTP/1.1 response: status line, headers, body framing
807/// (Content-Length, chunked, or read-to-EOF). Only 200 is accepted;
808/// redirects are deliberately not followed (a registry behind a
809/// redirect must be configured at its final URL — the same posture as
810/// webhook deliveries). Public for the integration tests in
811/// `tests/wasm_plugin_source.rs`, which pin the framing edge cases
812/// (truncation, caps, hostile chunk sizes) as pure functions.
813pub fn parse_response(raw: &[u8]) -> Result<Vec<u8>, String> {
814 let Some(pos) = find_subslice(raw, b"\r\n\r\n") else {
815 return Err("response head was not terminated (no blank line)".to_string());
816 };
817 if pos > MAX_HEAD_BYTES {
818 return Err(format!(
819 "response head exceeded the {MAX_HEAD_BYTES} byte cap"
820 ));
821 }
822 let head = std::str::from_utf8(&raw[..pos])
823 .map_err(|_| "response head is not valid UTF-8".to_string())?;
824 let status = head
825 .split_whitespace()
826 .nth(1)
827 .and_then(|s| s.parse::<u16>().ok())
828 .ok_or_else(|| "unparseable status line".to_string())?;
829 if status != 200 {
830 return Err(format!(
831 "HTTP {status} (redirects are not followed; configure the \
832 artifact's final URL)"
833 ));
834 }
835 let body = &raw[pos + 4..];
836 let mut chunked = false;
837 let mut content_length: Option<usize> = None;
838 for line in head.split("\r\n").skip(1) {
839 let Some((name, value)) = line.split_once(':') else {
840 continue;
841 };
842 let name = name.trim().to_ascii_lowercase();
843 let value = value.trim();
844 if name == "transfer-encoding" && value.to_ascii_lowercase().contains("chunked") {
845 chunked = true;
846 } else if name == "content-length" {
847 content_length = value.parse::<usize>().ok();
848 }
849 }
850 if chunked {
851 return decode_chunked(body);
852 }
853 if let Some(len) = content_length {
854 // Cap before truncation so a short body with a hostile
855 // over-cap Content-Length reports the cap, not a truncation.
856 if len > MAX_ARTIFACT_BYTES {
857 return Err(format!(
858 "Content-Length {len} exceeds the {} MiB artifact cap",
859 MAX_ARTIFACT_BYTES / (1024 * 1024)
860 ));
861 }
862 if body.len() < len {
863 return Err(format!(
864 "truncated body: Content-Length says {len}, got {} bytes",
865 body.len()
866 ));
867 }
868 return Ok(body[..len].to_vec());
869 }
870 // No framing header: Connection: close read-to-EOF is the body.
871 Ok(body.to_vec())
872}
873
874/// Minimal chunked transfer decoding (hex size; optional chunk
875/// extensions after `;` ignored; final 0-chunk ends the body, trailers
876/// after it are accepted and dropped). Public for the integration
877/// tests in `tests/wasm_plugin_source.rs`, which pin the hostile
878/// framings (overflow size lines, over-cap sizes, malformed hex,
879/// missing CRLFs) as pure functions.
880///
881/// Hostile-input hardening (review finding): every size flows through
882/// checked arithmetic. A size line like `ffffffffffffffff` used to
883/// wrap `out.len() + size` past the cap check (and `size + 2` past
884/// the truncation guard), reaching `&body[..size]` as a slice panic
885/// that killed startup or unwound the reload watcher. Now the size
886/// line must be 1-16 ASCII hex digits, the parsed size is rejected
887/// outright above the artifact cap, and both additions are checked.
888pub fn decode_chunked(mut body: &[u8]) -> Result<Vec<u8>, String> {
889 let cap_err = || {
890 format!(
891 "chunked body exceeds the {} MiB artifact cap",
892 MAX_ARTIFACT_BYTES / (1024 * 1024)
893 )
894 };
895 let mut out = Vec::new();
896 loop {
897 let Some(line_end) = find_subslice(body, b"\r\n") else {
898 return Err("chunked body: missing chunk size line".to_string());
899 };
900 let line = std::str::from_utf8(&body[..line_end])
901 .map_err(|_| "chunked body: size line is not valid UTF-8".to_string())?;
902 let size_str = line.split(';').next().unwrap_or("").trim();
903 // Validate BEFORE parsing: 1-16 ASCII hex digits only, so the
904 // parsed value can never be near usize::MAX.
905 if size_str.is_empty()
906 || size_str.len() > 16
907 || !size_str.bytes().all(|b| b.is_ascii_hexdigit())
908 {
909 return Err(format!("chunked body: bad chunk size '{size_str}'"));
910 }
911 let size = usize::from_str_radix(size_str, 16)
912 .map_err(|_| format!("chunked body: bad chunk size '{size_str}'"))?;
913 body = &body[line_end + 2..];
914 if size == 0 {
915 break; // trailers (if any) follow; ignored
916 }
917 // A single chunk over the cap is rejected outright; checked
918 // adds below keep the aggregate cap check unskippable.
919 if size > MAX_ARTIFACT_BYTES {
920 return Err(cap_err());
921 }
922 let next_len = out.len().checked_add(size).ok_or_else(cap_err)?;
923 if next_len > MAX_ARTIFACT_BYTES {
924 return Err(cap_err());
925 }
926 let need = size
927 .checked_add(2)
928 .ok_or_else(|| "chunked body: truncated chunk".to_string())?;
929 if body.len() < need {
930 return Err("chunked body: truncated chunk".to_string());
931 }
932 out.extend_from_slice(&body[..size]);
933 body = &body[need..]; // skip the chunk's trailing CRLF
934 }
935 Ok(out)
936}
937
938/// Find `needle` in `haystack` (a tiny `windows().position()` helper;
939/// `slice::windows` over the caps involved is fine at these sizes).
940fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
941 haystack.windows(needle.len()).position(|w| w == needle)
942}
943
944/// Public webpki root store for registry TLS verification. Local
945/// re-statement of the same set the webhook client and the pooled
946/// upstream connector default to (the wasm domain must not import
947/// events/security for it; the roots are the identical public set).
948/// Shared with the plugin-callout transport (`super::callout`), whose
949/// https targets verify against the same public set.
950pub(crate) fn webpki_root_store() -> rustls::RootCertStore {
951 let mut roots = rustls::RootCertStore::empty();
952 roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
953 roots
954}