pub mod gate;
pub mod hub_fetch;
pub mod overlay;
use std::collections::HashMap;
use std::fmt;
use std::path::{Path, PathBuf};
use sha2::{Digest, Sha256};
use warble::{
read_raw_dir, BindingFile, ComponentFile, ContextLoader, PreparedContext, ProfileFile,
RawSourceContext,
};
use warble_claude_code::ir::SUPPORTED_IR_VERSION;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SourceKind {
Local,
Hub,
}
impl fmt::Display for SourceKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
SourceKind::Local => "local",
SourceKind::Hub => "hub",
})
}
}
#[derive(Debug, Clone)]
pub struct ComponentSource {
pub kind: SourceKind,
pub components_dir: PathBuf,
}
impl ComponentSource {
pub fn local(components_dir: impl Into<PathBuf>) -> Self {
ComponentSource {
kind: SourceKind::Local,
components_dir: components_dir.into(),
}
}
pub fn hub(components_dir: impl Into<PathBuf>) -> Self {
ComponentSource {
kind: SourceKind::Hub,
components_dir: components_dir.into(),
}
}
fn candidate(&self, id: &str) -> PathBuf {
self.components_dir.join(id)
}
}
fn in_repo_hub_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("hub")
.join("components")
}
fn default_hub_source(hub_version: Option<&str>) -> Result<ComponentSource, String> {
let in_repo = in_repo_hub_dir();
if in_repo.is_dir() {
return Ok(ComponentSource::hub(in_repo));
}
let version = hub_version.unwrap_or(env!("CARGO_PKG_VERSION"));
let cached_dir = hub_fetch::ensure_cached_hub(version)?;
Ok(ComponentSource::hub(cached_dir))
}
pub fn default_component_sources(project_dir: &Path) -> Result<Vec<ComponentSource>, String> {
default_component_sources_with_hub_version(project_dir, None)
}
pub fn default_component_sources_with_hub_version(
project_dir: &Path,
hub_version: Option<&str>,
) -> Result<Vec<ComponentSource>, String> {
Ok(vec![
ComponentSource::local(project_dir.join("components")),
default_hub_source(hub_version)?,
])
}
fn resolve_component_dir(sources: &[ComponentSource], id: &str) -> Result<PathBuf, String> {
for kind in [SourceKind::Local, SourceKind::Hub] {
let matches: Vec<&ComponentSource> = sources
.iter()
.filter(|source| source.kind == kind)
.filter(|source| source.candidate(id).join("component.yml").is_file())
.collect();
match matches.as_slice() {
[] => continue,
[only] => return Ok(only.candidate(id)),
many => {
let dirs: Vec<String> = many
.iter()
.map(|s| s.candidate(id).display().to_string())
.collect();
return Err(format!(
"component '{id}' is ambiguous: found in {} '{kind}' sources ({}) — no \
precedence rule distinguishes sources of the same kind",
many.len(),
dirs.join(", ")
));
}
}
}
let searched: Vec<String> = sources
.iter()
.map(|s| format!("{} ({})", s.kind, s.candidate(id).display()))
.collect();
Err(format!(
"component '{id}' not found in any configured source: {}",
searched.join("; ")
))
}
pub trait ContextResolver {
fn resolve(
&self,
binding: &BindingFile,
project_dir: &Path,
) -> Result<Box<dyn ContextLoader>, String>;
}
pub struct BuiltinContextResolver;
const WREN_PROJECT_RETIRED: &str = "wren_project";
impl ContextResolver for BuiltinContextResolver {
fn resolve(
&self,
binding: &BindingFile,
project_dir: &Path,
) -> Result<Box<dyn ContextLoader>, String> {
if binding.kind == BindingFile::EXTERNAL {
return Ok(Box::new(warble::ExternalContext::new()));
}
let path = project_dir.join(&binding.project);
match binding.kind.as_str() {
WREN_PROJECT_RETIRED => Err(format!(
"`kind: {WREN_PROJECT_RETIRED}` is no longer resolved: warble reads no semantic \
format itself. Have the host read {} and write a prepared-context document, then \
bind it with `kind: {}` and a `document:` field naming that document.",
path.display(),
BindingFile::PREPARED
)),
BindingFile::RAW_SOURCE => {
let raw = read_raw_dir(&path)
.map_err(|e| format!("failed to read {}: {e}", path.display()))?
.ok_or_else(|| {
format!(
"binding declares `kind: {}` but {} has no schema.json",
BindingFile::RAW_SOURCE,
path.display()
)
})?;
Ok(Box::new(RawSourceContext::from_sources(&raw)))
}
BindingFile::PREPARED => {
let document_ref = binding
.extra
.get("document")
.and_then(|v| v.as_str())
.ok_or_else(|| {
format!(
"binding declares `kind: {}` but no `document:` — add the path to the \
prepared-context document the host wrote, and keep `project:` as the \
bound layer's name",
BindingFile::PREPARED
)
})?;
let document_path = project_dir.join(document_ref);
let document = std::fs::read_to_string(&document_path)
.map_err(|e| format!("failed to read {}: {e}", document_path.display()))?;
let context = PreparedContext::from_json(&document)
.map_err(|e| format!("{}: {e}", document_path.display()))?;
Ok(Box::new(context))
}
other => Err(format!(
"unknown context kind '{other}' (this build resolves '{}', '{}' and '{}'). A \
host that defines '{other}' must supply a ContextResolver for it — see \
`compile_project_to_ir_with`.",
BindingFile::RAW_SOURCE,
BindingFile::EXTERNAL,
BindingFile::PREPARED
)),
}
}
}
pub fn compile_project_to_ir(project_dir: &Path) -> Result<serde_json::Value, String> {
compile_project_to_ir_with_sources(project_dir, &default_component_sources(project_dir)?)
}
pub fn compile_project_to_ir_with_sources(
project_dir: &Path,
sources: &[ComponentSource],
) -> Result<serde_json::Value, String> {
compile_project_to_ir_with(project_dir, sources, &BuiltinContextResolver)
}
pub fn compile_project_to_ir_with(
project_dir: &Path,
sources: &[ComponentSource],
resolver: &dyn ContextResolver,
) -> Result<serde_json::Value, String> {
compile_project_to_ir_with_overlay(project_dir, sources, resolver, None)
}
pub fn compile_project_to_ir_with_overlay(
project_dir: &Path,
sources: &[ComponentSource],
resolver: &dyn ContextResolver,
overlay_path: Option<&Path>,
) -> Result<serde_json::Value, String> {
compile_project_to_ir_with_assets(project_dir, sources, resolver, overlay_path)
.map(|(ir, _)| ir)
}
pub type CompiledAssets = std::collections::BTreeMap<String, Vec<(String, Vec<u8>)>>;
pub fn compile_project_to_ir_with_assets(
project_dir: &Path,
sources: &[ComponentSource],
resolver: &dyn ContextResolver,
overlay_path: Option<&Path>,
) -> Result<(serde_json::Value, CompiledAssets), String> {
let mut collected_assets: CompiledAssets = CompiledAssets::new();
let profile_path = project_dir.join("profile.yml");
let mut profile: ProfileFile = serde_yaml::from_str(&read_file(&profile_path)?)
.map_err(|e| format!("failed to parse {}: {e}", profile_path.display()))?;
if let Some(path) = overlay_path {
let overlay = overlay::read_overlay(path)?;
overlay::apply_overlay(&mut profile, &overlay)?;
}
let binding_path = project_dir.join(&profile.context.project);
let binding: BindingFile = serde_yaml::from_str(&read_file(&binding_path)?)
.map_err(|e| format!("failed to parse {}: {e}", binding_path.display()))?;
let context = resolver.resolve(&binding, project_dir)?;
let mut components: HashMap<String, ComponentFile> = HashMap::new();
let mut step_contents: HashMap<String, HashMap<String, String>> = HashMap::new();
let mut profile_slots: HashMap<String, HashMap<String, String>> = HashMap::new();
for slot in &profile.slots {
let mut variants: HashMap<String, String> = HashMap::new();
for (key, reference) in &slot.variants {
let label = format!("profile slot '{}' variant '{}'", slot.name, key);
let variant_path = resolve_file_ref(project_dir, reference, &label)?;
variants.insert(key.clone(), read_file(&variant_path)?);
}
profile_slots.insert(slot.name.clone(), variants);
}
let mut slot_contents = warble::SlotContents {
profile: profile_slots,
..Default::default()
};
for mount in &profile.components {
let component_dir = resolve_component_dir(sources, &mount.use_id)?;
let component_path = component_dir.join("component.yml");
let mut component: ComponentFile = serde_yaml::from_str(&read_file(&component_path)?)
.map_err(|e| format!("failed to parse {}: {e}", component_path.display()))?;
let mut steps: HashMap<String, String> = HashMap::new();
for step in &component.llm_steps {
let step_path = resolve_file_ref(&component_dir, &step.prompt_ref, "prompt_ref")?;
steps.insert(step.name.clone(), read_file(&step_path)?);
}
step_contents.insert(component.id.clone(), steps);
let mut slots: HashMap<String, HashMap<String, String>> = HashMap::new();
for slot in &component.slots {
let mut variants: HashMap<String, String> = HashMap::new();
for (key, reference) in &slot.variants {
let label = format!("slot '{}' variant '{}'", slot.name, key);
let variant_path = resolve_file_ref(&component_dir, reference, &label)?;
variants.insert(key.clone(), read_file(&variant_path)?);
}
slots.insert(slot.name.clone(), variants);
}
if !slots.is_empty() {
slot_contents.components.insert(component.id.clone(), slots);
}
for asset in &mut component.assets {
let asset_path = resolve_file_ref(&component_dir, &asset.path, "asset")?;
let data = read_file_bytes(&asset_path)?;
asset.bytes = Some(data.len() as u64);
asset.hash = Some(format!("sha256:{:x}", Sha256::digest(&data)));
collected_assets
.entry(component.id.clone())
.or_default()
.push((asset.path.clone(), data));
}
components.insert(component.id.clone(), component);
}
let ir = warble::compile(
&profile,
&components,
&binding.project,
context.as_ref(),
&step_contents,
&slot_contents,
)
.map_err(|e| e.to_string())?;
Ok((ir, collected_assets))
}
pub fn asset_dir_for_ir(ir_path: &Path) -> PathBuf {
let name = ir_path
.file_stem()
.map(|stem| format!("{}.assets", stem.to_string_lossy()))
.unwrap_or_else(|| "ir.assets".to_string());
ir_path
.parent()
.unwrap_or_else(|| Path::new("."))
.join(name)
}
pub fn write_assets(root: &Path, assets: &CompiledAssets) -> Result<(), String> {
if assets.is_empty() {
return Ok(());
}
std::fs::create_dir_all(root)
.map_err(|e| format!("failed to create {}: {e}", root.display()))?;
for (component_id, files) in assets {
for (relative, data) in files {
assert_contained_relative_path(relative, component_id)?;
let target = root.join(component_id).join(relative);
assert_resolves_inside(root, &target, "asset target")?;
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("failed to create {}: {e}", parent.display()))?;
}
std::fs::write(&target, data)
.map_err(|e| format!("failed to write {}: {e}", target.display()))?;
}
}
Ok(())
}
fn read_file(path: &Path) -> Result<String, String> {
std::fs::read_to_string(path).map_err(|e| format!("failed to read {}: {e}", path.display()))
}
fn read_file_bytes(path: &Path) -> Result<Vec<u8>, String> {
std::fs::read(path).map_err(|e| format!("failed to read {}: {e}", path.display()))
}
fn resolve_file_ref(base_dir: &Path, reference: &str, label: &str) -> Result<PathBuf, String> {
let ref_path = Path::new(reference);
if ref_path.is_absolute()
|| ref_path
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
return Err(format!(
"{label} '{reference}' must be a relative path inside its own directory (no '..' or absolute paths)"
));
}
let resolved = base_dir.join(ref_path);
if !resolved.is_file() {
return Err(format!(
"{label} '{reference}' does not exist: {}",
resolved.display()
));
}
Ok(resolved)
}
#[cfg(test)]
mod resolve_file_ref_tests {
use super::resolve_file_ref;
#[test]
fn accepts_a_relative_path_to_an_existing_file() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("ask.md"), "Ask something.\n").unwrap();
let resolved = resolve_file_ref(dir.path(), "ask.md", "prompt_ref").unwrap();
assert_eq!(resolved, dir.path().join("ask.md"));
}
#[test]
fn accepts_a_relative_path_in_a_subdirectory() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("steps")).unwrap();
std::fs::write(dir.path().join("steps/ask.md"), "Ask something.\n").unwrap();
let resolved = resolve_file_ref(dir.path(), "steps/ask.md", "prompt_ref").unwrap();
assert_eq!(resolved, dir.path().join("steps/ask.md"));
}
#[test]
fn rejects_a_dotdot_escape() {
let dir = tempfile::tempdir().unwrap();
let err = resolve_file_ref(dir.path(), "../secrets.md", "prompt_ref").unwrap_err();
assert!(err.contains("prompt_ref"));
assert!(err.contains("../secrets.md"));
assert!(err.contains(".."));
}
#[test]
fn rejects_a_dotdot_segment_in_the_middle_of_the_path() {
let dir = tempfile::tempdir().unwrap();
let err = resolve_file_ref(dir.path(), "steps/../../escape.md", "prompt_ref").unwrap_err();
assert!(err.contains("steps/../../escape.md"));
}
#[test]
fn rejects_an_absolute_path() {
let dir = tempfile::tempdir().unwrap();
let err = resolve_file_ref(dir.path(), "/etc/passwd", "prompt_ref").unwrap_err();
assert!(err.contains("prompt_ref"));
assert!(err.contains("/etc/passwd"));
}
#[test]
fn rejects_a_missing_file() {
let dir = tempfile::tempdir().unwrap();
let err = resolve_file_ref(dir.path(), "steps/missing.md", "prompt_ref").unwrap_err();
assert!(err.contains("prompt_ref"));
assert!(err.contains("steps/missing.md"));
assert!(err.contains("does not exist"));
}
}
pub fn blast_radius_for_project(
project_dir: &Path,
node: &str,
) -> Result<Option<warble::HostImpact>, String> {
blast_radius_for_project_with(project_dir, node, &BuiltinContextResolver)
}
pub fn blast_radius_for_project_with(
project_dir: &Path,
node: &str,
resolver: &dyn ContextResolver,
) -> Result<Option<warble::HostImpact>, String> {
let profile_path = project_dir.join("profile.yml");
let profile: ProfileFile = serde_yaml::from_str(&read_file(&profile_path)?)
.map_err(|e| format!("failed to parse {}: {e}", profile_path.display()))?;
let binding_path = project_dir.join(&profile.context.project);
let binding: BindingFile = serde_yaml::from_str(&read_file(&binding_path)?)
.map_err(|e| format!("failed to parse {}: {e}", binding_path.display()))?;
let context = resolver.resolve(&binding, project_dir)?;
if !context.is_parseable() {
return Err(format!(
"bound context '{}' is not parseable — cannot compute blast radius",
binding.project
));
}
let analysis = context.host_analysis().ok_or_else(|| {
format!(
"bound context '{}' supplied no impact analysis — nothing to gate against",
binding.project
)
})?;
Ok(analysis.impact.get(node).cloned())
}
#[cfg(test)]
mod component_source_tests {
use super::{resolve_component_dir, ComponentSource};
use std::fs;
use std::path::Path;
fn stub_component(dir: &Path, id: &str) {
let component_dir = dir.join(id);
fs::create_dir_all(&component_dir).unwrap();
fs::write(component_dir.join("component.yml"), "id: placeholder\n").unwrap();
}
#[test]
fn resolves_from_the_only_source_that_defines_it() {
let hub = tempfile::tempdir().unwrap();
stub_component(hub.path(), "answer_query");
let sources = vec![ComponentSource::hub(hub.path())];
let resolved = resolve_component_dir(&sources, "answer_query").unwrap();
assert_eq!(resolved, hub.path().join("answer_query"));
}
#[test]
fn local_overrides_hub_when_both_define_the_same_id() {
let local = tempfile::tempdir().unwrap();
let hub = tempfile::tempdir().unwrap();
stub_component(local.path(), "answer_query");
stub_component(hub.path(), "answer_query");
let sources = vec![
ComponentSource::local(local.path()),
ComponentSource::hub(hub.path()),
];
let resolved = resolve_component_dir(&sources, "answer_query").unwrap();
assert_eq!(
resolved,
local.path().join("answer_query"),
"a Local source must win over a Hub source defining the same id"
);
}
#[test]
fn falls_back_to_hub_when_local_does_not_define_the_id() {
let local = tempfile::tempdir().unwrap();
let hub = tempfile::tempdir().unwrap();
stub_component(hub.path(), "answer_query");
let sources = vec![
ComponentSource::local(local.path()),
ComponentSource::hub(hub.path()),
];
let resolved = resolve_component_dir(&sources, "answer_query").unwrap();
assert_eq!(resolved, hub.path().join("answer_query"));
}
#[test]
fn unresolved_id_is_a_loud_fail_naming_every_source_searched() {
let local = tempfile::tempdir().unwrap();
let hub = tempfile::tempdir().unwrap();
let sources = vec![
ComponentSource::local(local.path()),
ComponentSource::hub(hub.path()),
];
let err = resolve_component_dir(&sources, "missing").unwrap_err();
assert!(err.contains("missing"));
assert!(err.contains(&local.path().display().to_string()));
assert!(err.contains(&hub.path().display().to_string()));
}
#[test]
fn same_id_in_two_same_kind_sources_is_ambiguous_not_first_match_wins() {
let local_a = tempfile::tempdir().unwrap();
let local_b = tempfile::tempdir().unwrap();
stub_component(local_a.path(), "answer_query");
stub_component(local_b.path(), "answer_query");
let sources = vec![
ComponentSource::local(local_a.path()),
ComponentSource::local(local_b.path()),
];
let err = resolve_component_dir(&sources, "answer_query").unwrap_err();
assert!(err.contains("ambiguous"));
assert!(err.contains(&local_a.path().display().to_string()));
assert!(err.contains(&local_b.path().display().to_string()));
}
#[test]
fn ambiguous_same_kind_sources_do_not_fall_through_to_a_lower_kind() {
let local_a = tempfile::tempdir().unwrap();
let local_b = tempfile::tempdir().unwrap();
let hub = tempfile::tempdir().unwrap();
stub_component(local_a.path(), "answer_query");
stub_component(local_b.path(), "answer_query");
stub_component(hub.path(), "answer_query");
let sources = vec![
ComponentSource::local(local_a.path()),
ComponentSource::local(local_b.path()),
ComponentSource::hub(hub.path()),
];
let err = resolve_component_dir(&sources, "answer_query").unwrap_err();
assert!(err.contains("ambiguous"));
}
}
pub fn check_compliance_ir_version(raw: &str, path: &Path) -> Result<(), String> {
let parsed: serde_json::Value = serde_json::from_str(raw).map_err(|e| {
format!(
"failed to parse IR {} for version check: {e}",
path.display()
)
})?;
match parsed.get("warble_ir_version") {
None => Err(format!(
"IR {} has no warble_ir_version field — eval compliance requires a complete compiled \
IR, not a hand-written subset",
path.display()
)),
Some(serde_json::Value::String(v)) if v == SUPPORTED_IR_VERSION => Ok(()),
Some(serde_json::Value::String(v)) => Err(format!(
"unsupported warble_ir_version '{v}' in {} (eval compliance understands: {SUPPORTED_IR_VERSION})",
path.display()
)),
Some(other) => Err(format!(
"warble_ir_version in {} is {}, not a string — eval compliance understands: \
{SUPPORTED_IR_VERSION}",
path.display(),
match other {
serde_json::Value::Null => "null",
serde_json::Value::Bool(_) => "a boolean",
serde_json::Value::Number(_) => "a number",
serde_json::Value::Array(_) => "an array",
_ => "an object",
}
)),
}
}
#[cfg(test)]
mod compliance_ir_version_tests {
use super::{check_compliance_ir_version, SUPPORTED_IR_VERSION};
use std::path::Path;
fn check(raw: &str) -> Result<(), String> {
check_compliance_ir_version(raw, Path::new("ir.json"))
}
#[test]
fn the_supported_version_passes() {
let raw = format!(r#"{{"warble_ir_version": "{SUPPORTED_IR_VERSION}", "components": []}}"#);
assert!(check(&raw).is_ok(), "got: {:?}", check(&raw));
}
#[test]
fn a_missing_field_is_reported_as_missing() {
let err = check(r#"{"components": []}"#).expect_err("must reject");
assert!(err.contains("has no warble_ir_version"), "got: {err}");
}
#[test]
fn an_out_of_range_version_is_reported_as_unsupported() {
let err = check(r#"{"warble_ir_version": "0.2", "components": []}"#).expect_err("reject");
assert!(
err.contains("unsupported warble_ir_version '0.2'"),
"got: {err}"
);
assert!(
err.contains(SUPPORTED_IR_VERSION),
"must name what it does accept: {err}"
);
}
#[test]
fn a_version_that_merely_starts_with_the_supported_one_is_rejected() {
let raw = format!(r#"{{"warble_ir_version": "{SUPPORTED_IR_VERSION}0"}}"#);
let err = check(&raw).expect_err("a superstring is a different version");
assert!(err.contains("unsupported warble_ir_version"), "got: {err}");
}
#[test]
fn every_non_string_type_is_reported_as_a_type_error() {
for (literal, expected) in [
("null", "is null"),
("true", "is a boolean"),
("3", "is a number"),
("[]", "is an array"),
("{}", "is an object"),
] {
let raw = format!(r#"{{"warble_ir_version": {literal}}}"#);
let err = check(&raw).expect_err(&format!("{literal} must be rejected"));
assert!(
err.contains(expected),
"for {literal} expected '{expected}', got: {err}"
);
assert!(
!err.contains("has no warble_ir_version"),
"for {literal} the field is present, not missing: {err}"
);
}
}
#[test]
fn unparseable_json_is_reported_as_a_parse_failure() {
let err = check("not json at all").expect_err("must reject");
assert!(err.contains("failed to parse IR"), "got: {err}");
}
}
pub fn land_assets(
ir: &serde_json::Value,
ir_path: &Path,
out_dir: &Path,
) -> Result<Vec<PathBuf>, String> {
let source_root = asset_dir_for_ir(ir_path);
let mut pending: Vec<(PathBuf, Vec<u8>)> = Vec::new();
let Some(components) = ir.get("components").and_then(|c| c.as_array()) else {
return Ok(Vec::new());
};
for node in components {
let component_id = node
.get("id")
.and_then(|v| v.as_str())
.unwrap_or("<unnamed>");
let Some(assets) = node.get("assets").and_then(|a| a.as_array()) else {
continue;
};
for asset in assets {
let relative = asset.get("path").and_then(|v| v.as_str()).ok_or_else(|| {
format!("component '{component_id}' has an asset with no path in the manifest")
})?;
let expected = asset.get("hash").and_then(|v| v.as_str()).ok_or_else(|| {
format!(
"asset '{relative}' of component '{component_id}' has no hash in the manifest"
)
})?;
assert_contained_relative_path(relative, component_id)?;
let source = source_root.join(component_id).join(relative);
let metadata = std::fs::symlink_metadata(&source).map_err(|e| {
format!(
"asset '{relative}' of component '{component_id}' is declared in the IR but \
missing from {}: {e}. An IR's assets travel in that directory; copying the IR \
without it leaves a component without the files it declared.",
source_root.display()
)
})?;
assert_resolves_inside(&source_root, &source, "asset source")?;
if !source
.metadata()
.map_err(|e| format!("failed to inspect {}: {e}", source.display()))?
.is_file()
{
let message = [
format!("asset '{relative}' of component '{component_id}' is not a regular"),
format!("file (found {:?}).", metadata.file_type()),
"Reading a pipe or device would block the dispatch indefinitely instead of"
.to_string(),
"failing, so anything that is not a plain file is refused.".to_string(),
]
.join(" ");
return Err(message);
}
let data = std::fs::read(&source)
.map_err(|e| format!("failed to read {}: {e}", source.display()))?;
let actual = format!("sha256:{:x}", Sha256::digest(&data));
if actual != expected {
return Err(format!(
"asset '{relative}' of component '{component_id}' does not match its manifest \
(expected {expected}, found {actual}). The file changed after the IR was \
compiled; recompile rather than dispatching content the manifest does not name."
));
}
let target = out_dir.join(relative);
assert_resolves_inside(out_dir, &target, "asset target")?;
pending.push((target, data));
}
}
let mut landed = Vec::with_capacity(pending.len());
for (target, data) in pending {
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("failed to create {}: {e}", parent.display()))?;
}
std::fs::write(&target, &data)
.map_err(|e| format!("failed to write {}: {e}", target.display()))?;
landed.push(target);
}
Ok(landed)
}
fn assert_contained_relative_path(relative: &str, component_id: &str) -> Result<(), String> {
let path = Path::new(relative);
if path.is_absolute()
|| path
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
let message = [
format!("asset path '{relative}' of component '{component_id}' must be a relative"),
"path with no '..' segments. A manifest naming a path outside the directory it"
.to_string(),
"lands in would be an arbitrary file write, so it is refused rather than".to_string(),
"resolved.".to_string(),
]
.join(" ");
return Err(message);
}
Ok(())
}
fn assert_resolves_inside(root: &Path, target: &Path, what: &str) -> Result<(), String> {
let canonical_root = root
.canonicalize()
.map_err(|e| format!("failed to resolve {}: {e}", root.display()))?;
let mut existing = target;
let anchor = loop {
if existing.exists() {
break existing
.canonicalize()
.map_err(|e| format!("failed to resolve {}: {e}", existing.display()))?;
}
match existing.parent() {
Some(parent) => existing = parent,
None => {
return Err(format!(
"{what} '{}' has no resolvable ancestor",
target.display()
))
}
}
};
if !anchor.starts_with(&canonical_root) {
let message = [
format!(
"{what} '{}' resolves outside {}",
target.display(),
canonical_root.display()
),
"once symlinks are followed. A path that looks contained but is not is refused rather"
.to_string(),
"than followed, because the manifest is data and the filesystem is what decides where"
.to_string(),
"a write actually lands.".to_string(),
]
.join(" ");
return Err(message);
}
Ok(())
}