Skip to main content

warble_cli/
lib.rs

1//! `warble_cli` — the native host library behind the `warble` binary.
2//!
3//! Holds the pieces the binary and the golden integration tests share. The headline is
4//! [`compile_project_to_ir`]: the real front-end host path — read a Warble project's files, resolve
5//! the declared context kind into a [`ContextLoader`] via [`BuiltinContextResolver`], and run the
6//! sans-IO core compiler with it injected. Resolving a kind is all this host does; it reads no
7//! semantic format, so a layer reaches it as a prepared-context document the producer wrote.
8//! (The binary's `dispatch`/`render`/`manifest`/`eval` subcommands stay in `main.rs`.)
9//!
10//! [`blast_radius_for_project`] reuses the same project-resolution path to answer a `blast_radius`
11//! query without running a full compile — the host side of the `warble blast-radius` subcommand.
12//!
13//! Component *resolution* — turning a profile's `use: <id>` mount into a directory to read
14//! `component.yml`/steps from — is entirely a host concern (see [`ComponentSource`] /
15//! `resolve_component_dir`): the core compiler never touches a filesystem, it only ever sees the
16//! already-resolved `HashMap<String, ComponentFile>` this module builds.
17//!
18//! This is the crate that becomes the `warble` binary itself — end users install just this one.
19//! It links in both dispatcher back-ends (`warble-claude-code`, `warble-vercel`) and nothing that
20//! speaks a semantic format; neither of those two crates is a standalone tool, and
21//! `warble dispatch --target ...` simply selects which linked-in back-end handles the compiled
22//! IR.
23
24pub mod gate;
25pub mod hub_fetch;
26pub mod overlay;
27
28use std::collections::HashMap;
29use std::fmt;
30use std::path::{Path, PathBuf};
31
32use sha2::{Digest, Sha256};
33use warble::{
34    read_raw_dir, BindingFile, ComponentFile, ContextLoader, PreparedContext, ProfileFile,
35    RawSourceContext,
36};
37use warble_claude_code::ir::SUPPORTED_IR_VERSION;
38
39/// The precedence class a [`ComponentSource`] belongs to. Precedence is a fixed rule *between*
40/// kinds — `Local` always outranks `Hub` — not derived from the order sources happen to be listed
41/// in. There is deliberately no rule *within* a kind: if the same component id is found in two
42/// sources of the same kind, `resolve_component_dir` treats that as ambiguous rather than
43/// guessing (e.g. "first in the list wins"), because nothing declares which of two equally-ranked
44/// sources should win.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum SourceKind {
47    /// A host- or product-specific component directory — e.g. a profile's own `components/`
48    /// override, or an external component library a consumer mounts alongside the Hub. Wins over
49    /// `Hub` whenever both define the same id.
50    Local,
51    /// The shared, generic Warble component library (this checkout's `hub/components`, or an
52    /// externally-supplied equivalent). The fallback tier: consulted only for ids no `Local`
53    /// source defines.
54    Hub,
55}
56
57impl fmt::Display for SourceKind {
58    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59        f.write_str(match self {
60            SourceKind::Local => "local",
61            SourceKind::Hub => "hub",
62        })
63    }
64}
65
66/// One explicitly-configured place a mounted component's files may live: `components_dir` is a
67/// directory whose immediate children are `<id>/component.yml` (+ its `steps/*`). The host builds
68/// the full ordered/kinded list up front (see [`compile_project_to_ir_with_sources`]) — resolution
69/// never discovers sources on its own (no ancestor/filesystem-root walk).
70#[derive(Debug, Clone)]
71pub struct ComponentSource {
72    pub kind: SourceKind,
73    pub components_dir: PathBuf,
74}
75
76impl ComponentSource {
77    pub fn local(components_dir: impl Into<PathBuf>) -> Self {
78        ComponentSource {
79            kind: SourceKind::Local,
80            components_dir: components_dir.into(),
81        }
82    }
83
84    pub fn hub(components_dir: impl Into<PathBuf>) -> Self {
85        ComponentSource {
86            kind: SourceKind::Hub,
87            components_dir: components_dir.into(),
88        }
89    }
90
91    fn candidate(&self, id: &str) -> PathBuf {
92        self.components_dir.join(id)
93    }
94}
95
96/// This checkout's own Hub component library — a fixed sibling of the `cli` crate's manifest dir,
97/// known at compile time (`CARGO_MANIFEST_DIR`), never discovered by walking the filesystem at
98/// runtime. This is what backs [`compile_project_to_ir`]'s default source list, so every in-repo
99/// example/eval profile keeps resolving its Hub-mounted components exactly as before.
100fn in_repo_hub_dir() -> PathBuf {
101    Path::new(env!("CARGO_MANIFEST_DIR"))
102        .join("..")
103        .join("hub")
104        .join("components")
105}
106
107/// The Hub source used when no `--hub-dir` override is given: this checkout's own
108/// `hub/components/` if it exists on disk, otherwise the per-user cache of the Hub release
109/// matching `hub_version` (or this binary's own version, when `hub_version` is `None`), fetching
110/// it over the network first if the cache doesn't already hold a verified copy.
111///
112/// The in-repo check comes first and is unconditional: an in-repo Hub directory always wins and
113/// never triggers a fetch, even when `hub_version` names a different version than this binary's
114/// own — "there is a Hub checked out right here" is decisive, not one input among several. This is also what keeps every in-repo example/eval profile (and the CLI's own
115/// integration tests) resolving offline, with no network access, when run from within this
116/// checkout.
117fn default_hub_source(hub_version: Option<&str>) -> Result<ComponentSource, String> {
118    let in_repo = in_repo_hub_dir();
119    if in_repo.is_dir() {
120        return Ok(ComponentSource::hub(in_repo));
121    }
122    let version = hub_version.unwrap_or(env!("CARGO_PKG_VERSION"));
123    let cached_dir = hub_fetch::ensure_cached_hub(version)?;
124    Ok(ComponentSource::hub(cached_dir))
125}
126
127/// The default two-source list used by [`compile_project_to_ir`]: the project's own `components/`
128/// (`Local`, highest precedence — lets a profile deliberately diverge from the Hub, e.g. an
129/// eval/demo substrate with intentionally different anatomy) plus the Hub (`Hub`, fallback) — see
130/// the crate's private `default_hub_source` helper for how the Hub source itself is resolved. A
131/// host mounting an *additional* local component library (e.g. a product-specific one, alongside
132/// the Hub) extends this list — see [`compile_project_to_ir_with_sources`].
133///
134/// Fallible since v0.7.0: resolving the Hub source can now require a network fetch (via that same
135/// `default_hub_source` helper), which can fail. Callers that previously relied on this being
136/// infallible need a `?` or `.expect(...)`.
137pub fn default_component_sources(project_dir: &Path) -> Result<Vec<ComponentSource>, String> {
138    default_component_sources_with_hub_version(project_dir, None)
139}
140
141/// Same as [`default_component_sources`], but lets the caller pick which Hub version to fetch
142/// when default resolution needs the network (i.e. no in-repo `hub/components` on disk). This is
143/// the seam the CLI's `--hub-version` flag threads through; `None` means "this binary's own
144/// version", the ordinary default.
145pub fn default_component_sources_with_hub_version(
146    project_dir: &Path,
147    hub_version: Option<&str>,
148) -> Result<Vec<ComponentSource>, String> {
149    Ok(vec![
150        ComponentSource::local(project_dir.join("components")),
151        default_hub_source(hub_version)?,
152    ])
153}
154
155/// Resolve where a mounted component's directory lives, against an explicit, ordered set of
156/// sources — never by walking the filesystem. Within the highest-precedence [`SourceKind`] that
157/// contains a match, exactly one source must define `id`; more than one is **ambiguous** (loud
158/// fail — no rule picks between equally-ranked sources) and zero across every source is
159/// **unresolved** (loud fail, listing everywhere it looked).
160fn resolve_component_dir(sources: &[ComponentSource], id: &str) -> Result<PathBuf, String> {
161    for kind in [SourceKind::Local, SourceKind::Hub] {
162        let matches: Vec<&ComponentSource> = sources
163            .iter()
164            .filter(|source| source.kind == kind)
165            .filter(|source| source.candidate(id).join("component.yml").is_file())
166            .collect();
167
168        match matches.as_slice() {
169            [] => continue,
170            [only] => return Ok(only.candidate(id)),
171            many => {
172                let dirs: Vec<String> = many
173                    .iter()
174                    .map(|s| s.candidate(id).display().to_string())
175                    .collect();
176                return Err(format!(
177                    "component '{id}' is ambiguous: found in {} '{kind}' sources ({}) — no \
178                     precedence rule distinguishes sources of the same kind",
179                    many.len(),
180                    dirs.join(", ")
181                ));
182            }
183        }
184    }
185
186    let searched: Vec<String> = sources
187        .iter()
188        .map(|s| format!("{} ({})", s.kind, s.candidate(id).display()))
189        .collect();
190    Err(format!(
191        "component '{id}' not found in any configured source: {}",
192        searched.join("; ")
193    ))
194}
195
196/// Turns a parsed `context/binding.yml` into the [`ContextLoader`] the compiler probes.
197///
198/// The counterpart of [`ComponentSource`] for context: components could already be supplied by a
199/// host, context could not. A host implements this to bind a semantic layer this checkout cannot
200/// read itself — one held by a service, for instance — without warble learning anything about it.
201///
202/// **A resolver is free to do no I/O at all, and `warble compile` must stay runnable offline and
203/// without credentials.** A host binding a remote layer is expected to resolve from a snapshot it
204/// pulled earlier, or to return a loader that declines the schema probes
205/// (`ContextLoader::can_answer`) rather than reaching for the network mid-compile.
206pub trait ContextResolver {
207    /// Build the loader for `binding`. `project_dir` is the Warble project directory, so a resolver
208    /// reading from disk can resolve a relative `binding.project` against it.
209    fn resolve(
210        &self,
211        binding: &BindingFile,
212        project_dir: &Path,
213    ) -> Result<Box<dyn ContextLoader>, String>;
214}
215
216/// The context kinds this checkout resolves without help: `raw_source` (read natively), `external`
217/// (read nothing) and `prepared` (read a projection the host already resolved). A host that needs
218/// another kind wraps this — delegating the ones it knows and handling its own.
219///
220/// `wren_project` is recognized but no longer resolved: it is matched only to say what to write
221/// instead, because a binding authored against an older warble names a kind this build cannot read
222/// rather than one it has never heard of.
223///
224/// `prepared` is the kind that does not require warble to speak the semantic format at all, so a
225/// host whose format has no adapter here binds through it rather than through a linked resolver —
226/// which is the only option open to a host that drives `warble` as a subprocess.
227pub struct BuiltinContextResolver;
228
229/// The retired kind name, kept in the reference host rather than in core: core no longer carries
230/// any semantic format's vocabulary, but the host that used to resolve this kind is the right
231/// place to recognize it and say what replaced it.
232const WREN_PROJECT_RETIRED: &str = "wren_project";
233
234impl ContextResolver for BuiltinContextResolver {
235    fn resolve(
236        &self,
237        binding: &BindingFile,
238        project_dir: &Path,
239    ) -> Result<Box<dyn ContextLoader>, String> {
240        // `external` names a layer that is not on this machine, so it must be resolved before any
241        // path is built — joining a locator like `remote-service://analytics` onto a directory would produce
242        // nonsense, and reading anything at all would break the offline guarantee.
243        if binding.kind == BindingFile::EXTERNAL {
244            return Ok(Box::new(warble::ExternalContext::new()));
245        }
246        // The remaining built-in kinds read a directory, so `project` is a path for them. A host
247        // kind's `project` may be anything at all, which is why this resolution lives per-kind
248        // rather than in the caller.
249        let path = project_dir.join(&binding.project);
250        match binding.kind.as_str() {
251            // Reading a wren project means linking a semantic-format library, which core no
252            // longer does. The kind is still matched by name so an older binding gets the
253            // migration, not the generic unknown-kind error that would leave the author guessing
254            // which of the remaining kinds replaced it.
255            WREN_PROJECT_RETIRED => Err(format!(
256                "`kind: {WREN_PROJECT_RETIRED}` is no longer resolved: warble reads no semantic \
257                 format itself. Have the host read {} and write a prepared-context document, then \
258                 bind it with `kind: {}` and a `document:` field naming that document.",
259                path.display(),
260                BindingFile::PREPARED
261            )),
262            BindingFile::RAW_SOURCE => {
263                let raw = read_raw_dir(&path)
264                    .map_err(|e| format!("failed to read {}: {e}", path.display()))?
265                    .ok_or_else(|| {
266                        format!(
267                            "binding declares `kind: {}` but {} has no schema.json",
268                            BindingFile::RAW_SOURCE,
269                            path.display()
270                        )
271                    })?;
272                Ok(Box::new(RawSourceContext::from_sources(&raw)))
273            }
274            BindingFile::PREPARED => {
275                // `project` stays what it is for every other kind: the bound layer's identity,
276                // echoed into the IR and the `{{project}}` placeholder. The document is a separate
277                // field, because pointing `project` at the file would put the file's name into
278                // every prompt — telling the agent it works on a project called "context.json".
279                let document_ref = binding
280                    .extra
281                    .get("document")
282                    .and_then(|v| v.as_str())
283                    .ok_or_else(|| {
284                        format!(
285                            "binding declares `kind: {}` but no `document:` — add the path to the \
286                             prepared-context document the host wrote, and keep `project:` as the \
287                             bound layer's name",
288                            BindingFile::PREPARED
289                        )
290                    })?;
291                let document_path = project_dir.join(document_ref);
292                // A missing or malformed document is an error rather than an unparseable context:
293                // the binding named a file the host was supposed to write, so its absence is a
294                // broken pipeline, not a project without a semantic layer.
295                let document = std::fs::read_to_string(&document_path)
296                    .map_err(|e| format!("failed to read {}: {e}", document_path.display()))?;
297                let context = PreparedContext::from_json(&document)
298                    .map_err(|e| format!("{}: {e}", document_path.display()))?;
299                Ok(Box::new(context))
300            }
301            other => Err(format!(
302                "unknown context kind '{other}' (this build resolves '{}', '{}' and '{}'). A \
303                 host that defines '{other}' must supply a ContextResolver for it — see \
304                 `compile_project_to_ir_with`.",
305                BindingFile::RAW_SOURCE,
306                BindingFile::EXTERNAL,
307                BindingFile::PREPARED
308            )),
309        }
310    }
311}
312
313/// Compile a Warble project directory into its IR JSON, resolving the context binding through
314/// [`BuiltinContextResolver`] and using the default component source list (project-local `components/` + this
315/// checkout's Hub — see [`default_component_sources`]). This is what every in-repo example/eval
316/// profile and integration test compiles through.
317pub fn compile_project_to_ir(project_dir: &Path) -> Result<serde_json::Value, String> {
318    compile_project_to_ir_with_sources(project_dir, &default_component_sources(project_dir)?)
319}
320
321/// Compile a Warble project directory into its IR JSON, resolving mounted components against an
322/// explicit, caller-supplied source list instead of the in-repo default (see [`ComponentSource`]).
323/// This is the seam a host outside this checkout uses to mount its own local component library
324/// alongside (or instead of) the Hub — e.g. `[ComponentSource::local(my_components),
325/// ComponentSource::hub(path_to_warble_hub)]`. All filesystem reads happen here; the core compiler
326/// stays sans-IO — it only ever receives the resolved `HashMap<String, ComponentFile>` built below.
327pub fn compile_project_to_ir_with_sources(
328    project_dir: &Path,
329    sources: &[ComponentSource],
330) -> Result<serde_json::Value, String> {
331    compile_project_to_ir_with(project_dir, sources, &BuiltinContextResolver)
332}
333
334/// As [`compile_project_to_ir_with_sources`], resolving the context binding through a
335/// caller-supplied [`ContextResolver`] instead of [`BuiltinContextResolver`]. This is the seam a
336/// host uses to bind a context kind this checkout cannot read — the context-side counterpart of
337/// passing your own [`ComponentSource`] list.
338pub fn compile_project_to_ir_with(
339    project_dir: &Path,
340    sources: &[ComponentSource],
341    resolver: &dyn ContextResolver,
342) -> Result<serde_json::Value, String> {
343    compile_project_to_ir_with_overlay(project_dir, sources, resolver, None)
344}
345
346/// As [`compile_project_to_ir_with`], additionally applying an overlay document to the parsed
347/// profile before compiling — see [`crate::overlay`] for what a patch may touch and why.
348///
349/// The overlay is applied here, between parsing `profile.yml` and resolving the mounted
350/// components, for two reasons that are easy to get wrong: a patch may add a mount, whose
351/// `component.yml` must therefore still be read; and every compile-time check then runs against
352/// the patched profile without any of them having to know a patch happened. The capability
353/// ceiling is the case that matters — it is enforced per mount, so a patch mounting a behavior
354/// the profile does not permit is refused with no ordering rule arranged for it.
355///
356/// `None` is exactly the previous behavior, which is why the three entry points above delegate
357/// here rather than duplicating the body.
358pub fn compile_project_to_ir_with_overlay(
359    project_dir: &Path,
360    sources: &[ComponentSource],
361    resolver: &dyn ContextResolver,
362    overlay_path: Option<&Path>,
363) -> Result<serde_json::Value, String> {
364    compile_project_to_ir_with_assets(project_dir, sources, resolver, overlay_path)
365        .map(|(ir, _)| ir)
366}
367
368/// A component's asset content, keyed by component id, each entry the authored relative path and
369/// the file's bytes.
370///
371/// Exists because compile is the only place that holds this: the compiler is sans-IO, and a
372/// dispatch has no component directory to re-read. See [`write_assets`].
373pub type CompiledAssets = std::collections::BTreeMap<String, Vec<(String, Vec<u8>)>>;
374
375/// Compile, and also hand back the asset content the manifest names.
376///
377/// The extra return value is what makes an IR's assets transportable. Callers that do not need it
378/// use [`compile_project_to_ir_with_overlay`], which drops it.
379pub fn compile_project_to_ir_with_assets(
380    project_dir: &Path,
381    sources: &[ComponentSource],
382    resolver: &dyn ContextResolver,
383    overlay_path: Option<&Path>,
384) -> Result<(serde_json::Value, CompiledAssets), String> {
385    let mut collected_assets: CompiledAssets = CompiledAssets::new();
386    let profile_path = project_dir.join("profile.yml");
387    let mut profile: ProfileFile = serde_yaml::from_str(&read_file(&profile_path)?)
388        .map_err(|e| format!("failed to parse {}: {e}", profile_path.display()))?;
389
390    if let Some(path) = overlay_path {
391        let overlay = overlay::read_overlay(path)?;
392        overlay::apply_overlay(&mut profile, &overlay)?;
393    }
394
395    let binding_path = project_dir.join(&profile.context.project);
396    let binding: BindingFile = serde_yaml::from_str(&read_file(&binding_path)?)
397        .map_err(|e| format!("failed to parse {}: {e}", binding_path.display()))?;
398
399    let context = resolver.resolve(&binding, project_dir)?;
400
401    let mut components: HashMap<String, ComponentFile> = HashMap::new();
402    let mut step_contents: HashMap<String, HashMap<String, String>> = HashMap::new();
403    // A profile-level slot's variants resolve against the profile's own directory, which is the
404    // project dir — the same shared rule component-level references follow, with the base dir
405    // being whatever owns the declaration.
406    let mut profile_slots: HashMap<String, HashMap<String, String>> = HashMap::new();
407    for slot in &profile.slots {
408        let mut variants: HashMap<String, String> = HashMap::new();
409        for (key, reference) in &slot.variants {
410            let label = format!("profile slot '{}' variant '{}'", slot.name, key);
411            let variant_path = resolve_file_ref(project_dir, reference, &label)?;
412            variants.insert(key.clone(), read_file(&variant_path)?);
413        }
414        profile_slots.insert(slot.name.clone(), variants);
415    }
416    let mut slot_contents = warble::SlotContents {
417        profile: profile_slots,
418        ..Default::default()
419    };
420
421    for mount in &profile.components {
422        let component_dir = resolve_component_dir(sources, &mount.use_id)?;
423        let component_path = component_dir.join("component.yml");
424        let mut component: ComponentFile = serde_yaml::from_str(&read_file(&component_path)?)
425            .map_err(|e| format!("failed to parse {}: {e}", component_path.display()))?;
426
427        let mut steps: HashMap<String, String> = HashMap::new();
428        for step in &component.llm_steps {
429            let step_path = resolve_file_ref(&component_dir, &step.prompt_ref, "prompt_ref")?;
430            steps.insert(step.name.clone(), read_file(&step_path)?);
431        }
432        step_contents.insert(component.id.clone(), steps);
433
434        // Slot variants are prompt text, so they are read here and travel into the IR — the same
435        // treatment `prompt_ref` gets, through the same resolution rule, because core never opens
436        // a file itself.
437        let mut slots: HashMap<String, HashMap<String, String>> = HashMap::new();
438        for slot in &component.slots {
439            let mut variants: HashMap<String, String> = HashMap::new();
440            for (key, reference) in &slot.variants {
441                let label = format!("slot '{}' variant '{}'", slot.name, key);
442                let variant_path = resolve_file_ref(&component_dir, reference, &label)?;
443                variants.insert(key.clone(), read_file(&variant_path)?);
444            }
445            slots.insert(slot.name.clone(), variants);
446        }
447        if !slots.is_empty() {
448            slot_contents.components.insert(component.id.clone(), slots);
449        }
450
451        // Assets are never read into the IR — only their identity (hash + size) is, and core never
452        // opens a file itself, so both are computed here before the declaration reaches `compile`.
453        //
454        // The bytes are kept rather than dropped. This is the only place that has them: `core` is
455        // sans-IO, and by dispatch there is no component directory at all — a Hub component was
456        // resolved over the network here, possibly on another machine. So the content has to leave
457        // compile alongside the IR or it cannot reach a runtime. See decision-101.
458        for asset in &mut component.assets {
459            let asset_path = resolve_file_ref(&component_dir, &asset.path, "asset")?;
460            let data = read_file_bytes(&asset_path)?;
461            asset.bytes = Some(data.len() as u64);
462            asset.hash = Some(format!("sha256:{:x}", Sha256::digest(&data)));
463            collected_assets
464                .entry(component.id.clone())
465                .or_default()
466                .push((asset.path.clone(), data));
467        }
468
469        components.insert(component.id.clone(), component);
470    }
471
472    let ir = warble::compile(
473        &profile,
474        &components,
475        &binding.project,
476        context.as_ref(),
477        &step_contents,
478        &slot_contents,
479    )
480    .map_err(|e| e.to_string())?;
481    Ok((ir, collected_assets))
482}
483
484/// The directory an IR's assets live in, derived from the IR's own path.
485///
486/// A sibling rather than a path recorded inside the IR: the IR is a portable document and should not
487/// carry a filesystem location that stops being true the moment it is copied. Both dispatch surfaces
488/// already receive the IR path, so both can derive this.
489pub fn asset_dir_for_ir(ir_path: &Path) -> PathBuf {
490    let name = ir_path
491        .file_stem()
492        .map(|stem| format!("{}.assets", stem.to_string_lossy()))
493        .unwrap_or_else(|| "ir.assets".to_string());
494    ir_path
495        .parent()
496        .unwrap_or_else(|| Path::new("."))
497        .join(name)
498}
499
500/// Write compiled asset content under `root`, as `<root>/<component-id>/<authored path>`.
501///
502/// Nothing is created when no component declares an asset, so a project without assets emits exactly
503/// what it emitted before this existed.
504pub fn write_assets(root: &Path, assets: &CompiledAssets) -> Result<(), String> {
505    if assets.is_empty() {
506        return Ok(());
507    }
508    // The root is this function's own output directory, created here rather than incidentally by
509    // the first file's parent. It has to exist before any containment check, since resolving a
510    // location is relative to a real root — and creating the directory it was told to write into is
511    // not the same as following a symlink out of it.
512    //
513    // `root` itself is a **caller-trusted anchor**, not something the containment check can validate:
514    // if it is already a symlink to somewhere else, `create_dir_all` is a no-op, canonicalization
515    // resolves through it, and every per-file check then measures containment against that resolved
516    // location. `out_dir`/`cwd` in `land_assets` have the identical property. Reaching it needs write
517    // access to the exact path before this runs, which is a weaker threat model than the "an IR
518    // arrives from anywhere" one governing the manifest — stated here so the question is not
519    // rediscovered as a surprise.
520    std::fs::create_dir_all(root)
521        .map_err(|e| format!("failed to create {}: {e}", root.display()))?;
522    for (component_id, files) in assets {
523        for (relative, data) in files {
524            assert_contained_relative_path(relative, component_id)?;
525            let target = root.join(component_id).join(relative);
526            // Checked before ANY filesystem effect. An earlier version created the parent first so
527            // canonicalization had something to resolve, which meant a symlinked component
528            // directory got a directory created through it before the check refused the write —
529            // "refused rather than followed" has to include not creating anything either.
530            // `assert_resolves_inside` walks to the nearest existing ancestor, so it needs nothing
531            // to have been created.
532            assert_resolves_inside(root, &target, "asset target")?;
533            if let Some(parent) = target.parent() {
534                std::fs::create_dir_all(parent)
535                    .map_err(|e| format!("failed to create {}: {e}", parent.display()))?;
536            }
537            std::fs::write(&target, data)
538                .map_err(|e| format!("failed to write {}: {e}", target.display()))?;
539        }
540    }
541    Ok(())
542}
543
544fn read_file(path: &Path) -> Result<String, String> {
545    std::fs::read_to_string(path).map_err(|e| format!("failed to read {}: {e}", path.display()))
546}
547
548/// Reads a file as raw bytes, for content that is fingerprinted rather than rendered. An asset may
549/// be a binary (an image, a font, an archive), so it cannot go through [`read_file`]'s UTF-8
550/// decode — and it never needs to, since only its hash and size reach the IR.
551fn read_file_bytes(path: &Path) -> Result<Vec<u8>, String> {
552    std::fs::read(path).map_err(|e| format!("failed to read {}: {e}", path.display()))
553}
554
555/// Resolves a file reference the same way `prompt_ref` does today (and, by convention,
556/// `eval.template_ref`): relative to the directory that owns it — a component directory for
557/// `prompt_ref`, and, for a future profile-level slot, the profile directory. This is the one
558/// shared rule every resolved-file-reference field should follow, loudly, at compile time:
559///
560/// - the reference must stay inside `base_dir` — an absolute path, or one containing a `..`
561///   segment, is rejected rather than silently escaping `base_dir` (`PathBuf::join` alone allows
562///   both: joining an absolute path replaces the base entirely, and `..` segments are never
563///   normalized away);
564/// - the resolved path must name a file that exists on disk — a missing file is a compile-time
565///   error, never a silent skip.
566///
567/// `label` names the kind of reference in the error message (e.g. `"prompt_ref"`), so this
568/// function stays generic: it never needs to know about `LlmStep`, `EvalSpec`, or any future
569/// slot/asset field.
570///
571/// Two different kinds of future callers are expected to reuse this function unchanged, and they
572/// differ only in what they do with the `Ok(PathBuf)` it returns:
573///
574/// - a **slot variant** reads the resolved file's *content* into the IR, exactly like `prompt_ref`
575///   does today (see the call site in [`compile_project_to_ir_with`]);
576/// - an **asset** only needs the existence check this function already performs — the resolved
577///   path becomes a manifest entry (a file that must exist on disk at render/dispatch time), and
578///   its content is never read into the IR.
579fn resolve_file_ref(base_dir: &Path, reference: &str, label: &str) -> Result<PathBuf, String> {
580    let ref_path = Path::new(reference);
581    if ref_path.is_absolute()
582        || ref_path
583            .components()
584            .any(|c| matches!(c, std::path::Component::ParentDir))
585    {
586        return Err(format!(
587            "{label} '{reference}' must be a relative path inside its own directory (no '..' or absolute paths)"
588        ));
589    }
590    let resolved = base_dir.join(ref_path);
591    if !resolved.is_file() {
592        return Err(format!(
593            "{label} '{reference}' does not exist: {}",
594            resolved.display()
595        ));
596    }
597    Ok(resolved)
598}
599
600#[cfg(test)]
601mod resolve_file_ref_tests {
602    use super::resolve_file_ref;
603
604    #[test]
605    fn accepts_a_relative_path_to_an_existing_file() {
606        let dir = tempfile::tempdir().unwrap();
607        std::fs::write(dir.path().join("ask.md"), "Ask something.\n").unwrap();
608
609        let resolved = resolve_file_ref(dir.path(), "ask.md", "prompt_ref").unwrap();
610
611        assert_eq!(resolved, dir.path().join("ask.md"));
612    }
613
614    #[test]
615    fn accepts_a_relative_path_in_a_subdirectory() {
616        let dir = tempfile::tempdir().unwrap();
617        std::fs::create_dir(dir.path().join("steps")).unwrap();
618        std::fs::write(dir.path().join("steps/ask.md"), "Ask something.\n").unwrap();
619
620        let resolved = resolve_file_ref(dir.path(), "steps/ask.md", "prompt_ref").unwrap();
621
622        assert_eq!(resolved, dir.path().join("steps/ask.md"));
623    }
624
625    #[test]
626    fn rejects_a_dotdot_escape() {
627        let dir = tempfile::tempdir().unwrap();
628
629        let err = resolve_file_ref(dir.path(), "../secrets.md", "prompt_ref").unwrap_err();
630
631        assert!(err.contains("prompt_ref"));
632        assert!(err.contains("../secrets.md"));
633        assert!(err.contains(".."));
634    }
635
636    #[test]
637    fn rejects_a_dotdot_segment_in_the_middle_of_the_path() {
638        let dir = tempfile::tempdir().unwrap();
639
640        let err = resolve_file_ref(dir.path(), "steps/../../escape.md", "prompt_ref").unwrap_err();
641
642        assert!(err.contains("steps/../../escape.md"));
643    }
644
645    #[test]
646    fn rejects_an_absolute_path() {
647        let dir = tempfile::tempdir().unwrap();
648
649        let err = resolve_file_ref(dir.path(), "/etc/passwd", "prompt_ref").unwrap_err();
650
651        assert!(err.contains("prompt_ref"));
652        assert!(err.contains("/etc/passwd"));
653    }
654
655    #[test]
656    fn rejects_a_missing_file() {
657        let dir = tempfile::tempdir().unwrap();
658
659        let err = resolve_file_ref(dir.path(), "steps/missing.md", "prompt_ref").unwrap_err();
660
661        assert!(err.contains("prompt_ref"));
662        assert!(err.contains("steps/missing.md"));
663        assert!(err.contains("does not exist"));
664    }
665}
666
667/// Fetch the bound layer's reported impact for `node`. Resolves the context the same way
668/// [`compile_project_to_ir`] does (profile.yml → context binding → [`ContextLoader`]), but stops
669/// short of a full compile. Nothing is computed here: the answer is whatever the layer supplied, and
670/// a layer that supplied no analysis is an error rather than an empty radius.
671pub fn blast_radius_for_project(
672    project_dir: &Path,
673    node: &str,
674) -> Result<Option<warble::HostImpact>, String> {
675    blast_radius_for_project_with(project_dir, node, &BuiltinContextResolver)
676}
677
678/// As [`blast_radius_for_project`], resolving the context binding through a caller-supplied
679/// [`ContextResolver`]. Kept in step with [`compile_project_to_ir_with`] so a host kind is bindable
680/// on both paths — a lineage query over a context only the host can load is no less valid than a
681/// compile over it.
682pub fn blast_radius_for_project_with(
683    project_dir: &Path,
684    node: &str,
685    resolver: &dyn ContextResolver,
686) -> Result<Option<warble::HostImpact>, String> {
687    let profile_path = project_dir.join("profile.yml");
688    let profile: ProfileFile = serde_yaml::from_str(&read_file(&profile_path)?)
689        .map_err(|e| format!("failed to parse {}: {e}", profile_path.display()))?;
690
691    let binding_path = project_dir.join(&profile.context.project);
692    let binding: BindingFile = serde_yaml::from_str(&read_file(&binding_path)?)
693        .map_err(|e| format!("failed to parse {}: {e}", binding_path.display()))?;
694
695    let context = resolver.resolve(&binding, project_dir)?;
696
697    if !context.is_parseable() {
698        return Err(format!(
699            "bound context '{}' is not parseable — cannot compute blast radius",
700            binding.project
701        ));
702    }
703
704    // The answer comes from the layer's own analysis rather than a traversal here. A layer that
705    // supplied none cannot be gated: allowing on the strength of an answer nobody gave is the
706    // false negative a gate exists to prevent, so that is an error rather than an empty radius.
707    let analysis = context.host_analysis().ok_or_else(|| {
708        format!(
709            "bound context '{}' supplied no impact analysis — nothing to gate against",
710            binding.project
711        )
712    })?;
713
714    // A node the analysis does not mention is a different case: it resolves, with nothing
715    // downstream. That is deliberate and long-standing — asking about an id the layer never
716    // declared is answered, not refused.
717    Ok(analysis.impact.get(node).cloned())
718}
719
720#[cfg(test)]
721mod component_source_tests {
722    use super::{resolve_component_dir, ComponentSource};
723    use std::fs;
724    use std::path::Path;
725
726    /// Create `<dir>/<id>/component.yml` (contents are irrelevant — resolution only checks the
727    /// file exists) so a source directory "defines" `id`.
728    fn stub_component(dir: &Path, id: &str) {
729        let component_dir = dir.join(id);
730        fs::create_dir_all(&component_dir).unwrap();
731        fs::write(component_dir.join("component.yml"), "id: placeholder\n").unwrap();
732    }
733
734    #[test]
735    fn resolves_from_the_only_source_that_defines_it() {
736        let hub = tempfile::tempdir().unwrap();
737        stub_component(hub.path(), "answer_query");
738
739        let sources = vec![ComponentSource::hub(hub.path())];
740        let resolved = resolve_component_dir(&sources, "answer_query").unwrap();
741        assert_eq!(resolved, hub.path().join("answer_query"));
742    }
743
744    #[test]
745    fn local_overrides_hub_when_both_define_the_same_id() {
746        let local = tempfile::tempdir().unwrap();
747        let hub = tempfile::tempdir().unwrap();
748        stub_component(local.path(), "answer_query");
749        stub_component(hub.path(), "answer_query");
750
751        let sources = vec![
752            ComponentSource::local(local.path()),
753            ComponentSource::hub(hub.path()),
754        ];
755        let resolved = resolve_component_dir(&sources, "answer_query").unwrap();
756        assert_eq!(
757            resolved,
758            local.path().join("answer_query"),
759            "a Local source must win over a Hub source defining the same id"
760        );
761    }
762
763    #[test]
764    fn falls_back_to_hub_when_local_does_not_define_the_id() {
765        let local = tempfile::tempdir().unwrap();
766        let hub = tempfile::tempdir().unwrap();
767        stub_component(hub.path(), "answer_query");
768
769        let sources = vec![
770            ComponentSource::local(local.path()),
771            ComponentSource::hub(hub.path()),
772        ];
773        let resolved = resolve_component_dir(&sources, "answer_query").unwrap();
774        assert_eq!(resolved, hub.path().join("answer_query"));
775    }
776
777    #[test]
778    fn unresolved_id_is_a_loud_fail_naming_every_source_searched() {
779        let local = tempfile::tempdir().unwrap();
780        let hub = tempfile::tempdir().unwrap();
781
782        let sources = vec![
783            ComponentSource::local(local.path()),
784            ComponentSource::hub(hub.path()),
785        ];
786        let err = resolve_component_dir(&sources, "missing").unwrap_err();
787        assert!(err.contains("missing"));
788        assert!(err.contains(&local.path().display().to_string()));
789        assert!(err.contains(&hub.path().display().to_string()));
790    }
791
792    #[test]
793    fn same_id_in_two_same_kind_sources_is_ambiguous_not_first_match_wins() {
794        let local_a = tempfile::tempdir().unwrap();
795        let local_b = tempfile::tempdir().unwrap();
796        stub_component(local_a.path(), "answer_query");
797        stub_component(local_b.path(), "answer_query");
798
799        let sources = vec![
800            ComponentSource::local(local_a.path()),
801            ComponentSource::local(local_b.path()),
802        ];
803        let err = resolve_component_dir(&sources, "answer_query").unwrap_err();
804        assert!(err.contains("ambiguous"));
805        assert!(err.contains(&local_a.path().display().to_string()));
806        assert!(err.contains(&local_b.path().display().to_string()));
807    }
808
809    #[test]
810    fn ambiguous_same_kind_sources_do_not_fall_through_to_a_lower_kind() {
811        // Even though `hub` alone would resolve `answer_query` unambiguously, an ambiguous match
812        // at the higher-precedence `Local` kind must fail loud rather than silently falling back.
813        let local_a = tempfile::tempdir().unwrap();
814        let local_b = tempfile::tempdir().unwrap();
815        let hub = tempfile::tempdir().unwrap();
816        stub_component(local_a.path(), "answer_query");
817        stub_component(local_b.path(), "answer_query");
818        stub_component(hub.path(), "answer_query");
819
820        let sources = vec![
821            ComponentSource::local(local_a.path()),
822            ComponentSource::local(local_b.path()),
823            ComponentSource::hub(hub.path()),
824        ];
825        let err = resolve_component_dir(&sources, "answer_query").unwrap_err();
826        assert!(err.contains("ambiguous"));
827    }
828}
829
830/// `warble_eval_runner::ComplianceIr` (the type `eval compliance` deserializes into) is
831/// *deliberately* narrower than `WarbleIr` — its own doc comment says so, so a compiled-in
832/// `warble_ir_version` field never belongs on that type. But `eval compliance` is not fed an
833/// arbitrary subset: every real caller hands it the same complete `ir.json` `dispatch`/`manifest`
834/// consume (confirmed by `eval/golden/compliance/ground_truth.yaml`'s own comment — its two golden
835/// IRs, `examples/mutate-agent/ir.golden.json` and `examples/analysis-agent/ir.golden.json`, are "reused
836/// as-is, not new fixtures"). So the version gate belongs here, at the CLI boundary, checked on the
837/// raw JSON before `ComplianceIr` ever sees it — against the same
838/// `warble_claude_code::ir::SUPPORTED_IR_VERSION` that the binary's own `load_ir` already gates
839/// `dispatch`/`manifest` against, so every CLI-level IR consumer rejects an out-of-range version
840/// the same way.
841pub fn check_compliance_ir_version(raw: &str, path: &Path) -> Result<(), String> {
842    let parsed: serde_json::Value = serde_json::from_str(raw).map_err(|e| {
843        format!(
844            "failed to parse IR {} for version check: {e}",
845            path.display()
846        )
847    })?;
848    // Absent and present-but-not-a-string are reported separately on purpose: collapsing them sends
849    // someone whose IR *does* carry the field looking for a missing key that is right there.
850    match parsed.get("warble_ir_version") {
851        None => Err(format!(
852            "IR {} has no warble_ir_version field — eval compliance requires a complete compiled \
853             IR, not a hand-written subset",
854            path.display()
855        )),
856        Some(serde_json::Value::String(v)) if v == SUPPORTED_IR_VERSION => Ok(()),
857        Some(serde_json::Value::String(v)) => Err(format!(
858            "unsupported warble_ir_version '{v}' in {} (eval compliance understands: {SUPPORTED_IR_VERSION})",
859            path.display()
860        )),
861        Some(other) => Err(format!(
862            "warble_ir_version in {} is {}, not a string — eval compliance understands: \
863             {SUPPORTED_IR_VERSION}",
864            path.display(),
865            match other {
866                serde_json::Value::Null => "null",
867                serde_json::Value::Bool(_) => "a boolean",
868                serde_json::Value::Number(_) => "a number",
869                serde_json::Value::Array(_) => "an array",
870                _ => "an object",
871            }
872        )),
873    }
874}
875
876#[cfg(test)]
877mod compliance_ir_version_tests {
878    use super::{check_compliance_ir_version, SUPPORTED_IR_VERSION};
879    use std::path::Path;
880
881    fn check(raw: &str) -> Result<(), String> {
882        check_compliance_ir_version(raw, Path::new("ir.json"))
883    }
884
885    #[test]
886    fn the_supported_version_passes() {
887        let raw = format!(r#"{{"warble_ir_version": "{SUPPORTED_IR_VERSION}", "components": []}}"#);
888        assert!(check(&raw).is_ok(), "got: {:?}", check(&raw));
889    }
890
891    #[test]
892    fn a_missing_field_is_reported_as_missing() {
893        let err = check(r#"{"components": []}"#).expect_err("must reject");
894        assert!(err.contains("has no warble_ir_version"), "got: {err}");
895    }
896
897    #[test]
898    fn an_out_of_range_version_is_reported_as_unsupported() {
899        let err = check(r#"{"warble_ir_version": "0.2", "components": []}"#).expect_err("reject");
900        assert!(
901            err.contains("unsupported warble_ir_version '0.2'"),
902            "got: {err}"
903        );
904        assert!(
905            err.contains(SUPPORTED_IR_VERSION),
906            "must name what it does accept: {err}"
907        );
908    }
909
910    #[test]
911    fn a_version_that_merely_starts_with_the_supported_one_is_rejected() {
912        // Guards against a `starts_with` regression: "0.30" is not "0.3".
913        let raw = format!(r#"{{"warble_ir_version": "{SUPPORTED_IR_VERSION}0"}}"#);
914        let err = check(&raw).expect_err("a superstring is a different version");
915        assert!(err.contains("unsupported warble_ir_version"), "got: {err}");
916    }
917
918    /// The matrix this module exists for: every non-string JSON type must be reported as a *type*
919    /// error naming what it actually is — never as "the field is missing", which would send someone
920    /// looking for a key that is sitting right there. `null` is the one that regressed before.
921    #[test]
922    fn every_non_string_type_is_reported_as_a_type_error() {
923        for (literal, expected) in [
924            ("null", "is null"),
925            ("true", "is a boolean"),
926            ("3", "is a number"),
927            ("[]", "is an array"),
928            ("{}", "is an object"),
929        ] {
930            let raw = format!(r#"{{"warble_ir_version": {literal}}}"#);
931            let err = check(&raw).expect_err(&format!("{literal} must be rejected"));
932            assert!(
933                err.contains(expected),
934                "for {literal} expected '{expected}', got: {err}"
935            );
936            assert!(
937                !err.contains("has no warble_ir_version"),
938                "for {literal} the field is present, not missing: {err}"
939            );
940        }
941    }
942
943    #[test]
944    fn unparseable_json_is_reported_as_a_parse_failure() {
945        let err = check("not json at all").expect_err("must reject");
946        assert!(err.contains("failed to parse IR"), "got: {err}");
947    }
948}
949
950/// Land the assets an IR's components declare into `out_dir`, verifying each against its manifest.
951///
952/// **Why this exists.** IR 0.7 lets a component declare the files it needs and compile records their
953/// identity, but nothing consumed that manifest: a component declared its files and the agent then
954/// ran in a directory that did not contain them, with no error at all. Landing them is what makes
955/// the declaration mean something.
956///
957/// Content comes from the directory compile wrote beside the IR ([`asset_dir_for_ir`]) — compile is
958/// the only place that has it, since the compiler is sans-IO and a Hub component was resolved over
959/// the network there. See decision-101.
960///
961/// **Both failure modes are loud.** A manifest entry with no file in the asset directory, and one
962/// whose content does not hash to the recorded value, each stop the dispatch. Silence is the bug
963/// being fixed, and a half-landed asset set is harder to diagnose than a refusal.
964pub fn land_assets(
965    ir: &serde_json::Value,
966    ir_path: &Path,
967    out_dir: &Path,
968) -> Result<Vec<PathBuf>, String> {
969    let source_root = asset_dir_for_ir(ir_path);
970    // Read and verify EVERY asset before writing any of them. Writing as each one verifies would
971    // leave a component's earlier files on disk when a later one fails — a half-landed set, which
972    // this function's contract and `docs/spec/ir-schema.md` both promise not to produce. That
973    // promise was prose before it was code: a fixture declaring one asset per component cannot tell
974    // the two apart.
975    let mut pending: Vec<(PathBuf, Vec<u8>)> = Vec::new();
976    let Some(components) = ir.get("components").and_then(|c| c.as_array()) else {
977        return Ok(Vec::new());
978    };
979    for node in components {
980        let component_id = node
981            .get("id")
982            .and_then(|v| v.as_str())
983            .unwrap_or("<unnamed>");
984        let Some(assets) = node.get("assets").and_then(|a| a.as_array()) else {
985            continue;
986        };
987        for asset in assets {
988            let relative = asset.get("path").and_then(|v| v.as_str()).ok_or_else(|| {
989                format!("component '{component_id}' has an asset with no path in the manifest")
990            })?;
991            let expected = asset.get("hash").and_then(|v| v.as_str()).ok_or_else(|| {
992                format!(
993                    "asset '{relative}' of component '{component_id}' has no hash in the manifest"
994                )
995            })?;
996            // The manifest is re-validated here, not trusted. Compile checks the *authored*
997            // reference against the component directory, but an IR is a document that can arrive
998            // from anywhere, and by this point nothing has looked at the path again. Without this,
999            // a manifest entry of `../../../etc/whatever` is an arbitrary file write.
1000            assert_contained_relative_path(relative, component_id)?;
1001            let source = source_root.join(component_id).join(relative);
1002            // Everything about the source is decided before it is opened. Ordering here has bitten
1003            // twice: checking containment first masked an absent travelling directory with a
1004            // resolution error, and reading first let a FIFO planted at a declared path hang the
1005            // read forever — before any check could refuse it, and for an in-root path the symlink
1006            // check cannot help with. So: existence, then location, then file kind, then read.
1007            let metadata = std::fs::symlink_metadata(&source).map_err(|e| {
1008                format!(
1009                    "asset '{relative}' of component '{component_id}' is declared in the IR but \
1010                     missing from {}: {e}. An IR's assets travel in that directory; copying the IR \
1011                     without it leaves a component without the files it declared.",
1012                    source_root.display()
1013                )
1014            })?;
1015            assert_resolves_inside(&source_root, &source, "asset source")?;
1016            if !source
1017                .metadata()
1018                .map_err(|e| format!("failed to inspect {}: {e}", source.display()))?
1019                .is_file()
1020            {
1021                let message = [
1022                    format!("asset '{relative}' of component '{component_id}' is not a regular"),
1023                    format!("file (found {:?}).", metadata.file_type()),
1024                    "Reading a pipe or device would block the dispatch indefinitely instead of"
1025                        .to_string(),
1026                    "failing, so anything that is not a plain file is refused.".to_string(),
1027                ]
1028                .join(" ");
1029                return Err(message);
1030            }
1031            let data = std::fs::read(&source)
1032                .map_err(|e| format!("failed to read {}: {e}", source.display()))?;
1033            let actual = format!("sha256:{:x}", Sha256::digest(&data));
1034            if actual != expected {
1035                return Err(format!(
1036                    "asset '{relative}' of component '{component_id}' does not match its manifest \
1037                     (expected {expected}, found {actual}). The file changed after the IR was \
1038                     compiled; recompile rather than dispatching content the manifest does not name."
1039                ));
1040            }
1041            let target = out_dir.join(relative);
1042            assert_resolves_inside(out_dir, &target, "asset target")?;
1043            pending.push((target, data));
1044        }
1045    }
1046
1047    let mut landed = Vec::with_capacity(pending.len());
1048    for (target, data) in pending {
1049        if let Some(parent) = target.parent() {
1050            std::fs::create_dir_all(parent)
1051                .map_err(|e| format!("failed to create {}: {e}", parent.display()))?;
1052        }
1053        std::fs::write(&target, &data)
1054            .map_err(|e| format!("failed to write {}: {e}", target.display()))?;
1055        landed.push(target);
1056    }
1057    Ok(landed)
1058}
1059
1060/// Refuse a manifest path that is absolute or that climbs out of the directory it is joined to.
1061///
1062/// Mirrors the compile-time rule in [`resolve_file_ref`], and for the same reason: `PathBuf::join`
1063/// alone allows both escapes — joining an absolute path replaces the base entirely, and `..`
1064/// segments are never normalized away. The check has to exist on both sides because compile validates
1065/// what an author wrote while this validates what a manifest says, and those are different documents.
1066fn assert_contained_relative_path(relative: &str, component_id: &str) -> Result<(), String> {
1067    let path = Path::new(relative);
1068    if path.is_absolute()
1069        || path
1070            .components()
1071            .any(|c| matches!(c, std::path::Component::ParentDir))
1072    {
1073        let message = [
1074            format!("asset path '{relative}' of component '{component_id}' must be a relative"),
1075            "path with no '..' segments. A manifest naming a path outside the directory it"
1076                .to_string(),
1077            "lands in would be an arbitrary file write, so it is refused rather than".to_string(),
1078            "resolved.".to_string(),
1079        ]
1080        .join(" ");
1081        return Err(message);
1082    }
1083    Ok(())
1084}
1085
1086/// Confirm a path still resolves inside `root` once the filesystem has had its say.
1087///
1088/// The string check above is not enough on its own, and this is the second half of the same lesson:
1089/// it inspects what the manifest *says*, and a syntactically clean relative path — no `..`, not
1090/// absolute — still escapes when a component of it is a symlink pointing elsewhere. That is
1091/// reachable: the Agent SDK back-end's working directory is the bound project, a real directory
1092/// somebody else may have written to, and a CLI `out_dir` may hold whatever a previous step left.
1093///
1094/// `target` need not exist yet: the nearest existing ancestor is canonicalized and checked, and the
1095/// segments below it cannot be a symlink because nothing has created them. `root` must exist.
1096fn assert_resolves_inside(root: &Path, target: &Path, what: &str) -> Result<(), String> {
1097    let canonical_root = root
1098        .canonicalize()
1099        .map_err(|e| format!("failed to resolve {}: {e}", root.display()))?;
1100
1101    let mut existing = target;
1102    let anchor = loop {
1103        if existing.exists() {
1104            break existing
1105                .canonicalize()
1106                .map_err(|e| format!("failed to resolve {}: {e}", existing.display()))?;
1107        }
1108        match existing.parent() {
1109            Some(parent) => existing = parent,
1110            None => {
1111                return Err(format!(
1112                    "{what} '{}' has no resolvable ancestor",
1113                    target.display()
1114                ))
1115            }
1116        }
1117    };
1118
1119    if !anchor.starts_with(&canonical_root) {
1120        let message = [
1121            format!(
1122                "{what} '{}' resolves outside {}",
1123                target.display(),
1124                canonical_root.display()
1125            ),
1126            "once symlinks are followed. A path that looks contained but is not is refused rather"
1127                .to_string(),
1128            "than followed, because the manifest is data and the filesystem is what decides where"
1129                .to_string(),
1130            "a write actually lands.".to_string(),
1131        ]
1132        .join(" ");
1133        return Err(message);
1134    }
1135    Ok(())
1136}