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}