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}