1use std::{
8 collections::{BTreeMap, BTreeSet, HashSet},
9 fmt, fs,
10 path::Path,
11 process::Stdio,
12 time::Duration,
13};
14
15use serde::{
16 de::{self, MapAccess, Visitor},
17 Deserialize, Deserializer,
18};
19use serde_json::Value;
20use subc_protocol::{
21 manifest::{validate_manifest_capability_grammar, CapabilityNeed, ModuleManifest},
22 PROTOCOL_VERSION,
23};
24use tokio::{process::Command, time};
25
26use crate::daemon_config::{self, ConfiguredModule};
27
28pub const MANIFEST_TIMEOUT: Duration = Duration::from_secs(10);
31
32#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
34pub enum OperationalClass {
35 ProgramMissing,
36 ProgramNotExecutable,
37 ManifestTimeout,
38 ManifestExitNonzero,
39 ManifestUnparsable,
40 ManifestVersionUnsupported,
41 DuplicateModuleId,
42 ManifestInvalid,
43}
44
45impl OperationalClass {
46 pub const fn as_str(self) -> &'static str {
47 match self {
48 Self::ProgramMissing => "program_missing",
49 Self::ProgramNotExecutable => "program_not_executable",
50 Self::ManifestTimeout => "manifest_timeout",
51 Self::ManifestExitNonzero => "manifest_exit_nonzero",
52 Self::ManifestUnparsable => "manifest_unparsable",
53 Self::ManifestVersionUnsupported => "manifest_version_unsupported",
54 Self::DuplicateModuleId => "duplicate_module_id",
55 Self::ManifestInvalid => "manifest_invalid",
56 }
57 }
58}
59
60#[derive(Clone, Copy, Debug, PartialEq, Eq)]
62pub enum LintOutcome {
63 Clean,
64 SemanticViolation,
65 OperationalFailure,
66}
67
68impl LintOutcome {
69 pub const fn exit_code(self) -> i32 {
70 match self {
71 Self::Clean => 0,
72 Self::SemanticViolation => 1,
73 Self::OperationalFailure => 2,
74 }
75 }
76}
77
78#[derive(Debug)]
80pub struct LintReport {
81 pub outcome: LintOutcome,
82 pub examined: usize,
83 pub configured: usize,
84 lines: Vec<String>,
85 #[cfg(test)]
86 failures: Vec<OperationalFailure>,
87}
88
89impl LintReport {
90 pub fn render(&self) -> String {
93 self.lines.join("\n")
94 }
95
96 #[cfg(test)]
97 fn has_failure(&self, class: OperationalClass, module: &str) -> bool {
98 self.failures
99 .iter()
100 .any(|failure| failure.class == class && failure.module == module)
101 }
102}
103
104#[derive(Debug)]
105pub struct LintConfigError(String);
106
107impl fmt::Display for LintConfigError {
108 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
109 formatter.write_str(&self.0)
110 }
111}
112
113impl std::error::Error for LintConfigError {}
114
115#[derive(Debug)]
116struct OperationalFailure {
117 class: OperationalClass,
118 module: String,
119}
120
121#[derive(Debug)]
122struct ExaminedManifest {
123 module_id: String,
124 enabled: bool,
125 manifest: ModuleManifest,
126}
127
128#[derive(Debug)]
129struct RequirementLine {
130 consumer: String,
131 capability: String,
132 text: String,
133}
134
135pub async fn lint(path: impl AsRef<Path>, verbose: bool) -> Result<LintReport, LintConfigError> {
137 lint_with_timeout(path.as_ref(), verbose, MANIFEST_TIMEOUT).await
138}
139
140async fn lint_with_timeout(
141 path: &Path,
142 verbose: bool,
143 manifest_timeout: Duration,
144) -> Result<LintReport, LintConfigError> {
145 let duplicate_module_ids = duplicate_module_ids(path)?;
146 let config = daemon_config::load(path)
147 .map_err(|error| LintConfigError(format!("failed to parse {}: {error}", path.display())))?
148 .ok_or_else(|| {
149 LintConfigError(format!("daemon config {} does not exist", path.display()))
150 })?;
151
152 let mut modules = config.modules.iter().collect::<Vec<_>>();
153 modules.sort_by(|left, right| left.module_id.cmp(&right.module_id));
154 let mut failures = duplicate_module_ids
155 .into_iter()
156 .map(|module| OperationalFailure {
157 class: OperationalClass::DuplicateModuleId,
158 module,
159 })
160 .collect::<Vec<_>>();
161 let mut skipped_daemons = Vec::new();
162 let mut examined = Vec::new();
163
164 for module in modules {
165 if is_daemon_entry(module) {
166 skipped_daemons.push(module.module_id.clone());
167 continue;
168 }
169
170 match read_manifest(module, manifest_timeout).await {
171 Ok(manifest) => examined.push(ExaminedManifest {
172 module_id: module.module_id.clone(),
173 enabled: module.enabled,
174 manifest,
175 }),
176 Err(class) => failures.push(OperationalFailure {
177 class,
178 module: module.module_id.clone(),
179 }),
180 }
181 }
182
183 let configured = config
184 .modules
185 .iter()
186 .filter(|module| !is_daemon_entry(module))
187 .count();
188 let unavailable = failures
189 .iter()
190 .filter(|failure| failure.class != OperationalClass::DuplicateModuleId)
191 .map(|failure| failure.module.as_str())
192 .collect::<BTreeSet<_>>()
193 .into_iter()
194 .collect::<Vec<_>>();
195 let checked = format!(
196 "checked {} of {configured} configured modules",
197 examined.len()
198 );
199 let mut lines = vec![if unavailable.is_empty() {
200 checked
201 } else {
202 format!(
203 "{checked} — {} do not expose a manifest",
204 unavailable.join(", ")
205 )
206 }];
207
208 if verbose {
209 for module in &skipped_daemons {
210 lines.push(format!("verbose: skipped daemon entry {module}"));
211 }
212 }
213
214 failures.sort_by(|left, right| {
215 left.class
216 .cmp(&right.class)
217 .then_with(|| left.module.cmp(&right.module))
218 });
219 if verbose {
220 for failure in &failures {
221 lines.push(format!(
222 "partial: evaluation incomplete ({}: {})",
223 failure.class.as_str(),
224 failure.module
225 ));
226 }
227 if examined.is_empty() {
228 lines.push("operational failure: no modules examined (vacuity floor)".to_string());
232 }
233 lines.push("deny consistency = self-contradiction check".to_string());
234 }
235
236 let enabled_providers = capability_claimants(&examined, true);
237 let all_providers = capability_claimants(&examined, false);
238 let mut has_semantic_violation = false;
239 let mut deny_violations = Vec::new();
240 let mut requirement_lines = Vec::new();
241
242 for entry in &examined {
243 let Some(capabilities) = &entry.manifest.capabilities else {
244 continue;
245 };
246 if entry.enabled {
247 for requirement in &capabilities.requires {
248 let provided = enabled_providers.contains_key(&requirement.capability);
249 match requirement.need {
250 CapabilityNeed::Required => {
251 let text = if provided {
252 format!(
253 "required {} {}: provided",
254 entry.module_id, requirement.capability
255 )
256 } else {
257 let text = format!(
258 "required {} {}: no enabled provider",
259 entry.module_id, requirement.capability
260 );
261 has_semantic_violation = true;
262 text
263 };
264 requirement_lines.push(RequirementLine {
265 consumer: entry.module_id.clone(),
266 capability: requirement.capability.clone(),
267 text,
268 });
269 }
270 CapabilityNeed::Optional if verbose && !provided => {
271 requirement_lines.push(RequirementLine {
272 consumer: entry.module_id.clone(),
273 capability: requirement.capability.clone(),
274 text: format!(
275 "optional {}: no provider (consumer degrades, by declaration)",
276 requirement.capability
277 ),
278 });
279 }
280 CapabilityNeed::Optional => {}
281 }
282 }
283 }
284
285 let denied = capabilities
286 .must_never_reach
287 .iter()
288 .collect::<BTreeSet<_>>();
289 for requirement in &capabilities.requires {
290 if denied.contains(&requirement.capability) {
291 has_semantic_violation = true;
292 deny_violations.push(format!(
293 "requires_deny_conflict module={} capability={}",
294 entry.module_id, requirement.capability
295 ));
296 }
297 }
298 }
299
300 requirement_lines.sort_by(|left, right| {
301 left.consumer
302 .cmp(&right.consumer)
303 .then_with(|| left.capability.cmp(&right.capability))
304 });
305 lines.extend(requirement_lines.into_iter().map(|line| line.text));
306
307 deny_violations.sort();
308 deny_violations.dedup();
309 lines.extend(deny_violations);
310
311 let mut reserved_lines = Vec::new();
312 let mut reserved_violation = false;
313 for (capability, bound_module) in &config.reserved_capabilities {
314 let claimants = all_providers.get(capability);
315 match claimants {
316 None => reserved_lines.push(format!(
317 "warning: reserved capability {capability} has no configured claimant for {bound_module}"
318 )),
319 Some(claimants) => {
320 for claimant in claimants {
321 if claimant != bound_module {
322 reserved_violation = true;
323 reserved_lines.push(format!(
324 "reserved capability {capability}: claimant {claimant} conflicts with binding {bound_module}"
325 ));
326 }
327 }
328 }
329 }
330 }
331 lines.extend(reserved_lines);
332
333 let mut disabled_notes = Vec::new();
334 for entry in &examined {
335 if entry.enabled {
336 continue;
337 }
338 let Some(capabilities) = &entry.manifest.capabilities else {
339 continue;
340 };
341 for capability in &capabilities.provides {
342 if !enabled_providers.contains_key(capability) {
343 disabled_notes.push(format!(
344 "note: {} (disabled) claims {capability}",
345 entry.module_id
346 ));
347 }
348 }
349 }
350 disabled_notes.sort();
351 disabled_notes.dedup();
352 lines.extend(disabled_notes);
353
354 let outcome = if !failures.is_empty() || examined.is_empty() {
355 LintOutcome::OperationalFailure
356 } else if has_semantic_violation || reserved_violation {
357 LintOutcome::SemanticViolation
358 } else {
359 LintOutcome::Clean
360 };
361
362 Ok(LintReport {
363 outcome,
364 examined: examined.len(),
365 configured,
366 lines,
367 #[cfg(test)]
368 failures,
369 })
370}
371
372fn capability_claimants(
373 examined: &[ExaminedManifest],
374 enabled_only: bool,
375) -> BTreeMap<String, BTreeSet<String>> {
376 let mut claims = BTreeMap::<String, BTreeSet<String>>::new();
377 for entry in examined {
378 if enabled_only && !entry.enabled {
379 continue;
380 }
381 let Some(capabilities) = &entry.manifest.capabilities else {
382 continue;
383 };
384 for capability in &capabilities.provides {
385 claims
386 .entry(capability.clone())
387 .or_default()
388 .insert(entry.module_id.clone());
389 }
390 }
391 claims
392}
393
394fn is_daemon_entry(module: &ConfiguredModule) -> bool {
395 module
396 .program
397 .file_name()
398 .and_then(|name| name.to_str())
399 .is_some_and(|name| matches!(name, "ck-subc" | "ck-subc.exe"))
400}
401
402async fn read_manifest(
403 module: &ConfiguredModule,
404 manifest_timeout: Duration,
405) -> Result<ModuleManifest, OperationalClass> {
406 let metadata = fs::metadata(&module.program).map_err(|error| {
407 if error.kind() == std::io::ErrorKind::NotFound {
408 OperationalClass::ProgramMissing
409 } else {
410 OperationalClass::ProgramNotExecutable
411 }
412 })?;
413 if !is_executable_file(&metadata) {
414 return Err(OperationalClass::ProgramNotExecutable);
415 }
416
417 let mut command = Command::new(&module.program);
418 #[cfg(test)]
421 let isolated = subc_test_support::TestTempDir::new("fleet-lint-manifest");
422 #[cfg(test)]
423 command
424 .env("XDG_DATA_HOME", isolated.path().join("data"))
425 .env("XDG_RUNTIME_DIR", isolated.path().join("runtime"))
426 .env("XDG_CONFIG_HOME", isolated.path().join("config"));
427 command
428 .arg("--manifest")
429 .stdin(Stdio::null())
430 .kill_on_drop(true);
431 let output = match time::timeout(manifest_timeout, command.output()).await {
432 Ok(Ok(output)) => output,
433 Ok(Err(_)) => return Err(OperationalClass::ProgramNotExecutable),
434 Err(_) => return Err(OperationalClass::ManifestTimeout),
435 };
436 if !output.status.success() {
437 return Err(OperationalClass::ManifestExitNonzero);
438 }
439
440 let value: Value =
441 serde_json::from_slice(&output.stdout).map_err(|_| OperationalClass::ManifestUnparsable)?;
442 validate_manifest_capability_grammar(&value).map_err(|_| OperationalClass::ManifestInvalid)?;
443 let manifest: ModuleManifest =
444 serde_json::from_value(value).map_err(|_| OperationalClass::ManifestUnparsable)?;
445 if manifest.protocol_ver != PROTOCOL_VERSION {
446 return Err(OperationalClass::ManifestVersionUnsupported);
447 }
448 Ok(manifest)
449}
450
451#[cfg(unix)]
452fn is_executable_file(metadata: &fs::Metadata) -> bool {
453 use std::os::unix::fs::PermissionsExt;
454
455 metadata.is_file() && metadata.permissions().mode() & 0o111 != 0
456}
457
458#[cfg(not(unix))]
459fn is_executable_file(metadata: &fs::Metadata) -> bool {
460 metadata.is_file()
461}
462
463fn duplicate_module_ids(path: &Path) -> Result<Vec<String>, LintConfigError> {
464 let document = fs::read_to_string(path)
465 .map_err(|error| LintConfigError(format!("failed to read {}: {error}", path.display())))?;
466 let json = subc_jsonc::jsonc_to_json(&document)
467 .map_err(|error| LintConfigError(format!("failed to parse {}: {error}", path.display())))?;
468 let probe: ModuleIdProbe = serde_json::from_str(&json)
469 .map_err(|error| LintConfigError(format!("failed to parse {}: {error}", path.display())))?;
470 Ok(probe.modules)
471}
472
473#[derive(Deserialize)]
474struct ModuleIdProbe {
475 #[serde(default, deserialize_with = "deserialize_module_ids")]
476 modules: Vec<String>,
477}
478
479fn deserialize_module_ids<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
480where
481 D: Deserializer<'de>,
482{
483 struct ModuleIdsVisitor;
484
485 impl<'de> Visitor<'de> for ModuleIdsVisitor {
486 type Value = Vec<String>;
487
488 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
489 formatter.write_str("an object keyed by module id")
490 }
491
492 fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
493 where
494 M: MapAccess<'de>,
495 {
496 let mut duplicates = Vec::new();
497 let mut seen = HashSet::new();
498 while let Some(module_id) = map.next_key::<String>()? {
499 if !seen.insert(module_id.clone()) {
500 duplicates.push(module_id);
501 }
502 map.next_value::<de::IgnoredAny>()?;
503 }
504 Ok(duplicates)
505 }
506 }
507
508 deserializer.deserialize_map(ModuleIdsVisitor)
509}
510
511#[cfg(test)]
512mod tests {
513 use std::{
514 fs,
515 path::{Path, PathBuf},
516 process::Command,
517 time::Duration,
518 };
519
520 use serde_json::{json, Map, Value};
521 use subc_protocol::PROTOCOL_VERSION;
522
523 use super::{lint_with_timeout, LintOutcome, LintReport, OperationalClass, MANIFEST_TIMEOUT};
524 use subc_test_support::TestTempDir as TempDir;
525
526 #[derive(serde::Serialize)]
527 struct FixtureSpec {
528 stdout: String,
529 exit_code: i32,
530 sleep_ms: u64,
531 #[serde(skip)]
532 executable: bool,
533 }
534
535 impl Default for FixtureSpec {
536 fn default() -> Self {
537 Self {
538 stdout: String::new(),
539 exit_code: 0,
540 sleep_ms: 0,
541 executable: true,
542 }
543 }
544 }
545
546 #[track_caller]
569 fn assert_failure(report: &LintReport, class: OperationalClass, module: &str) {
570 assert!(
571 report.has_failure(class, module),
572 "expected {class:?} for module '{module}', but the report carries {:?} \
573 (outcome {:?}, examined {} of {})",
574 report.failures,
575 report.outcome,
576 report.examined,
577 report.configured,
578 );
579 }
580
581 fn fake_aft_stub_path() -> PathBuf {
582 let mut path = std::env::current_exe().expect("current_exe available in tests");
583 path.pop(); path.pop(); path.push(if cfg!(windows) {
586 "fake-aft-stub.exe"
587 } else {
588 "fake-aft-stub"
589 });
590 assert!(
591 path.exists(),
592 "fake-aft-stub not built at {}: run `cargo test -p subc-core` (which builds [[bin]] targets) rather than `cargo test -p subc-core --lib` (which does not)",
593 path.display()
594 );
595 path
596 }
597
598 fn write_fixture_program(temp: &TempDir, name: &str, fixture: FixtureSpec) -> PathBuf {
599 let filename = if cfg!(windows) {
600 format!("{name}.exe")
601 } else {
602 name.to_string()
603 };
604 let path = temp.path().join(filename);
605 #[cfg(unix)]
608 assert!(Command::new("cp")
609 .arg(fake_aft_stub_path())
610 .arg(&path)
611 .status()
612 .expect("copy executable fixture")
613 .success());
614 #[cfg(not(unix))]
615 fs::copy(fake_aft_stub_path(), &path).unwrap();
616
617 let mut sidecar = path.as_os_str().to_os_string();
620 sidecar.push(".fixture.json");
621 fs::write(
622 PathBuf::from(sidecar),
623 serde_json::to_vec(&fixture).unwrap(),
624 )
625 .unwrap();
626
627 #[cfg(unix)]
628 {
629 use std::os::unix::fs::PermissionsExt;
630 fs::set_permissions(
631 &path,
632 fs::Permissions::from_mode(if fixture.executable { 0o755 } else { 0o644 }),
633 )
634 .unwrap();
635 }
636 #[cfg(not(unix))]
639 let _ = fixture.executable;
640 path
641 }
642
643 #[test]
644 fn fixture_sidecar_absent_preserves_existing_stub_behavior() {
645 let output = Command::new(fake_aft_stub_path())
646 .env("FAKE_AFT_EXIT_CODE", "17")
647 .output()
648 .unwrap();
649 assert_eq!(output.status.code(), Some(17));
650 }
651
652 fn manifest(module_id: &str, capabilities: Value, protocol_ver: u8) -> String {
653 json!({
654 "module_id": module_id,
655 "module_version": "0.1.0",
656 "protocol_ver": protocol_ver,
657 "trust_tier": "first_party",
658 "provides": [],
659 "consumes": [],
660 "bindings": {
661 "storage": {"kind": "sqlite", "scope": "project", "owns_schema": false},
662 "vault_grants": [],
663 "identity": {"requires": [], "optional": []}
664 },
665 "capabilities": capabilities,
666 "runtime_computed": []
667 })
668 .to_string()
669 }
670
671 fn manifest_fixture(temp: &TempDir, module_id: &str, capabilities: Value) -> PathBuf {
672 let document = manifest(module_id, capabilities, PROTOCOL_VERSION);
673 write_fixture_program(
674 temp,
675 module_id,
676 FixtureSpec {
677 stdout: document,
678 ..FixtureSpec::default()
679 },
680 )
681 }
682
683 fn write_config(
684 temp: &TempDir,
685 modules: Vec<(&str, &Path, bool)>,
686 reserved_capabilities: Value,
687 ) -> PathBuf {
688 let mut entries = Map::new();
689 for (module_id, program, enabled) in modules {
690 entries.insert(
691 module_id.to_string(),
692 json!({"program": program, "enabled": enabled}),
693 );
694 }
695 let path = temp.path().join("subc.jsonc");
696 fs::write(
697 &path,
698 json!({
699 "version": 1,
700 "modules": entries,
701 "reserved_capabilities": reserved_capabilities
702 })
703 .to_string(),
704 )
705 .unwrap();
706 path
707 }
708
709 async fn lint_config(path: &Path, verbose: bool) -> super::LintReport {
710 lint_with_timeout(path, verbose, Duration::from_secs(60))
713 .await
714 .unwrap()
715 }
716
717 #[tokio::test]
718 async fn fixture_program_missing_classifies_operational_failure() {
719 let temp = TempDir::new("program-missing");
720 let config = write_config(
721 &temp,
722 vec![("missing", &temp.path().join("missing"), true)],
723 json!({}),
724 );
725
726 let report = lint_config(&config, false).await;
727 assert_eq!(report.outcome, LintOutcome::OperationalFailure);
728 assert_failure(&report, OperationalClass::ProgramMissing, "missing");
729 }
730
731 #[tokio::test]
732 async fn fixture_program_not_executable_classifies_operational_failure() {
733 let temp = TempDir::new("program-not-executable");
734 let script = write_fixture_program(
735 &temp,
736 "not-executable",
737 FixtureSpec {
738 executable: false,
739 ..FixtureSpec::default()
740 },
741 );
742 let config = write_config(&temp, vec![("not-executable", &script, true)], json!({}));
743
744 let report = lint_config(&config, false).await;
745 assert_eq!(report.outcome, LintOutcome::OperationalFailure);
746 #[cfg(unix)]
747 assert_failure(
748 &report,
749 OperationalClass::ProgramNotExecutable,
750 "not-executable",
751 );
752 #[cfg(not(unix))]
753 {
754 assert_failure(
757 &report,
758 OperationalClass::ManifestUnparsable,
759 "not-executable",
760 );
761 }
762 }
763
764 #[tokio::test]
765 async fn fixture_manifest_timeout_classifies_operational_failure() {
766 let temp = TempDir::new("manifest-timeout");
767 let script = write_fixture_program(
768 &temp,
769 "timeout",
770 FixtureSpec {
771 sleep_ms: (MANIFEST_TIMEOUT + Duration::from_secs(1)).as_millis() as u64,
772 ..FixtureSpec::default()
773 },
774 );
775 let config = write_config(&temp, vec![("timeout", &script, true)], json!({}));
776
777 let report = lint_with_timeout(&config, false, Duration::from_millis(5))
778 .await
779 .unwrap();
780 assert_eq!(MANIFEST_TIMEOUT, Duration::from_secs(10));
781 assert_eq!(report.outcome, LintOutcome::OperationalFailure);
782 assert_failure(&report, OperationalClass::ManifestTimeout, "timeout");
783 }
784
785 #[tokio::test]
786 async fn fixture_manifest_exit_nonzero_classifies_operational_failure() {
787 let temp = TempDir::new("manifest-exit-nonzero");
788 let script = write_fixture_program(
789 &temp,
790 "nonzero",
791 FixtureSpec {
792 exit_code: 7,
793 ..FixtureSpec::default()
794 },
795 );
796 let config = write_config(&temp, vec![("nonzero", &script, true)], json!({}));
797
798 let report = lint_config(&config, false).await;
799 assert_eq!(report.outcome, LintOutcome::OperationalFailure);
800 assert_failure(&report, OperationalClass::ManifestExitNonzero, "nonzero");
801 }
802
803 #[tokio::test]
804 async fn fixture_manifest_unparsable_classifies_operational_failure() {
805 let temp = TempDir::new("manifest-unparsable");
806 let script = write_fixture_program(
807 &temp,
808 "unparsable",
809 FixtureSpec {
810 stdout: "not json\\n".to_string(),
811 ..FixtureSpec::default()
812 },
813 );
814 let config = write_config(&temp, vec![("unparsable", &script, true)], json!({}));
815
816 let report = lint_config(&config, false).await;
817 assert_eq!(report.outcome, LintOutcome::OperationalFailure);
818 assert!(
819 report.has_failure(OperationalClass::ManifestUnparsable, "unparsable"),
820 "report:\n{}",
821 report.render()
822 );
823 }
824
825 #[tokio::test]
826 async fn fixture_manifest_version_unsupported_classifies_operational_failure() {
827 let temp = TempDir::new("manifest-version-unsupported");
828 let document = manifest(
829 "unsupported",
830 json!({"provides": [], "requires": [], "must_never_reach": []}),
831 PROTOCOL_VERSION.saturating_add(1),
832 );
833 let script = write_fixture_program(
834 &temp,
835 "unsupported",
836 FixtureSpec {
837 stdout: document,
838 ..FixtureSpec::default()
839 },
840 );
841 let config = write_config(&temp, vec![("unsupported", &script, true)], json!({}));
842
843 let report = lint_config(&config, false).await;
844 assert_eq!(report.outcome, LintOutcome::OperationalFailure);
845 assert!(
846 report.has_failure(OperationalClass::ManifestVersionUnsupported, "unsupported"),
847 "report:\n{}",
848 report.render()
849 );
850 }
851
852 #[tokio::test]
853 async fn fixture_duplicate_module_id_classifies_operational_failure() {
854 let temp = TempDir::new("duplicate-module-id");
855 let script = manifest_fixture(
856 &temp,
857 "duplicate",
858 json!({"provides": [], "requires": [], "must_never_reach": []}),
859 );
860 let config = temp.path().join("subc.jsonc");
861 let program = serde_json::to_string(&script.display().to_string()).unwrap();
867 fs::write(
868 &config,
869 format!(
870 r#"{{"version":1,"modules":{{"duplicate":{{"program":{program}}},"duplicate":{{"program":{program}}}}}}}"#
871 ),
872 )
873 .unwrap();
874
875 let report = lint_config(&config, false).await;
876 assert_eq!(report.outcome, LintOutcome::OperationalFailure);
877 assert_failure(&report, OperationalClass::DuplicateModuleId, "duplicate");
878 assert_eq!(
879 report.render().lines().next().unwrap(),
880 "checked 1 of 1 configured modules",
881 "a duplicate id is an operational error, not a missing manifest"
882 );
883 }
884
885 #[tokio::test]
886 async fn fixture_manifest_invalid_classifies_operational_failure() {
887 let temp = TempDir::new("manifest-invalid");
888 let script = manifest_fixture(
889 &temp,
890 "invalid",
891 json!({"provides": ["Not-valid/v1"], "requires": [], "must_never_reach": []}),
892 );
893 let config = write_config(&temp, vec![("invalid", &script, true)], json!({}));
894
895 let report = lint_config(&config, false).await;
896 assert_eq!(report.outcome, LintOutcome::OperationalFailure);
897 assert_failure(&report, OperationalClass::ManifestInvalid, "invalid");
898 }
899
900 #[tokio::test]
901 async fn disabled_modules_are_still_manifest_validated() {
902 let temp = TempDir::new("disabled-manifest-invalid");
903 let script = manifest_fixture(
904 &temp,
905 "disabled-invalid",
906 json!({"provides": ["Not-valid/v1"], "requires": [], "must_never_reach": []}),
907 );
908 let config = write_config(&temp, vec![("disabled-invalid", &script, false)], json!({}));
909
910 let report = lint_config(&config, false).await;
911 assert_eq!(report.outcome, LintOutcome::OperationalFailure);
912 assert_failure(
913 &report,
914 OperationalClass::ManifestInvalid,
915 "disabled-invalid",
916 );
917 }
918
919 #[tokio::test]
920 async fn golden_disabled_claimant_count_daemon_skip_and_verbose_optional_inventory() {
921 let temp = TempDir::new("disabled-claimant");
922 let consumer = manifest_fixture(
923 &temp,
924 "consumer",
925 json!({
926 "provides": [],
927 "requires": [
928 {"capability": "credentials-provider/v1", "need": "required"},
929 {"capability": "context-transform/v1", "need": "optional"}
930 ],
931 "must_never_reach": []
932 }),
933 );
934 let disabled = manifest_fixture(
935 &temp,
936 "disabled",
937 json!({"provides": ["credentials-provider/v1"], "requires": [], "must_never_reach": []}),
938 );
939 let daemon = temp.path().join("ck-subc");
940 let config = write_config(
941 &temp,
942 vec![
943 ("daemon", &daemon, true),
944 ("consumer", &consumer, true),
945 ("disabled", &disabled, false),
946 ],
947 json!({}),
948 );
949
950 let report = lint_config(&config, true).await;
951 assert_eq!(report.outcome, LintOutcome::SemanticViolation);
952 assert_eq!(report.examined, 2);
953 assert_eq!(report.configured, 2);
954 assert_eq!(
955 report.render(),
956 "checked 2 of 2 configured modules\n\
957verbose: skipped daemon entry daemon\n\
958deny consistency = self-contradiction check\n\
959optional context-transform/v1: no provider (consumer degrades, by declaration)\n\
960required consumer credentials-provider/v1: no enabled provider\n\
961note: disabled (disabled) claims credentials-provider/v1"
962 );
963 let default_report = lint_config(&config, false).await;
964 assert!(
965 !default_report
966 .render()
967 .contains("optional context-transform/v1"),
968 "default report must not style declared optional degradation as a warning:\n{}",
969 default_report.render()
970 );
971 }
972
973 #[tokio::test]
974 async fn golden_requirement_lines_sort_by_consumer_then_capability() {
975 let temp = TempDir::new("requirement-order");
976 let alpha = manifest_fixture(
977 &temp,
978 "alpha",
979 json!({"provides": [], "requires": [{"capability": "alpha/v1", "need": "required"}], "must_never_reach": []}),
980 );
981 let zeta = manifest_fixture(
982 &temp,
983 "zeta",
984 json!({"provides": [], "requires": [{"capability": "zeta/v1", "need": "required"}], "must_never_reach": []}),
985 );
986 let config = write_config(
987 &temp,
988 vec![("zeta", &zeta, true), ("alpha", &alpha, true)],
989 json!({}),
990 );
991
992 let report = lint_config(&config, false).await;
993 let rendered = report.render();
994 assert!(
995 rendered.find("required alpha alpha/v1").unwrap()
996 < rendered.find("required zeta zeta/v1").unwrap(),
997 "report:\n{rendered}"
998 );
999 }
1000
1001 #[tokio::test]
1002 async fn deny_self_contradiction_mutation_proof_requires_overlap() {
1003 let temp = TempDir::new("deny-self-contradiction");
1004 let self_contradiction = manifest_fixture(
1005 &temp,
1006 "contradictory",
1007 json!({
1008 "provides": [],
1009 "requires": [{"capability": "credentials-provider/v1", "need": "required"}],
1010 "must_never_reach": ["credentials-provider/v1"]
1011 }),
1012 );
1013 let config = write_config(
1014 &temp,
1015 vec![("contradictory", &self_contradiction, true)],
1016 json!({}),
1017 );
1018
1019 let report = lint_config(&config, false).await;
1020 assert_eq!(report.outcome, LintOutcome::SemanticViolation);
1021 assert!(
1022 !report
1023 .render()
1024 .contains("deny consistency = self-contradiction check"),
1025 "internal consistency vocabulary belongs behind --verbose"
1026 );
1027 let verbose = lint_config(&config, true).await;
1028 assert!(verbose
1029 .render()
1030 .contains("deny consistency = self-contradiction check"));
1031 assert!(verbose.render().contains(
1032 "requires_deny_conflict module=contradictory capability=credentials-provider/v1"
1033 ));
1034 }
1035
1036 #[tokio::test]
1037 async fn operational_failure_overrides_semantic_exit_classification() {
1038 let temp = TempDir::new("operational-trump");
1039 let consumer = manifest_fixture(
1040 &temp,
1041 "consumer",
1042 json!({"provides": [], "requires": [{"capability": "credentials-provider/v1", "need": "required"}], "must_never_reach": []}),
1043 );
1044 let broken = write_fixture_program(
1045 &temp,
1046 "broken",
1047 FixtureSpec {
1048 exit_code: 1,
1049 ..FixtureSpec::default()
1050 },
1051 );
1052 let config = write_config(
1053 &temp,
1054 vec![("consumer", &consumer, true), ("broken", &broken, true)],
1055 json!({}),
1056 );
1057
1058 let report = lint_config(&config, false).await;
1059 assert_eq!(report.outcome, LintOutcome::OperationalFailure);
1060 assert!(
1061 report
1062 .render()
1063 .contains("checked 1 of 2 configured modules — broken do not expose a manifest"),
1064 "report:\n{}",
1065 report.render()
1066 );
1067 assert!(
1068 !report.render().contains("partial: evaluation incomplete"),
1069 "instrument detail belongs behind --verbose:\n{}",
1070 report.render()
1071 );
1072 assert!(report
1073 .render()
1074 .contains("required consumer credentials-provider/v1: no enabled provider"));
1075 }
1076
1077 #[tokio::test]
1078 async fn zero_examined_is_an_operational_failure_not_a_vacuous_pass() {
1079 let temp = TempDir::new("vacuity-floor");
1080 let config = write_config(&temp, Vec::new(), json!({}));
1081
1082 let report = lint_config(&config, false).await;
1083 assert_eq!(report.outcome, LintOutcome::OperationalFailure);
1084 assert_eq!(report.render(), "checked 0 of 0 configured modules");
1085 let verbose = lint_config(&config, true).await;
1086 assert!(verbose.render().contains("vacuity floor"));
1087 assert!(verbose
1088 .render()
1089 .contains("deny consistency = self-contradiction check"));
1090 }
1091
1092 #[tokio::test]
1093 async fn reserved_bindings_warn_when_unclaimed_and_fail_for_conflicting_claimants() {
1094 let temp = TempDir::new("reserved-bindings");
1095 let claimant = manifest_fixture(
1096 &temp,
1097 "other",
1098 json!({"provides": ["credentials-provider/v1"], "requires": [], "must_never_reach": []}),
1099 );
1100 let config = write_config(
1101 &temp,
1102 vec![("other", &claimant, true)],
1103 json!({
1104 "credentials-provider/v1": "bound",
1105 "context-transform/v1": "not-installed"
1106 }),
1107 );
1108
1109 let report = lint_config(&config, false).await;
1110 assert_eq!(report.outcome, LintOutcome::SemanticViolation);
1111 let rendered = report.render();
1112 assert!(rendered.contains(
1113 "reserved capability credentials-provider/v1: claimant other conflicts with binding bound"
1114 ));
1115 assert!(rendered.contains(
1116 "warning: reserved capability context-transform/v1 has no configured claimant for not-installed"
1117 ));
1118 }
1119}