1mod authoring;
25pub mod gate;
26pub mod hub_fetch;
27pub mod overlay;
28pub mod provenance;
29
30use std::collections::HashMap;
31use std::fmt;
32use std::path::{Path, PathBuf};
33
34use sha2::{Digest, Sha256};
35use warble::{
36 read_raw_dir, BindingFile, ComponentFile, ContextLoader, PreparedContext, RawSourceContext,
37};
38use warble_claude_code::ir::SUPPORTED_IR_VERSION;
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum SourceKind {
48 Local,
52 Hub,
56}
57
58impl fmt::Display for SourceKind {
59 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60 f.write_str(match self {
61 SourceKind::Local => "local",
62 SourceKind::Hub => "hub",
63 })
64 }
65}
66
67#[derive(Debug, Clone)]
72pub struct ComponentSource {
73 pub kind: SourceKind,
74 pub components_dir: PathBuf,
75}
76
77impl ComponentSource {
78 pub fn local(components_dir: impl Into<PathBuf>) -> Self {
79 ComponentSource {
80 kind: SourceKind::Local,
81 components_dir: components_dir.into(),
82 }
83 }
84
85 pub fn hub(components_dir: impl Into<PathBuf>) -> Self {
86 ComponentSource {
87 kind: SourceKind::Hub,
88 components_dir: components_dir.into(),
89 }
90 }
91
92 fn candidate(&self, id: &str) -> PathBuf {
93 self.components_dir.join(id)
94 }
95}
96
97fn in_repo_hub_dir() -> PathBuf {
102 Path::new(env!("CARGO_MANIFEST_DIR"))
103 .join("..")
104 .join("hub")
105 .join("components")
106}
107
108fn default_hub_source(hub_version: Option<&str>) -> Result<ComponentSource, String> {
119 let in_repo = in_repo_hub_dir();
120 if in_repo.is_dir() {
121 return Ok(ComponentSource::hub(in_repo));
122 }
123 let version = hub_version.unwrap_or(env!("CARGO_PKG_VERSION"));
124 let cached_dir = hub_fetch::ensure_cached_hub(version)?;
125 Ok(ComponentSource::hub(cached_dir))
126}
127
128pub fn default_component_sources(project_dir: &Path) -> Result<Vec<ComponentSource>, String> {
139 default_component_sources_with_hub_version(project_dir, None)
140}
141
142pub fn default_component_sources_with_hub_version(
147 project_dir: &Path,
148 hub_version: Option<&str>,
149) -> Result<Vec<ComponentSource>, String> {
150 Ok(vec![
151 ComponentSource::local(project_dir.join("components")),
152 default_hub_source(hub_version)?,
153 ])
154}
155
156pub fn project_needs_hub(
159 project_dir: &Path,
160 local_sources: &[ComponentSource],
161 overlay_path: Option<&Path>,
162) -> Result<bool, String> {
163 let profile_path = project_dir.join("profile.yml");
164 let (mut profile, inline) = authoring::parse_profile(&read_file(&profile_path)?)
165 .map_err(|error| format!("failed to parse {}: {error}", profile_path.display()))?;
166 if let Some(path) = overlay_path {
167 overlay::apply_overlay(&mut profile, &overlay::read_overlay(path)?)?;
168 }
169 Ok(profile.components.iter().any(|mount| {
170 !inline.contains_key(&mount.use_id)
171 && !local_sources.iter().any(|source| {
172 source
173 .candidate(&mount.use_id)
174 .join("component.yml")
175 .is_file()
176 })
177 }))
178}
179
180fn resolve_component_dir(sources: &[ComponentSource], id: &str) -> Result<PathBuf, String> {
186 for kind in [SourceKind::Local, SourceKind::Hub] {
187 let matches: Vec<&ComponentSource> = sources
188 .iter()
189 .filter(|source| source.kind == kind)
190 .filter(|source| source.candidate(id).join("component.yml").is_file())
191 .collect();
192
193 match matches.as_slice() {
194 [] => continue,
195 [only] => return Ok(only.candidate(id)),
196 many => {
197 let dirs: Vec<String> = many
198 .iter()
199 .map(|s| s.candidate(id).display().to_string())
200 .collect();
201 return Err(format!(
202 "component '{id}' is ambiguous: found in {} '{kind}' sources ({}) — no \
203 precedence rule distinguishes sources of the same kind",
204 many.len(),
205 dirs.join(", ")
206 ));
207 }
208 }
209 }
210
211 let searched: Vec<String> = sources
212 .iter()
213 .map(|s| format!("{} ({})", s.kind, s.candidate(id).display()))
214 .collect();
215 Err(format!(
216 "component '{id}' not found in any configured source: {}",
217 searched.join("; ")
218 ))
219}
220
221pub trait ContextResolver {
232 fn resolve(
235 &self,
236 binding: &BindingFile,
237 project_dir: &Path,
238 ) -> Result<Box<dyn ContextLoader>, String>;
239}
240
241pub struct BuiltinContextResolver;
253
254const WREN_PROJECT_RETIRED: &str = "wren_project";
258
259impl ContextResolver for BuiltinContextResolver {
260 fn resolve(
261 &self,
262 binding: &BindingFile,
263 project_dir: &Path,
264 ) -> Result<Box<dyn ContextLoader>, String> {
265 if binding.kind == BindingFile::EXTERNAL {
269 return Ok(Box::new(warble::ExternalContext::new()));
270 }
271 let path = project_dir.join(&binding.project);
275 match binding.kind.as_str() {
276 WREN_PROJECT_RETIRED => Err(format!(
281 "`kind: {WREN_PROJECT_RETIRED}` is no longer resolved: warble reads no semantic \
282 format itself. Have the host read {} and write a prepared-context document, then \
283 bind it with `kind: {}` and a `document:` field naming that document.",
284 path.display(),
285 BindingFile::PREPARED
286 )),
287 BindingFile::RAW_SOURCE => {
288 let raw = read_raw_dir(&path)
289 .map_err(|e| format!("failed to read {}: {e}", path.display()))?
290 .ok_or_else(|| {
291 format!(
292 "binding declares `kind: {}` but {} has no schema.json",
293 BindingFile::RAW_SOURCE,
294 path.display()
295 )
296 })?;
297 Ok(Box::new(RawSourceContext::from_sources(&raw)))
298 }
299 BindingFile::PREPARED => {
300 let document_ref = binding
305 .extra
306 .get("document")
307 .and_then(|v| v.as_str())
308 .ok_or_else(|| {
309 format!(
310 "binding declares `kind: {}` but no `document:` — add the path to the \
311 prepared-context document the host wrote, and keep `project:` as the \
312 bound layer's name",
313 BindingFile::PREPARED
314 )
315 })?;
316 let document_path = project_dir.join(document_ref);
317 let document = std::fs::read_to_string(&document_path)
321 .map_err(|e| format!("failed to read {}: {e}", document_path.display()))?;
322 let context = PreparedContext::from_json(&document)
323 .map_err(|e| format!("{}: {e}", document_path.display()))?;
324 Ok(Box::new(context))
325 }
326 other => Err(format!(
327 "unknown context kind '{other}' (this build resolves '{}', '{}' and '{}'). A \
328 host that defines '{other}' must supply a ContextResolver for it — see \
329 `compile_project_to_ir_with`.",
330 BindingFile::RAW_SOURCE,
331 BindingFile::EXTERNAL,
332 BindingFile::PREPARED
333 )),
334 }
335 }
336}
337
338pub fn compile_project_to_ir(project_dir: &Path) -> Result<serde_json::Value, String> {
343 let local = vec![ComponentSource::local(project_dir.join("components"))];
344 let sources = if project_needs_hub(project_dir, &local, None)? {
345 default_component_sources(project_dir)?
346 } else {
347 local
348 };
349 compile_project_to_ir_with_sources(project_dir, &sources)
350}
351
352pub fn compile_project_to_ir_with_sources(
359 project_dir: &Path,
360 sources: &[ComponentSource],
361) -> Result<serde_json::Value, String> {
362 compile_project_to_ir_with(project_dir, sources, &BuiltinContextResolver)
363}
364
365pub fn compile_project_to_ir_with(
370 project_dir: &Path,
371 sources: &[ComponentSource],
372 resolver: &dyn ContextResolver,
373) -> Result<serde_json::Value, String> {
374 compile_project_to_ir_with_overlay(project_dir, sources, resolver, None)
375}
376
377pub fn compile_project_to_ir_with_overlay(
390 project_dir: &Path,
391 sources: &[ComponentSource],
392 resolver: &dyn ContextResolver,
393 overlay_path: Option<&Path>,
394) -> Result<serde_json::Value, String> {
395 compile_project_to_ir_with_assets(project_dir, sources, resolver, overlay_path)
396 .map(|(ir, _)| ir)
397}
398
399pub type CompiledAssets = std::collections::BTreeMap<String, Vec<(String, Vec<u8>)>>;
405
406pub fn compile_project_to_ir_with_assets(
411 project_dir: &Path,
412 sources: &[ComponentSource],
413 resolver: &dyn ContextResolver,
414 overlay_path: Option<&Path>,
415) -> Result<(serde_json::Value, CompiledAssets), String> {
416 let result =
417 compile_project_with_provenance(project_dir, sources, resolver, overlay_path, false)?;
418 Ok((result.ir, result.assets))
419}
420
421pub struct AuthorCompilation {
423 pub ir: serde_json::Value,
424 pub assets: CompiledAssets,
425 pub sources: Vec<provenance::AuthorSource>,
426}
427
428pub fn compile_project_for_authoring(
431 project_dir: &Path,
432 sources: &[ComponentSource],
433 resolver: &dyn ContextResolver,
434) -> Result<AuthorCompilation, String> {
435 compile_project_with_provenance(project_dir, sources, resolver, None, true)
436}
437
438fn compile_project_with_provenance(
439 project_dir: &Path,
440 sources: &[ComponentSource],
441 resolver: &dyn ContextResolver,
442 overlay_path: Option<&Path>,
443 strict_profile: bool,
444) -> Result<AuthorCompilation, String> {
445 let mut collected_assets: CompiledAssets = CompiledAssets::new();
446 let mut origins = Vec::new();
447 let profile_path = project_dir.join("profile.yml");
448 let profile_text = read_file(&profile_path)?;
449 if strict_profile {
450 provenance::validate_profile_fields(&profile_text, &profile_path)?;
451 }
452 let (mut profile, inline_components) = authoring::parse_profile(&profile_text)
453 .map_err(|e| format!("failed to parse {}: {e}", profile_path.display()))?;
454
455 if let Some(path) = overlay_path {
456 let overlay = overlay::read_overlay(path)?;
457 overlay::apply_overlay(&mut profile, &overlay)?;
458 }
459
460 provenance::profile_sources(&profile, &profile_path, &mut origins);
461 let (project, context): (String, Box<dyn ContextLoader>) = match &profile.context {
462 Some(authored) => {
463 let binding_path = project_dir.join(&authored.project);
464 let binding: BindingFile = serde_yaml::from_str(&read_file(&binding_path)?)
465 .map_err(|e| format!("failed to parse {}: {e}", binding_path.display()))?;
466 let context = resolver.resolve(&binding, project_dir)?;
467 (binding.project, context)
468 }
469 None => (String::new(), Box::new(warble::NoContext::default())),
470 };
471
472 let mut components: HashMap<String, ComponentFile> = HashMap::new();
473 let mut step_contents: HashMap<String, HashMap<String, String>> = HashMap::new();
474 let mut profile_slots: HashMap<String, HashMap<String, String>> = HashMap::new();
478 for slot in &profile.slots {
479 let mut variants: HashMap<String, String> = HashMap::new();
480 for (key, reference) in &slot.variants {
481 let label = format!("profile slot '{}' variant '{}'", slot.name, key);
482 let variant_path = resolve_file_ref(project_dir, reference, &label)?;
483 variants.insert(key.clone(), read_file(&variant_path)?);
484 }
485 profile_slots.insert(slot.name.clone(), variants);
486 }
487 let mut slot_contents = warble::SlotContents {
488 profile: profile_slots,
489 ..Default::default()
490 };
491
492 for (mount_index, mount) in profile.components.iter().enumerate() {
493 let (component_dir, mut component, shorthand) =
494 if let Some(component) = inline_components.get(&mount.use_id) {
495 let raw: serde_yaml::Value =
496 serde_yaml::from_str(&profile_text).expect("profile parsed");
497 let shorthand = raw["components"][mount_index].get("prompt").is_some();
498 (project_dir.to_path_buf(), component.clone(), shorthand)
499 } else {
500 let dir = resolve_component_dir(sources, &mount.use_id)?;
501 let path = dir.join("component.yml");
502 let value: serde_yaml::Value = serde_yaml::from_str(&read_file(&path)?)
503 .map_err(|e| format!("failed to parse {}: {e}", path.display()))?;
504 let shorthand = value.get("prompt").is_some();
505 let component = authoring::parse_component(value)
506 .map_err(|e| format!("failed to parse {}: {e}", path.display()))?;
507 (dir, component, shorthand)
508 };
509 provenance::component_sources(
510 &profile,
511 mount,
512 &component,
513 &profile_path,
514 &component_dir,
515 inline_components.contains_key(&mount.use_id),
516 shorthand,
517 &mut origins,
518 );
519 if component.id != mount.use_id {
520 return Err(format!(
521 "mount '{}' resolves to component '{}'",
522 mount.use_id, component.id
523 ));
524 }
525 let mut steps: HashMap<String, String> = HashMap::new();
526 for step in &component.llm_steps {
527 let text = match (&step.prompt, &step.prompt_ref) {
528 (Some(text), None) if !text.trim().is_empty() => text.clone(),
529 (None, Some(reference)) if !reference.trim().is_empty() => {
530 let path = resolve_file_ref(&component_dir, reference, "prompt_ref")?;
531 read_file(&path)?
532 }
533 _ => {
534 return Err(format!(
535 "component '{}' step '{}': supply exactly one non-empty prompt or prompt_ref",
536 component.id, step.name
537 ))
538 }
539 };
540 steps.insert(step.name.clone(), text);
541 }
542 step_contents.insert(component.id.clone(), steps);
543
544 let mut slots: HashMap<String, HashMap<String, String>> = HashMap::new();
548 for slot in &component.slots {
549 let mut variants: HashMap<String, String> = HashMap::new();
550 for (key, reference) in &slot.variants {
551 let label = format!("slot '{}' variant '{}'", slot.name, key);
552 let variant_path = resolve_file_ref(&component_dir, reference, &label)?;
553 variants.insert(key.clone(), read_file(&variant_path)?);
554 }
555 slots.insert(slot.name.clone(), variants);
556 }
557 if !slots.is_empty() {
558 slot_contents.components.insert(component.id.clone(), slots);
559 }
560
561 for asset in &mut component.assets {
569 let asset_path = resolve_file_ref(&component_dir, &asset.path, "asset")?;
570 let data = read_file_bytes(&asset_path)?;
571 asset.bytes = Some(data.len() as u64);
572 asset.hash = Some(format!("sha256:{:x}", Sha256::digest(&data)));
573 collected_assets
574 .entry(component.id.clone())
575 .or_default()
576 .push((asset.path.clone(), data));
577 }
578
579 components.insert(component.id.clone(), component);
580 }
581
582 let ir = warble::compile(
583 &profile,
584 &components,
585 &project,
586 context.as_ref(),
587 &step_contents,
588 &slot_contents,
589 )
590 .map_err(|e| {
591 if strict_profile {
592 format!(
593 "{e}\nAuthor sources:\n{}",
594 origins
595 .iter()
596 .filter(|s| s.role == "component declaration")
597 .map(|s| format!(
598 " {}#{} ({})",
599 s.file,
600 s.field,
601 s.component.as_deref().unwrap_or("profile")
602 ))
603 .collect::<Vec<_>>()
604 .join("\n")
605 )
606 } else {
607 e.to_string()
608 }
609 })?;
610 Ok(AuthorCompilation {
611 ir,
612 assets: collected_assets,
613 sources: origins,
614 })
615}
616
617pub fn asset_dir_for_ir(ir_path: &Path) -> PathBuf {
623 let name = ir_path
624 .file_stem()
625 .map(|stem| format!("{}.assets", stem.to_string_lossy()))
626 .unwrap_or_else(|| "ir.assets".to_string());
627 ir_path
628 .parent()
629 .unwrap_or_else(|| Path::new("."))
630 .join(name)
631}
632
633pub fn write_assets(root: &Path, assets: &CompiledAssets) -> Result<(), String> {
638 if assets.is_empty() {
639 return Ok(());
640 }
641 std::fs::create_dir_all(root)
654 .map_err(|e| format!("failed to create {}: {e}", root.display()))?;
655 for (component_id, files) in assets {
656 for (relative, data) in files {
657 assert_contained_relative_path(relative, component_id)?;
658 let target = root.join(component_id).join(relative);
659 assert_resolves_inside(root, &target, "asset target")?;
666 if let Some(parent) = target.parent() {
667 std::fs::create_dir_all(parent)
668 .map_err(|e| format!("failed to create {}: {e}", parent.display()))?;
669 }
670 std::fs::write(&target, data)
671 .map_err(|e| format!("failed to write {}: {e}", target.display()))?;
672 }
673 }
674 Ok(())
675}
676
677fn read_file(path: &Path) -> Result<String, String> {
678 std::fs::read_to_string(path).map_err(|e| format!("failed to read {}: {e}", path.display()))
679}
680
681fn read_file_bytes(path: &Path) -> Result<Vec<u8>, String> {
685 std::fs::read(path).map_err(|e| format!("failed to read {}: {e}", path.display()))
686}
687
688fn resolve_file_ref(base_dir: &Path, reference: &str, label: &str) -> Result<PathBuf, String> {
713 let ref_path = Path::new(reference);
714 if ref_path.is_absolute()
715 || ref_path
716 .components()
717 .any(|c| matches!(c, std::path::Component::ParentDir))
718 {
719 return Err(format!(
720 "{label} '{reference}' must be a relative path inside its own directory (no '..' or absolute paths)"
721 ));
722 }
723 let resolved = base_dir.join(ref_path);
724 if !resolved.is_file() {
725 return Err(format!(
726 "{label} '{reference}' does not exist: {}",
727 resolved.display()
728 ));
729 }
730 Ok(resolved)
731}
732
733#[cfg(test)]
734mod resolve_file_ref_tests {
735 use super::resolve_file_ref;
736
737 #[test]
738 fn accepts_a_relative_path_to_an_existing_file() {
739 let dir = tempfile::tempdir().unwrap();
740 std::fs::write(dir.path().join("ask.md"), "Ask something.\n").unwrap();
741
742 let resolved = resolve_file_ref(dir.path(), "ask.md", "prompt_ref").unwrap();
743
744 assert_eq!(resolved, dir.path().join("ask.md"));
745 }
746
747 #[test]
748 fn accepts_a_relative_path_in_a_subdirectory() {
749 let dir = tempfile::tempdir().unwrap();
750 std::fs::create_dir(dir.path().join("steps")).unwrap();
751 std::fs::write(dir.path().join("steps/ask.md"), "Ask something.\n").unwrap();
752
753 let resolved = resolve_file_ref(dir.path(), "steps/ask.md", "prompt_ref").unwrap();
754
755 assert_eq!(resolved, dir.path().join("steps/ask.md"));
756 }
757
758 #[test]
759 fn rejects_a_dotdot_escape() {
760 let dir = tempfile::tempdir().unwrap();
761
762 let err = resolve_file_ref(dir.path(), "../secrets.md", "prompt_ref").unwrap_err();
763
764 assert!(err.contains("prompt_ref"));
765 assert!(err.contains("../secrets.md"));
766 assert!(err.contains(".."));
767 }
768
769 #[test]
770 fn rejects_a_dotdot_segment_in_the_middle_of_the_path() {
771 let dir = tempfile::tempdir().unwrap();
772
773 let err = resolve_file_ref(dir.path(), "steps/../../escape.md", "prompt_ref").unwrap_err();
774
775 assert!(err.contains("steps/../../escape.md"));
776 }
777
778 #[test]
779 fn rejects_an_absolute_path() {
780 let dir = tempfile::tempdir().unwrap();
781
782 let err = resolve_file_ref(dir.path(), "/etc/passwd", "prompt_ref").unwrap_err();
783
784 assert!(err.contains("prompt_ref"));
785 assert!(err.contains("/etc/passwd"));
786 }
787
788 #[test]
789 fn rejects_a_missing_file() {
790 let dir = tempfile::tempdir().unwrap();
791
792 let err = resolve_file_ref(dir.path(), "steps/missing.md", "prompt_ref").unwrap_err();
793
794 assert!(err.contains("prompt_ref"));
795 assert!(err.contains("steps/missing.md"));
796 assert!(err.contains("does not exist"));
797 }
798}
799
800pub fn blast_radius_for_project(
805 project_dir: &Path,
806 node: &str,
807) -> Result<Option<warble::HostImpact>, String> {
808 blast_radius_for_project_with(project_dir, node, &BuiltinContextResolver)
809}
810
811pub fn blast_radius_for_project_with(
816 project_dir: &Path,
817 node: &str,
818 resolver: &dyn ContextResolver,
819) -> Result<Option<warble::HostImpact>, String> {
820 let profile_path = project_dir.join("profile.yml");
821 let (profile, _) = authoring::parse_profile(&read_file(&profile_path)?)?;
822 let authored = profile
823 .context
824 .as_ref()
825 .ok_or("blast-radius requires an explicit context binding")?;
826 let binding_path = project_dir.join(&authored.project);
827 let binding: BindingFile = serde_yaml::from_str(&read_file(&binding_path)?)
828 .map_err(|e| format!("failed to parse {}: {e}", binding_path.display()))?;
829
830 let context = resolver.resolve(&binding, project_dir)?;
831
832 if !context.is_parseable() {
833 return Err(format!(
834 "bound context '{}' is not parseable — cannot compute blast radius",
835 binding.project
836 ));
837 }
838
839 let analysis = context.host_analysis().ok_or_else(|| {
843 format!(
844 "bound context '{}' supplied no impact analysis — nothing to gate against",
845 binding.project
846 )
847 })?;
848
849 Ok(analysis.impact.get(node).cloned())
853}
854
855#[cfg(test)]
856mod component_source_tests {
857 use super::{resolve_component_dir, ComponentSource};
858 use std::fs;
859 use std::path::Path;
860
861 fn stub_component(dir: &Path, id: &str) {
864 let component_dir = dir.join(id);
865 fs::create_dir_all(&component_dir).unwrap();
866 fs::write(component_dir.join("component.yml"), "id: placeholder\n").unwrap();
867 }
868
869 #[test]
870 fn resolves_from_the_only_source_that_defines_it() {
871 let hub = tempfile::tempdir().unwrap();
872 stub_component(hub.path(), "answer_query");
873
874 let sources = vec![ComponentSource::hub(hub.path())];
875 let resolved = resolve_component_dir(&sources, "answer_query").unwrap();
876 assert_eq!(resolved, hub.path().join("answer_query"));
877 }
878
879 #[test]
880 fn local_overrides_hub_when_both_define_the_same_id() {
881 let local = tempfile::tempdir().unwrap();
882 let hub = tempfile::tempdir().unwrap();
883 stub_component(local.path(), "answer_query");
884 stub_component(hub.path(), "answer_query");
885
886 let sources = vec![
887 ComponentSource::local(local.path()),
888 ComponentSource::hub(hub.path()),
889 ];
890 let resolved = resolve_component_dir(&sources, "answer_query").unwrap();
891 assert_eq!(
892 resolved,
893 local.path().join("answer_query"),
894 "a Local source must win over a Hub source defining the same id"
895 );
896 }
897
898 #[test]
899 fn falls_back_to_hub_when_local_does_not_define_the_id() {
900 let local = tempfile::tempdir().unwrap();
901 let hub = tempfile::tempdir().unwrap();
902 stub_component(hub.path(), "answer_query");
903
904 let sources = vec![
905 ComponentSource::local(local.path()),
906 ComponentSource::hub(hub.path()),
907 ];
908 let resolved = resolve_component_dir(&sources, "answer_query").unwrap();
909 assert_eq!(resolved, hub.path().join("answer_query"));
910 }
911
912 #[test]
913 fn unresolved_id_is_a_loud_fail_naming_every_source_searched() {
914 let local = tempfile::tempdir().unwrap();
915 let hub = tempfile::tempdir().unwrap();
916
917 let sources = vec![
918 ComponentSource::local(local.path()),
919 ComponentSource::hub(hub.path()),
920 ];
921 let err = resolve_component_dir(&sources, "missing").unwrap_err();
922 assert!(err.contains("missing"));
923 assert!(err.contains(&local.path().display().to_string()));
924 assert!(err.contains(&hub.path().display().to_string()));
925 }
926
927 #[test]
928 fn same_id_in_two_same_kind_sources_is_ambiguous_not_first_match_wins() {
929 let local_a = tempfile::tempdir().unwrap();
930 let local_b = tempfile::tempdir().unwrap();
931 stub_component(local_a.path(), "answer_query");
932 stub_component(local_b.path(), "answer_query");
933
934 let sources = vec![
935 ComponentSource::local(local_a.path()),
936 ComponentSource::local(local_b.path()),
937 ];
938 let err = resolve_component_dir(&sources, "answer_query").unwrap_err();
939 assert!(err.contains("ambiguous"));
940 assert!(err.contains(&local_a.path().display().to_string()));
941 assert!(err.contains(&local_b.path().display().to_string()));
942 }
943
944 #[test]
945 fn ambiguous_same_kind_sources_do_not_fall_through_to_a_lower_kind() {
946 let local_a = tempfile::tempdir().unwrap();
949 let local_b = tempfile::tempdir().unwrap();
950 let hub = tempfile::tempdir().unwrap();
951 stub_component(local_a.path(), "answer_query");
952 stub_component(local_b.path(), "answer_query");
953 stub_component(hub.path(), "answer_query");
954
955 let sources = vec![
956 ComponentSource::local(local_a.path()),
957 ComponentSource::local(local_b.path()),
958 ComponentSource::hub(hub.path()),
959 ];
960 let err = resolve_component_dir(&sources, "answer_query").unwrap_err();
961 assert!(err.contains("ambiguous"));
962 }
963}
964
965pub fn check_compliance_ir_version(raw: &str, path: &Path) -> Result<(), String> {
977 let parsed: serde_json::Value = serde_json::from_str(raw).map_err(|e| {
978 format!(
979 "failed to parse IR {} for version check: {e}",
980 path.display()
981 )
982 })?;
983 match parsed.get("warble_ir_version") {
986 None => Err(format!(
987 "IR {} has no warble_ir_version field — eval compliance requires a complete compiled \
988 IR, not a hand-written subset",
989 path.display()
990 )),
991 Some(serde_json::Value::String(v)) if v == SUPPORTED_IR_VERSION => Ok(()),
992 Some(serde_json::Value::String(v)) => Err(format!(
993 "unsupported warble_ir_version '{v}' in {} (eval compliance understands: {SUPPORTED_IR_VERSION})",
994 path.display()
995 )),
996 Some(other) => Err(format!(
997 "warble_ir_version in {} is {}, not a string — eval compliance understands: \
998 {SUPPORTED_IR_VERSION}",
999 path.display(),
1000 match other {
1001 serde_json::Value::Null => "null",
1002 serde_json::Value::Bool(_) => "a boolean",
1003 serde_json::Value::Number(_) => "a number",
1004 serde_json::Value::Array(_) => "an array",
1005 _ => "an object",
1006 }
1007 )),
1008 }
1009}
1010
1011#[cfg(test)]
1012mod compliance_ir_version_tests {
1013 use super::{check_compliance_ir_version, SUPPORTED_IR_VERSION};
1014 use std::path::Path;
1015
1016 fn check(raw: &str) -> Result<(), String> {
1017 check_compliance_ir_version(raw, Path::new("ir.json"))
1018 }
1019
1020 #[test]
1021 fn the_supported_version_passes() {
1022 let raw = format!(r#"{{"warble_ir_version": "{SUPPORTED_IR_VERSION}", "components": []}}"#);
1023 assert!(check(&raw).is_ok(), "got: {:?}", check(&raw));
1024 }
1025
1026 #[test]
1027 fn a_missing_field_is_reported_as_missing() {
1028 let err = check(r#"{"components": []}"#).expect_err("must reject");
1029 assert!(err.contains("has no warble_ir_version"), "got: {err}");
1030 }
1031
1032 #[test]
1033 fn an_out_of_range_version_is_reported_as_unsupported() {
1034 let err = check(r#"{"warble_ir_version": "0.2", "components": []}"#).expect_err("reject");
1035 assert!(
1036 err.contains("unsupported warble_ir_version '0.2'"),
1037 "got: {err}"
1038 );
1039 assert!(
1040 err.contains(SUPPORTED_IR_VERSION),
1041 "must name what it does accept: {err}"
1042 );
1043 }
1044
1045 #[test]
1046 fn a_version_that_merely_starts_with_the_supported_one_is_rejected() {
1047 let raw = format!(r#"{{"warble_ir_version": "{SUPPORTED_IR_VERSION}0"}}"#);
1049 let err = check(&raw).expect_err("a superstring is a different version");
1050 assert!(err.contains("unsupported warble_ir_version"), "got: {err}");
1051 }
1052
1053 #[test]
1057 fn every_non_string_type_is_reported_as_a_type_error() {
1058 for (literal, expected) in [
1059 ("null", "is null"),
1060 ("true", "is a boolean"),
1061 ("3", "is a number"),
1062 ("[]", "is an array"),
1063 ("{}", "is an object"),
1064 ] {
1065 let raw = format!(r#"{{"warble_ir_version": {literal}}}"#);
1066 let err = check(&raw).expect_err(&format!("{literal} must be rejected"));
1067 assert!(
1068 err.contains(expected),
1069 "for {literal} expected '{expected}', got: {err}"
1070 );
1071 assert!(
1072 !err.contains("has no warble_ir_version"),
1073 "for {literal} the field is present, not missing: {err}"
1074 );
1075 }
1076 }
1077
1078 #[test]
1079 fn unparseable_json_is_reported_as_a_parse_failure() {
1080 let err = check("not json at all").expect_err("must reject");
1081 assert!(err.contains("failed to parse IR"), "got: {err}");
1082 }
1083}
1084
1085pub fn land_assets(
1100 ir: &serde_json::Value,
1101 ir_path: &Path,
1102 out_dir: &Path,
1103) -> Result<Vec<PathBuf>, String> {
1104 let source_root = asset_dir_for_ir(ir_path);
1105 let mut pending: Vec<(PathBuf, Vec<u8>)> = Vec::new();
1111 let Some(components) = ir.get("components").and_then(|c| c.as_array()) else {
1112 return Ok(Vec::new());
1113 };
1114 for node in components {
1115 let component_id = node
1116 .get("id")
1117 .and_then(|v| v.as_str())
1118 .unwrap_or("<unnamed>");
1119 let Some(assets) = node.get("assets").and_then(|a| a.as_array()) else {
1120 continue;
1121 };
1122 for asset in assets {
1123 let relative = asset.get("path").and_then(|v| v.as_str()).ok_or_else(|| {
1124 format!("component '{component_id}' has an asset with no path in the manifest")
1125 })?;
1126 let expected = asset.get("hash").and_then(|v| v.as_str()).ok_or_else(|| {
1127 format!(
1128 "asset '{relative}' of component '{component_id}' has no hash in the manifest"
1129 )
1130 })?;
1131 assert_contained_relative_path(relative, component_id)?;
1136 let source = source_root.join(component_id).join(relative);
1137 let metadata = std::fs::symlink_metadata(&source).map_err(|e| {
1143 format!(
1144 "asset '{relative}' of component '{component_id}' is declared in the IR but \
1145 missing from {}: {e}. An IR's assets travel in that directory; copying the IR \
1146 without it leaves a component without the files it declared.",
1147 source_root.display()
1148 )
1149 })?;
1150 assert_resolves_inside(&source_root, &source, "asset source")?;
1151 if !source
1152 .metadata()
1153 .map_err(|e| format!("failed to inspect {}: {e}", source.display()))?
1154 .is_file()
1155 {
1156 let message = [
1157 format!("asset '{relative}' of component '{component_id}' is not a regular"),
1158 format!("file (found {:?}).", metadata.file_type()),
1159 "Reading a pipe or device would block the dispatch indefinitely instead of"
1160 .to_string(),
1161 "failing, so anything that is not a plain file is refused.".to_string(),
1162 ]
1163 .join(" ");
1164 return Err(message);
1165 }
1166 let data = std::fs::read(&source)
1167 .map_err(|e| format!("failed to read {}: {e}", source.display()))?;
1168 let actual = format!("sha256:{:x}", Sha256::digest(&data));
1169 if actual != expected {
1170 return Err(format!(
1171 "asset '{relative}' of component '{component_id}' does not match its manifest \
1172 (expected {expected}, found {actual}). The file changed after the IR was \
1173 compiled; recompile rather than dispatching content the manifest does not name."
1174 ));
1175 }
1176 let target = out_dir.join(relative);
1177 assert_resolves_inside(out_dir, &target, "asset target")?;
1178 pending.push((target, data));
1179 }
1180 }
1181
1182 let mut landed = Vec::with_capacity(pending.len());
1183 for (target, data) in pending {
1184 if let Some(parent) = target.parent() {
1185 std::fs::create_dir_all(parent)
1186 .map_err(|e| format!("failed to create {}: {e}", parent.display()))?;
1187 }
1188 std::fs::write(&target, &data)
1189 .map_err(|e| format!("failed to write {}: {e}", target.display()))?;
1190 landed.push(target);
1191 }
1192 Ok(landed)
1193}
1194
1195fn assert_contained_relative_path(relative: &str, component_id: &str) -> Result<(), String> {
1202 let path = Path::new(relative);
1203 if path.is_absolute()
1204 || path
1205 .components()
1206 .any(|c| matches!(c, std::path::Component::ParentDir))
1207 {
1208 let message = [
1209 format!("asset path '{relative}' of component '{component_id}' must be a relative"),
1210 "path with no '..' segments. A manifest naming a path outside the directory it"
1211 .to_string(),
1212 "lands in would be an arbitrary file write, so it is refused rather than".to_string(),
1213 "resolved.".to_string(),
1214 ]
1215 .join(" ");
1216 return Err(message);
1217 }
1218 Ok(())
1219}
1220
1221fn assert_resolves_inside(root: &Path, target: &Path, what: &str) -> Result<(), String> {
1232 let canonical_root = root
1233 .canonicalize()
1234 .map_err(|e| format!("failed to resolve {}: {e}", root.display()))?;
1235
1236 let mut existing = target;
1237 let anchor = loop {
1238 if existing.exists() {
1239 break existing
1240 .canonicalize()
1241 .map_err(|e| format!("failed to resolve {}: {e}", existing.display()))?;
1242 }
1243 match existing.parent() {
1244 Some(parent) => existing = parent,
1245 None => {
1246 return Err(format!(
1247 "{what} '{}' has no resolvable ancestor",
1248 target.display()
1249 ))
1250 }
1251 }
1252 };
1253
1254 if !anchor.starts_with(&canonical_root) {
1255 let message = [
1256 format!(
1257 "{what} '{}' resolves outside {}",
1258 target.display(),
1259 canonical_root.display()
1260 ),
1261 "once symlinks are followed. A path that looks contained but is not is refused rather"
1262 .to_string(),
1263 "than followed, because the manifest is data and the filesystem is what decides where"
1264 .to_string(),
1265 "a write actually lands.".to_string(),
1266 ]
1267 .join(" ");
1268 return Err(message);
1269 }
1270 Ok(())
1271}