1#![allow(dead_code)]
12
13mod backend_capability_surface;
14mod cache;
15#[path = "cache/cmd.rs"]
16mod cache_cmd;
17#[path = "commands/cli_model.rs"]
18mod cli_model;
19#[cfg(feature = "experimental-public-api")]
20#[path = "commands/command_report_contracts.rs"]
21mod command_report_contracts;
22mod commands;
23#[path = "commands/config_resolution.rs"]
24mod config_resolution;
25#[path = "commands/config_surface.rs"]
26mod config_surface;
27#[path = "replay/diff.rs"]
28mod diff;
29#[path = "inspect/doctor_cmd.rs"]
30mod doctor_cmd;
31mod explain;
32#[path = "explain/cmd.rs"]
33mod explain_cmd;
34#[path = "commands/export_cmd.rs"]
35mod export_cmd;
36mod format;
37#[path = "read/fs_input.rs"]
38mod fs_input;
39mod graph;
40#[path = "graph/cmd.rs"]
41mod graph_cmd;
42#[path = "graph/helpers.rs"]
43mod graph_helpers;
44#[path = "commands/import_cmd.rs"]
45mod import_cmd;
46mod inspect;
47#[path = "inspect/service.rs"]
48mod inspect_service;
49#[path = "inspect/integrity_service.rs"]
50mod integrity_service;
51mod migrate;
52#[path = "inspect/node_execution_explanation.rs"]
53mod node_execution_explanation;
54#[path = "commands/output_contract.rs"]
55mod output_contract;
56mod read;
57#[path = "read/read_graph.rs"]
58mod read_graph;
59#[path = "commands/reference_docs.rs"]
60mod reference_docs;
61mod repair;
62#[path = "repair/service.rs"]
63mod repair_service;
64mod replay;
65#[path = "replay/cmd.rs"]
66mod replay_cmd;
67#[path = "replay/service.rs"]
68mod replay_service;
69mod routes;
70#[path = "commands/run_cmd.rs"]
71mod run_cmd;
72#[path = "inspect/run_comparison.rs"]
73mod run_comparison;
74#[path = "read/run_data.rs"]
75mod run_data;
76#[path = "inspect/run_failure_summary.rs"]
77mod run_failure_summary;
78#[path = "inspect/run_views.rs"]
79mod run_views;
80#[path = "read/runtime_inputs.rs"]
81mod runtime_inputs;
82#[path = "inspect/status_cmd.rs"]
83mod status_cmd;
84#[path = "graph/validate_cmd.rs"]
85mod validate_cmd;
86#[cfg(feature = "experimental-public-api")]
87#[path = "commands/workspace_compatibility_contracts.rs"]
88mod workspace_compatibility_contracts;
89mod write;
90
91#[doc(hidden)]
92pub use config_surface::{
93 config_fingerprint, default_runtime_config, normalize_runtime_config, policy_evaluation_trace,
94 resolve_effective_config, CacheModeSurface, MaterializeInputsSurface,
95 PartialRuntimeSurfaceConfig, PolicySurfaceConfig, RuntimeSurfaceConfig,
96};
97#[doc(hidden)]
98pub use integrity_service::inspect_artifact;
99#[doc(hidden)]
100pub use reference_docs::write_checked_in_cli_reference_docs;
101#[doc(hidden)]
102pub use run_comparison::runs_compare;
103#[doc(hidden)]
104pub use run_failure_summary::explain_failure;
105#[doc(hidden)]
106pub use run_views::{
107 doctor_run, explain_run_id, format_inspect_human, format_run_completion_human,
108 format_show_human, inspect_summary, list_runs, resolve_run_dir, run_completion_summary,
109 run_scheduler_checkpoint, run_timeline, run_tree, runs_failures, runs_flakes, runs_history,
110 runs_history_query, runs_summary, runs_trend,
111};
112
113pub mod stable {
115 pub use crate::{
116 dag_command, dag_run, default_runtime_config, inspect_artifact, list_runs,
117 normalize_runtime_config, policy_evaluation_trace, resolve_effective_config,
118 resolve_run_dir, runs_summary, CacheModeSurface, MaterializeInputsSurface,
119 PartialRuntimeSurfaceConfig, PolicySurfaceConfig, RuntimeSurfaceConfig,
120 };
121}
122
123pub mod prelude {
125 pub use crate::stable::{
126 dag_command, dag_run, default_runtime_config, inspect_artifact, normalize_runtime_config,
127 resolve_effective_config, RuntimeSurfaceConfig,
128 };
129}
130
131#[cfg(feature = "experimental-public-api")]
133pub mod experimental {
134 pub mod command_reports {
135 pub use crate::command_report_contracts::*;
136 }
137 pub mod workspace_compatibility {
138 pub use crate::workspace_compatibility_contracts::*;
139 }
140}
141
142use crate::cache::{
143 cache_diff, cache_prune_simulate, cache_stats, explain_cache_key, explain_run_node_cache_miss,
144 pack_cache_entry, unpack_cache_entry, verify_cache_dirs,
145};
146use crate::cli_model::command_name as dag_command_name;
147use crate::integrity_service::{check_engine, hash_run_dir, verify_run};
148use base64::engine::general_purpose::STANDARD as BASE64;
149use base64::Engine;
150use bijux_dag_core::{Graph, GraphError, Severity, SPEC_VERSION};
151use bijux_dag_runtime::{CacheMode, Runtime, RuntimeConfig};
152use clap::{ArgMatches, CommandFactory, FromArgMatches};
153use commands::{
154 command_access_denial, hide_non_public_help, lane_label, CacheCommands, CommandAccessDenial,
155 Commands, ConfigCommands, DagCli, GraphFormatArg, HashCommands, MigrateCommands,
156 PolicyCommands,
157};
158use config_resolution::{
159 show_effective_config, show_effective_policy, ShowEffectiveConfigRequest,
160 ShowEffectivePolicyRequest,
161};
162use graph_helpers::*;
163use output_contract::{emit_json, LintDiagnostic};
164use run_data::env_cache_dir;
165use serde_json::{json, Value};
166use std::fs;
167use std::path::{Path, PathBuf};
168use std::process::ExitCode;
169use thiserror as _;
170
171pub fn dag_command() -> clap::Command {
172 let command = DagCli::command().name(dag_command_name()).subcommand_required(false);
173 hide_non_public_help(command, "")
174}
175
176pub fn dag_run(matches: &ArgMatches) -> Result<ExitCode, ExitCode> {
177 if matches.subcommand_name().is_none() {
178 let mut cmd = dag_command();
179 let _ = cmd.print_help();
180 println!();
181 return Ok(ExitCode::SUCCESS);
182 }
183 let cli = DagCli::from_arg_matches(matches).map_err(|_| ExitCode::from(2))?;
184 run(cli)
185}
186
187fn run(cli: DagCli) -> Result<ExitCode, ExitCode> {
188 if let Some(denial) = command_access_denial(&cli.command) {
189 return emit_command_access_denial(&cli, denial);
190 }
191 match &cli.command {
192 Commands::Init { dir } => {
193 let base = dir.clone().unwrap_or_else(|| PathBuf::from("."));
194 fs::create_dir_all(&base).map_err(|_| ExitCode::from(3))?;
195 let dag_path = base.join("dag.json");
196 if dag_path.exists() {
197 return Err(ExitCode::from(3));
198 }
199 let runs_dir = base.join("runs");
200 fs::create_dir_all(&runs_dir).map_err(|_| ExitCode::from(3))?;
201 let docs_spec_dir = base.join("docs").join("spec");
202 fs::create_dir_all(&docs_spec_dir).ok();
203 let dag = json!({
204 "spec": SPEC_VERSION,
205 "meta": {
206 "name": "hello-bijux-dag",
207 "description": "Starter Bijux DAG",
208 "owners": [],
209 "tags": []
210 },
211 "nodes": [
212 {
213 "id": "const1",
214 "kind": "const",
215 "inputs": [],
216 "outputs": [{"name": "out", "path": "out"}],
217 "params": {"value": "hello"}
218 },
219 {
220 "id": "echo",
221 "kind": "shell",
222 "inputs": ["in"],
223 "outputs": [{"name": "out", "path": "out"}],
224 "params": {"argv": ["/bin/sh","-c","cat ../inputs/const1/in > ../outputs/out"]},
225 "effects": ["filesystem"]
226 }
227 ],
228 "edges": [
229 {"from": {"node_id": "const1", "port": "out"}, "to": {"node_id": "echo", "port": "in"}}
230 ]
231 });
232 fs::write(&dag_path, serde_json::to_vec_pretty(&dag).unwrap())
233 .map_err(|_| ExitCode::from(3))?;
234 if cli.json {
235 return emit_json(
236 &cli,
237 "dag.init",
238 true,
239 json!({"dag": dag_path, "runs": runs_dir}),
240 Vec::new(),
241 ExitCode::SUCCESS,
242 );
243 } else if !cli.quiet {
244 println!("created {}", dag_path.display());
245 println!("created {}", runs_dir.display());
246 }
247 Ok(ExitCode::SUCCESS)
248 }
249 Commands::Validate { dags, strict, print_fingerprints, explain } => {
250 routes::validate_routes::handle_validate_command(
251 &cli,
252 dags,
253 *strict,
254 *print_fingerprints,
255 *explain,
256 )
257 }
258 Commands::Canonicalize { dags } => {
259 let graph = load_graphs_or_emit(&cli, "dag.canonicalize", dags)?;
260 let json = graph.to_canonical_json().map_err(|_| ExitCode::from(3))?;
261 if cli.json {
262 return emit_json(
263 &cli,
264 "dag.canonicalize",
265 true,
266 json!({ "canonical": json }),
267 Vec::new(),
268 ExitCode::SUCCESS,
269 );
270 }
271 println!("{}", json);
272 Ok(ExitCode::SUCCESS)
273 }
274 Commands::Lint { dags, strict } => {
275 let strict = *strict;
276 let graph = load_graphs_or_emit(&cli, "dag.lint", dags)?;
277 let lint = lint_graph(&graph);
278 let has_warnings = !lint.is_empty();
279 if cli.json {
280 let diagnostics: Vec<Value> =
281 lint.iter().map(|d| serde_json::to_value(d).unwrap()).collect();
282 let code =
283 if strict && has_warnings { ExitCode::from(2) } else { ExitCode::SUCCESS };
284 return emit_json(
285 &cli,
286 "dag.lint",
287 !(strict && has_warnings),
288 json!({}),
289 diagnostics,
290 code,
291 );
292 } else {
293 for warn in &lint {
294 println!("WARN {} {} {}", warn.code, warn.path, warn.message);
295 }
296 }
297 if strict && has_warnings {
298 return Err(ExitCode::from(2));
299 }
300 Ok(ExitCode::SUCCESS)
301 }
302 Commands::GraphLint { dags, strict } => {
303 let graph = load_graphs_or_emit(&cli, "dag.graph-lint", dags)?;
304 let lint = lint_graph(&graph);
305 let has_warnings = !lint.is_empty();
306 if cli.json {
307 let diagnostics: Vec<Value> =
308 lint.iter().map(|d| serde_json::to_value(d).unwrap()).collect();
309 let code =
310 if *strict && has_warnings { ExitCode::from(2) } else { ExitCode::SUCCESS };
311 return emit_json(
312 &cli,
313 "dag.graph-lint",
314 !(*strict && has_warnings),
315 json!({}),
316 diagnostics,
317 code,
318 );
319 }
320 for warn in &lint {
321 println!("WARN {} {} {}", warn.code, warn.path, warn.message);
322 }
323 if *strict && has_warnings {
324 return Err(ExitCode::from(2));
325 }
326 Ok(ExitCode::SUCCESS)
327 }
328 Commands::Fingerprint { dags, explain } => {
329 let graph = load_graphs_or_emit(&cli, "dag.fingerprint", dags)?;
330 let explained = graph.graph_fingerprint_explain().map_err(|_| ExitCode::from(3))?;
331 if cli.json {
332 return emit_json(
333 &cli,
334 "dag.fingerprint",
335 true,
336 if *explain {
337 serde_json::to_value(&explained).map_err(|_| ExitCode::from(3))?
338 } else {
339 json!({"graph": explained.graph_id.as_str()})
340 },
341 Vec::new(),
342 ExitCode::SUCCESS,
343 );
344 } else {
345 if *explain {
346 println!("{}", explained.graph_id.as_str());
347 println!("hash_algorithm={}", explained.hash_algorithm);
348 println!("canonical_json_bytes_len={}", explained.canonical_json_bytes_len);
349 } else {
350 println!("{}", explained.graph_id.as_str());
351 }
352 }
353 Ok(ExitCode::SUCCESS)
354 }
355 Commands::Hash { command } => match command {
356 HashCommands::Graph { dags, explain } => {
357 let graph = load_graphs_or_emit(&cli, "dag.hash.graph", dags)?;
358 let explained = graph.graph_fingerprint_explain().map_err(|_| ExitCode::from(3))?;
359 if cli.json {
360 return emit_json(
361 &cli,
362 "dag.hash.graph",
363 true,
364 if *explain {
365 serde_json::to_value(&explained).map_err(|_| ExitCode::from(3))?
366 } else {
367 json!({"graph_id": explained.graph_id.as_str()})
368 },
369 Vec::new(),
370 ExitCode::SUCCESS,
371 );
372 }
373 println!("{}", explained.graph_id.as_str());
374 if *explain {
375 println!("hash_algorithm={}", explained.hash_algorithm);
376 println!("canonical_json_bytes_len={}", explained.canonical_json_bytes_len);
377 }
378 Ok(ExitCode::SUCCESS)
379 }
380 HashCommands::Run { run_dir } => {
381 let digest = hash_run_dir(run_dir)?;
382 if cli.json {
383 return emit_json(
384 &cli,
385 "dag.hash.run",
386 true,
387 json!({"run_hash": digest}),
388 Vec::new(),
389 ExitCode::SUCCESS,
390 );
391 }
392 println!("{digest}");
393 Ok(ExitCode::SUCCESS)
394 }
395 HashCommands::Artifact { file } => {
396 let bytes = fs::read(file).map_err(|_| ExitCode::from(3))?;
397 let sha256 = bijux_dag_artifacts::hash::sha256_hex(&bytes);
398 if cli.json {
399 return emit_json(
400 &cli,
401 "dag.hash.artifact",
402 true,
403 json!({
404 "artifact_sha256": sha256,
405 "bytes_len": bytes.len()
406 }),
407 Vec::new(),
408 ExitCode::SUCCESS,
409 );
410 }
411 println!("{sha256}");
412 Ok(ExitCode::SUCCESS)
413 }
414 },
415 Commands::ArtifactInspect { run_dir, artifact_id } => {
416 routes::artifact_routes::handle_artifact_inspect_command(&cli, run_dir, artifact_id)
417 }
418 Commands::Artifact { command } => {
419 routes::artifact_routes::handle_artifact_command(&cli, command)
420 }
421 Commands::ControlPlane { command } => {
422 routes::control_plane_routes::handle_control_plane_command(&cli, command)
423 }
424 Commands::StateStore { command } => {
425 routes::state_store_routes::handle_state_store_command(&cli, command)
426 }
427 Commands::Dataset { command } => {
428 routes::dataset_routes::handle_dataset_command(&cli, command)
429 }
430 Commands::Enterprise { command } => {
431 routes::enterprise_routes::handle_enterprise_command(&cli, command)
432 }
433 Commands::Fleet { command } => routes::fleet_routes::handle_fleet_command(&cli, command),
434 Commands::Governance { command } => {
435 routes::governance_routes::handle_governance_command(&cli, command)
436 }
437 Commands::Incident { command } => {
438 routes::incident_routes::handle_incident_command(&cli, command)
439 }
440 Commands::Lab { command } => match command {
441 commands::LabCommands::Federation { command } => {
442 routes::federation_routes::handle_federation_command(&cli, command)
443 }
444 commands::LabCommands::Incident { command } => {
445 routes::incident_routes::handle_incident_command(&cli, command)
446 }
447 commands::LabCommands::Enterprise { command } => {
448 routes::enterprise_routes::handle_enterprise_command(&cli, command)
449 }
450 commands::LabCommands::Release { command } => {
451 routes::release_routes::handle_release_command(&cli, command)
452 }
453 commands::LabCommands::Security { command } => {
454 routes::security_routes::handle_security_command(&cli, command)
455 }
456 commands::LabCommands::Durability { command } => {
457 routes::durability_routes::handle_durability_command(&cli, command)
458 }
459 commands::LabCommands::Performance { command } => {
460 routes::performance_routes::handle_performance_command(&cli, command)
461 }
462 },
463 Commands::Federation { command } => {
464 routes::federation_routes::handle_federation_command(&cli, command)
465 }
466 Commands::Security { command } => {
467 routes::security_routes::handle_security_command(&cli, command)
468 }
469 Commands::Durability { command } => {
470 routes::durability_routes::handle_durability_command(&cli, command)
471 }
472 Commands::Performance { command } => {
473 routes::performance_routes::handle_performance_command(&cli, command)
474 }
475 Commands::Release { command } => {
476 routes::release_routes::handle_release_command(&cli, command)
477 }
478 Commands::CanonicalBytes { dags } => {
479 let graph = load_graphs_or_emit(&cli, "dag.canonical-bytes", dags)?;
480 let bytes = graph.canonical_json_bytes().map_err(|_| ExitCode::from(3))?;
481 if cli.json {
482 return emit_json(
483 &cli,
484 "dag.canonical-bytes",
485 true,
486 json!({
487 "bytes_len": bytes.len(),
488 "utf8": String::from_utf8_lossy(&bytes),
489 }),
490 Vec::new(),
491 ExitCode::SUCCESS,
492 );
493 }
494 println!("{}", String::from_utf8_lossy(&bytes));
495 Ok(ExitCode::SUCCESS)
496 }
497 Commands::CanonicalDiff { dag } => {
498 let input = read_file(dag)?;
499 let raw: Value = serde_json::from_str(&input).map_err(|_| ExitCode::from(2))?;
500 let graph = parse_graph(&input)?;
501 let canonical: Value =
502 serde_json::from_str(&graph.to_canonical_json().map_err(|_| ExitCode::from(3))?)
503 .map_err(|_| ExitCode::from(3))?;
504 let mut changed_paths = Vec::new();
505 collect_json_diff_paths("", &raw, &canonical, &mut changed_paths);
506 if cli.json {
507 return emit_json(
508 &cli,
509 "dag.canonical-diff",
510 true,
511 json!({
512 "changed_paths": changed_paths,
513 "raw": raw,
514 "canonical": canonical
515 }),
516 Vec::new(),
517 ExitCode::SUCCESS,
518 );
519 }
520 for p in changed_paths {
521 println!("{p}");
522 }
523 Ok(ExitCode::SUCCESS)
524 }
525 Commands::ShowEffectiveGraph {
526 dags,
527 run_dir,
528 select,
529 exclude,
530 from_node,
531 to_node,
532 dependency_closure,
533 } => routes::graph_routes::handle_show_effective_graph_command(
534 &cli,
535 dags,
536 run_dir,
537 select,
538 exclude,
539 from_node,
540 to_node,
541 *dependency_closure,
542 ),
543 Commands::ExplainPlan {
544 dags,
545 out,
546 run_id,
547 cache_dir,
548 absolute_path_policy,
549 jobs,
550 cpu_budget,
551 memory_budget_mb,
552 gpu_device_budget,
553 resource_capacity,
554 from_node,
555 to_node,
556 } => {
557 let graph = load_graphs_or_emit(&cli, "dag.explain-plan", dags)?;
558 graph_helpers::validate_partial_selection_surface(from_node, to_node, &[], &[], false)?;
559 let (upstream_selection_targets, _) =
560 graph_helpers::resolve_upstream_run_selection(&graph, to_node)?;
561 let (downstream_selection_roots, _) =
562 graph_helpers::resolve_downstream_run_selection(&graph, from_node)?;
563 let preview_layout = routes::plan_routes::resolve_plan_preview_layout(
564 out.as_deref(),
565 run_id.as_deref(),
566 )?;
567 let named_resource_capacities =
568 routes::resource_capacity_args::parse_resource_capacities(resource_capacity)?;
569 let preview = routes::plan_routes::PlanPreviewConfig {
570 run_root: out.clone(),
571 run_id: preview_layout.as_ref().map(|layout| layout.run_id.clone()),
572 cache_dir: cache_dir.clone(),
573 absolute_path_policy: (*absolute_path_policy).into(),
574 jobs: *jobs,
575 cpu_budget: *cpu_budget,
576 memory_budget_mb: *memory_budget_mb,
577 gpu_device_budget: *gpu_device_budget,
578 named_resource_capacities,
579 upstream_selection_targets,
580 downstream_selection_roots,
581 selectors: bijux_dag_runtime::SelectorSet::default(),
582 dependency_closure: false,
583 };
584 let analysis = routes::plan_routes::build_default_planner_analysis(&graph, &preview)
585 .map_err(|_| ExitCode::from(3))?;
586 let payload = routes::plan_routes::plan_explain_payload(
587 &analysis,
588 preview_layout.as_ref(),
589 preview.absolute_path_policy,
590 );
591 if cli.json {
592 return emit_json(
593 &cli,
594 "dag.explain-plan",
595 true,
596 payload,
597 Vec::new(),
598 ExitCode::SUCCESS,
599 );
600 }
601 for line in routes::plan_routes::concise_plan_lines(&analysis) {
602 println!("{line}");
603 }
604 Ok(ExitCode::SUCCESS)
605 }
606 Commands::Plan { command } => routes::plan_routes::handle_plan_command(&cli, command),
607 Commands::Schedule { command } => {
608 routes::schedule_routes::handle_schedule_command(&cli, command)
609 }
610 Commands::Runtime { command } => {
611 routes::runtime_routes::handle_runtime_command(&cli, command)
612 }
613 Commands::Graph { dags, format } => {
614 let graph = load_graphs_or_emit(&cli, "dag.graph", dags)?;
615 match format {
616 GraphFormatArg::Dot => {
617 let dot = graph_to_dot(&graph);
618 if cli.json {
619 return emit_json(
620 &cli,
621 "dag.graph",
622 true,
623 json!({ "dot": dot }),
624 Vec::new(),
625 ExitCode::SUCCESS,
626 );
627 }
628 println!("{}", dot);
629 }
630 }
631 Ok(ExitCode::SUCCESS)
632 }
633 Commands::Replay { command } => routes::replay_routes::handle_replay_command(
634 &cli,
635 command.run_dir.as_deref(),
636 command.source_run_id.as_deref(),
637 command.source_run_root.as_deref(),
638 &command.out,
639 command.dry_run,
640 command.sandbox,
641 command.prove,
642 command.reuse_cache,
643 command.cache,
644 command.jobs,
645 command.run_id.clone(),
646 command.cpu_budget,
647 command.memory_budget_mb,
648 command.gpu_device_budget,
649 &command.resource_capacity,
650 command.deny_network,
651 command.deny_env,
652 command.deny_clock,
653 command.clean_env,
654 command.hermetic,
655 &command.from_node,
656 &command.select,
657 &command.exclude,
658 command.dependency_closure,
659 command.materialize_inputs,
660 command.remote_cache_dir.clone(),
661 ),
662 Commands::Prove { run_dir } => {
663 routes::prove_verify_routes::handle_prove_command(&cli, run_dir)
664 }
665 Commands::ProofSummary { run_dir } => {
666 routes::prove_verify_routes::handle_proof_summary_command(&cli, run_dir)
667 }
668 Commands::Runs { command } => routes::runs_routes::handle_runs_command(&cli, command),
669 Commands::Diff { run_a, run_b, mode, node, explain } => {
670 routes::diff_routes::handle_diff_command(
671 &cli,
672 run_a,
673 run_b,
674 *mode,
675 node.as_deref(),
676 *explain,
677 "dag.diff",
678 )
679 }
680 Commands::WhyRerun { run_a, run_b, node } => {
681 routes::diagnostics_routes::handle_why_rerun_command(
682 &cli,
683 run_a,
684 run_b,
685 node.as_deref(),
686 )
687 }
688 Commands::WhyCacheMissed {
689 key,
690 expected_adapter_id,
691 expected_adapter_version,
692 run_dir,
693 node,
694 cache_dir,
695 } => {
696 let payload = if let (Some(run_dir), Some(node_id)) =
697 (run_dir.as_ref(), node.as_deref())
698 {
699 explain_run_node_cache_miss(run_dir, node_id, cache_dir.as_deref())?
700 } else {
701 let key = key.as_deref().ok_or(ExitCode::from(3))?;
702 let expected_adapter_id =
703 expected_adapter_id.as_deref().ok_or(ExitCode::from(3))?;
704 let expected_adapter_version =
705 expected_adapter_version.as_deref().ok_or(ExitCode::from(3))?;
706 let dir = cache_dir
707 .clone()
708 .or_else(env_cache_dir)
709 .unwrap_or_else(|| PathBuf::from(".bijux/cache"));
710 let report =
711 explain_cache_key(&dir, key, expected_adapter_id, expected_adapter_version)?;
712 json!({
713 "mode": "key",
714 "cache_dir": dir,
715 "key": key,
716 "eligible": report["eligible"],
717 "reasons": report["reasons"],
718 "taxonomy": report["taxonomy"],
719 "key_components": report["key_components"],
720 "proof_verified": report["proof_verified"],
721 "meta": report["meta"]
722 })
723 };
724 if cli.json {
725 return emit_json(
726 &cli,
727 "dag.why-cache-missed",
728 true,
729 payload,
730 Vec::new(),
731 ExitCode::SUCCESS,
732 );
733 }
734 println!("{}", serde_json::to_string_pretty(&payload).unwrap());
735 Ok(ExitCode::SUCCESS)
736 }
737 Commands::TraceArtifact { run_dir, artifact_id } => {
738 routes::diagnostics_routes::handle_trace_artifact_command(&cli, run_dir, artifact_id)
739 }
740 Commands::TraceNode { run_dir, id } => {
741 routes::diagnostics_routes::handle_trace_node_command(&cli, run_dir, id)
742 }
743 Commands::Run { command } => routes::run_routes::handle_run_command(
744 &cli,
745 routes::run_routes::RunRouteRequest {
746 dags: &command.dags,
747 out: &command.out,
748 input: &command.input,
749 inputs_file: command.inputs_file.clone(),
750 run_id: command.run_id.clone(),
751 resume_run: command.resume_run.clone(),
752 resume_failure_mode: command.resume_failure_mode,
753 latest: command.latest.clone(),
754 jobs: command.jobs,
755 cpu_budget: command.cpu_budget,
756 memory_budget_mb: command.memory_budget_mb,
757 gpu_device_budget: command.gpu_device_budget,
758 resource_capacity: &command.resource_capacity,
759 node_timeout_ms: command.node_timeout_ms,
760 run_timeout_ms: command.run_timeout_ms,
761 run_timeout_behavior: command.run_timeout_behavior,
762 deny_network: command.deny_network,
763 deny_env: command.deny_env,
764 deny_clock: command.deny_clock,
765 clean_env: command.clean_env,
766 hermetic: command.hermetic,
767 select: &command.select,
768 exclude: &command.exclude,
769 to_node: &command.to_node,
770 dependency_closure: command.dependency_closure,
771 materialize_inputs: command.materialize_inputs,
772 cache: command.cache,
773 cache_dir: command.cache_dir.clone(),
774 remote_cache_dir: command.remote_cache_dir.clone(),
775 absolute_path_policy: command.absolute_path_policy,
776 preflight_only: command.preflight_only,
777 explain_scheduling: command.explain_scheduling,
778 progress: command.progress,
779 backend: command.backend,
780 kubernetes_namespace: command.kubernetes_namespace.clone(),
781 kubernetes_volume_claim: command.kubernetes_volume_claim.clone(),
782 kubernetes_shared_root: command.kubernetes_shared_root.clone(),
783 slurm_queue: command.slurm_queue.clone(),
784 slurm_partition: command.slurm_partition.clone(),
785 },
786 ),
787 Commands::RunBundle { run_dir, out, redact } => {
788 routes::export_import_routes::handle_export_command(
789 &cli,
790 &Some(run_dir.clone()),
791 &None,
792 out,
793 false,
794 false,
795 false,
796 *redact,
797 true,
798 false,
799 )
800 }
801 Commands::Explain { run_dir, node } => {
802 routes::inspect_routes::handle_explain_command(&cli, run_dir, node)
803 }
804 Commands::Node { run_dir, id: node } => {
805 routes::inspect_routes::handle_node_command(&cli, run_dir, node)
806 }
807 Commands::Status { run_dir } => {
808 routes::inspect_routes::handle_status_command(&cli, run_dir)
809 }
810 Commands::Verify { run_dir, deep, strict } => {
811 routes::prove_verify_routes::handle_verify_command(&cli, run_dir, *deep, *strict)
812 }
813 Commands::Fsck { run_dir, strict } => {
814 routes::prove_verify_routes::handle_fsck_command(&cli, run_dir, *strict)
815 }
816 Commands::Doctor => {
817 let report = doctor_report()?;
818 let ok =
819 report.get("status").and_then(|v| v.as_str()).map(|v| v == "ok").unwrap_or(false);
820 if cli.json {
821 return emit_json(
822 &cli,
823 "dag.doctor",
824 ok,
825 report,
826 Vec::new(),
827 if ok { ExitCode::SUCCESS } else { ExitCode::from(3) },
828 );
829 } else {
830 println!("status: {}", report["status"]);
831 }
832 if !ok {
833 return Err(ExitCode::from(3));
834 }
835 Ok(ExitCode::SUCCESS)
836 }
837 Commands::CommandCatalog { groups, lanes } => {
838 routes::command_routes::handle_command_catalog_command(&cli, *groups, lanes)
839 }
840 Commands::Migrate { command } => {
841 let msg = match command {
842 MigrateCommands::Dag { file, from, to, dry_run } => {
843 let result = migrate_dag(file, from, to)?;
844 if *dry_run {
845 format!("dry-run: {result}")
846 } else {
847 result
848 }
849 }
850 MigrateCommands::Run { run_dir, from, to, dry_run } => {
851 let result = migrate_run(run_dir, from, to)?;
852 if *dry_run {
853 format!("dry-run: {result}")
854 } else {
855 result
856 }
857 }
858 MigrateCommands::Inspect { dag, run_dir, from, to } => {
859 let report = match (dag, run_dir) {
860 (Some(path), None) => inspect_migrate_dag(path, from, to)?,
861 (None, Some(path)) => inspect_migrate_run(path, from, to)?,
862 _ => return Err(ExitCode::from(2)),
863 };
864 if cli.json {
865 return emit_json(
866 &cli,
867 "dag.migrate.inspect",
868 true,
869 report,
870 Vec::new(),
871 ExitCode::SUCCESS,
872 );
873 }
874 println!("{}", serde_json::to_string_pretty(&report).unwrap());
875 return Ok(ExitCode::SUCCESS);
876 }
877 };
878 if cli.json {
879 return emit_json(
880 &cli,
881 "dag.migrate",
882 true,
883 json!({ "message": msg }),
884 Vec::new(),
885 ExitCode::SUCCESS,
886 );
887 } else if !cli.quiet {
888 println!("{}", msg);
889 }
890 Ok(ExitCode::SUCCESS)
891 }
892 Commands::Cache { command } => match command {
893 CacheCommands::Ls { cache_dir } => {
894 let dir = cache_dir.clone().or_else(env_cache_dir).ok_or(ExitCode::from(3))?;
895 let mut entries_vec = Vec::new();
896 if dir.exists() {
897 for entry in fs::read_dir(dir).map_err(|_| ExitCode::from(3))? {
898 let entry = entry.map_err(|_| ExitCode::from(3))?;
899 let name = entry.file_name().to_string_lossy().to_string();
900 if cli.json {
901 entries_vec.push(name);
902 } else {
903 println!("{}", name);
904 }
905 }
906 }
907 if cli.json {
908 return emit_json(
909 &cli,
910 "dag.cache.ls",
911 true,
912 json!({ "entries": entries_vec }),
913 Vec::new(),
914 ExitCode::SUCCESS,
915 );
916 }
917 Ok(ExitCode::SUCCESS)
918 }
919 CacheCommands::Pack { node_fp, out, cache_dir } => {
920 let dir = cache_dir.clone().or_else(env_cache_dir).ok_or(ExitCode::from(3))?;
921 let entry = dir.join(node_fp);
922 if !entry.exists() {
923 return Err(ExitCode::from(3));
924 }
925 pack_cache_entry(&entry, out)?;
926 if cli.json {
927 return emit_json(
928 &cli,
929 "dag.cache.pack",
930 true,
931 json!({ "pack": out }),
932 Vec::new(),
933 ExitCode::SUCCESS,
934 );
935 } else if !cli.quiet {
936 println!("pack: {}", out.display());
937 }
938 Ok(ExitCode::SUCCESS)
939 }
940 CacheCommands::Unpack { pack, cache_dir } => {
941 let dir = cache_dir.clone().or_else(env_cache_dir).ok_or(ExitCode::from(3))?;
942 unpack_cache_entry(pack, &dir)?;
943 if cli.json {
944 return emit_json(
945 &cli,
946 "dag.cache.unpack",
947 true,
948 json!({ "pack": pack }),
949 Vec::new(),
950 ExitCode::SUCCESS,
951 );
952 } else if !cli.quiet {
953 println!("unpacked: {}", pack.display());
954 }
955 Ok(ExitCode::SUCCESS)
956 }
957 CacheCommands::Gc { cache_dir } => {
958 let _dir = cache_dir.clone().or_else(env_cache_dir).ok_or(ExitCode::from(3))?;
959 if cli.json {
960 return emit_json(
961 &cli,
962 "dag.cache.gc",
963 true,
964 json!({ "status": "stub" }),
965 Vec::new(),
966 ExitCode::SUCCESS,
967 );
968 }
969 println!("cache gc stub");
970 Ok(ExitCode::SUCCESS)
971 }
972 CacheCommands::Verify { cache_dir, remote } => {
973 let dir = cache_dir.clone().or_else(env_cache_dir).ok_or(ExitCode::from(3))?;
974 let report = verify_cache_dirs(&dir, remote.as_ref().map(|v| v.as_path()))?;
975 let corrupt = report["corrupt_total"].as_u64().unwrap_or(0);
976 if cli.json {
977 return emit_json(
978 &cli,
979 "dag.cache.verify",
980 corrupt == 0,
981 report,
982 Vec::new(),
983 if corrupt == 0 { ExitCode::SUCCESS } else { ExitCode::from(3) },
984 );
985 } else {
986 println!("local_checked: {}", report["local"]["checked"]);
987 println!("local_corrupt: {}", report["local"]["corrupt"]);
988 if let Some(keys) = report["local"]["corrupt_keys"].as_array() {
989 if !keys.is_empty() {
990 println!("local_corrupt_keys: {}", report["local"]["corrupt_keys"]);
991 }
992 }
993 if let Some(remote_report) = report.get("remote") {
994 println!("remote_checked: {}", remote_report["checked"]);
995 println!("remote_corrupt: {}", remote_report["corrupt"]);
996 if let Some(keys) = remote_report["corrupt_keys"].as_array() {
997 if !keys.is_empty() {
998 println!("remote_corrupt_keys: {}", remote_report["corrupt_keys"]);
999 }
1000 }
1001 }
1002 }
1003 if corrupt > 0 {
1004 return Err(ExitCode::from(3));
1005 }
1006 Ok(ExitCode::SUCCESS)
1007 }
1008 CacheCommands::Explain {
1009 cache_dir,
1010 key,
1011 expected_adapter_id,
1012 expected_adapter_version,
1013 } => {
1014 let dir = cache_dir.clone().or_else(env_cache_dir).ok_or(ExitCode::from(3))?;
1015 let report = explain_cache_key(
1016 &dir,
1017 key,
1018 expected_adapter_id.as_deref().unwrap_or(""),
1019 expected_adapter_version.as_deref().unwrap_or(""),
1020 )?;
1021 let hit = report["eligible"].as_bool().unwrap_or(false);
1022 if cli.json {
1023 return emit_json(
1024 &cli,
1025 "dag.cache.explain",
1026 true,
1027 report,
1028 Vec::new(),
1029 ExitCode::SUCCESS,
1030 );
1031 }
1032 println!("{}", serde_json::to_string_pretty(&report).unwrap());
1033 if !hit {
1034 return Err(ExitCode::from(3));
1035 }
1036 Ok(ExitCode::SUCCESS)
1037 }
1038 CacheCommands::Stats { cache_dir } => {
1039 let dir = cache_dir.clone().or_else(env_cache_dir).ok_or(ExitCode::from(3))?;
1040 let report = cache_stats(&dir)?;
1041 if cli.json {
1042 return emit_json(
1043 &cli,
1044 "dag.cache.stats",
1045 true,
1046 report,
1047 Vec::new(),
1048 ExitCode::SUCCESS,
1049 );
1050 }
1051 println!("{}", serde_json::to_string_pretty(&report).unwrap());
1052 Ok(ExitCode::SUCCESS)
1053 }
1054 CacheCommands::PruneSimulate { cache_dir } => {
1055 let dir = cache_dir.clone().or_else(env_cache_dir).ok_or(ExitCode::from(3))?;
1056 let report = cache_prune_simulate(&dir)?;
1057 if cli.json {
1058 return emit_json(
1059 &cli,
1060 "dag.cache.prune-simulate",
1061 true,
1062 report,
1063 Vec::new(),
1064 ExitCode::SUCCESS,
1065 );
1066 }
1067 println!("{}", serde_json::to_string_pretty(&report).unwrap());
1068 Ok(ExitCode::SUCCESS)
1069 }
1070 CacheCommands::Diff { cache_dir, key_a, key_b } => {
1071 let dir = cache_dir.clone().or_else(env_cache_dir).ok_or(ExitCode::from(3))?;
1072 let report = cache_diff(&dir, &key_a, &key_b)?;
1073 let comparable = report["comparable"].as_bool().unwrap_or(false);
1074 if cli.json {
1075 return emit_json(
1076 &cli,
1077 "dag.cache.diff",
1078 true,
1079 report,
1080 Vec::new(),
1081 ExitCode::SUCCESS,
1082 );
1083 }
1084 println!("{}", serde_json::to_string_pretty(&report).unwrap());
1085 if !comparable {
1086 return Err(ExitCode::from(3));
1087 }
1088 Ok(ExitCode::SUCCESS)
1089 }
1090 },
1091 Commands::Adapters { command } => {
1092 routes::adapter_routes::handle_adapters_command(&cli, command)
1093 }
1094 Commands::Export {
1095 run_dir,
1096 from_run,
1097 out,
1098 manifest_only,
1099 without_artifacts,
1100 provenance_only,
1101 redact,
1102 with_files,
1103 include_files,
1104 } => routes::export_import_routes::handle_export_command(
1105 &cli,
1106 run_dir,
1107 from_run,
1108 out,
1109 *manifest_only,
1110 *without_artifacts,
1111 *provenance_only,
1112 *redact,
1113 *with_files,
1114 *include_files,
1115 ),
1116 Commands::Import { file, verify_only } => {
1117 routes::export_import_routes::handle_import_command(&cli, file, *verify_only)
1118 }
1119 Commands::Version => {
1120 let v = format!("bijux-dag {} ({})", env!("CARGO_PKG_VERSION"), SPEC_VERSION);
1121 if cli.json {
1122 return emit_json(
1123 &cli,
1124 "dag.version",
1125 true,
1126 json!({ "version": v }),
1127 Vec::new(),
1128 ExitCode::SUCCESS,
1129 );
1130 }
1131 println!("{}", v);
1132 Ok(ExitCode::SUCCESS)
1133 }
1134 Commands::Capabilities { backend } => {
1135 routes::surface_routes::handle_capabilities_command(&cli, backend)
1136 }
1137 Commands::SemanticPortability { backend } => {
1138 routes::surface_routes::handle_semantic_portability_command(&cli, backend)
1139 }
1140 Commands::EquivalenceProof { run_a, run_b, backend_a, backend_b } => {
1141 routes::surface_routes::handle_equivalence_proof_command(
1142 &cli, run_a, run_b, backend_a, backend_b,
1143 )
1144 }
1145 Commands::VersionInspect { dag, run_dir, export_bundle } => {
1146 let provided =
1147 dag.is_some() as u8 + run_dir.is_some() as u8 + export_bundle.is_some() as u8;
1148 if provided != 1 {
1149 return Err(ExitCode::from(2));
1150 }
1151 let mut report = json!({
1152 "binary_version": env!("CARGO_PKG_VERSION"),
1153 "graph_schema_version": Value::Null,
1154 "run_dir_format_version": Value::Null,
1155 "export_bundle_format_version": Value::Null,
1156 });
1157 if let Some(path) = dag {
1158 let input = read_file(path)?;
1159 let graph = parse_graph(&input)?;
1160 report["graph_schema_version"] = json!(graph.spec);
1161 let supported_graph_spec =
1162 graph.spec == "0.1" || graph.spec == "v0.1" || graph.spec == SPEC_VERSION;
1163 if !supported_graph_spec {
1164 report["support_status"] = json!("unsupported-graph-schema");
1165 if cli.json {
1166 return emit_json(
1167 &cli,
1168 "dag.version-inspect",
1169 false,
1170 report,
1171 vec![
1172 json!({"message":"unsupported graph schema version","remediation":"use spec 0.1"} ),
1173 ],
1174 ExitCode::from(2),
1175 );
1176 }
1177 return Err(ExitCode::from(2));
1178 }
1179 }
1180 if let Some(path) = run_dir {
1181 let manifest = read_file(&path.join("manifest.json"))?;
1182 let parsed: Value =
1183 serde_json::from_str(&manifest).map_err(|_| ExitCode::from(3))?;
1184 let run_version = parsed
1185 .get("manifest_version")
1186 .cloned()
1187 .unwrap_or_else(|| json!("run-manifest/v0.1"));
1188 report["run_dir_format_version"] = run_version.clone();
1189 report["graph_schema_version"] = parsed.get("spec").cloned().unwrap_or(Value::Null);
1190 if run_version != json!("run-manifest/v0.1") {
1191 report["support_status"] = json!("unsupported-run-dir-version");
1192 if cli.json {
1193 return emit_json(
1194 &cli,
1195 "dag.version-inspect",
1196 false,
1197 report,
1198 vec![
1199 json!({"message":"unsupported run-dir format version","remediation":"use run-manifest/v0.1"} ),
1200 ],
1201 ExitCode::from(2),
1202 );
1203 }
1204 return Err(ExitCode::from(2));
1205 }
1206 }
1207 if let Some(path) = export_bundle {
1208 let payload = read_file(path)?;
1209 let parsed: Value =
1210 serde_json::from_str(&payload).map_err(|_| ExitCode::from(3))?;
1211 let bundle_version = parsed
1212 .get("export_bundle_version")
1213 .cloned()
1214 .unwrap_or_else(|| json!("export-bundle/v0.1"));
1215 report["export_bundle_format_version"] = bundle_version.clone();
1216 report["run_dir_format_version"] = parsed
1217 .get("manifest")
1218 .and_then(|m| m.get("manifest_version"))
1219 .cloned()
1220 .unwrap_or(Value::Null);
1221 report["graph_schema_version"] = parsed
1222 .get("manifest")
1223 .and_then(|m| m.get("spec"))
1224 .cloned()
1225 .unwrap_or(Value::Null);
1226 if bundle_version != json!("export-bundle/v0.1") {
1227 report["support_status"] = json!("unsupported-export-bundle-version");
1228 if cli.json {
1229 return emit_json(
1230 &cli,
1231 "dag.version-inspect",
1232 false,
1233 report,
1234 vec![
1235 json!({"message":"unsupported export bundle version","remediation":"use export-bundle/v0.1"} ),
1236 ],
1237 ExitCode::from(2),
1238 );
1239 }
1240 return Err(ExitCode::from(2));
1241 }
1242 }
1243 if cli.json {
1244 return emit_json(
1245 &cli,
1246 "dag.version-inspect",
1247 true,
1248 report,
1249 Vec::new(),
1250 ExitCode::SUCCESS,
1251 );
1252 }
1253 println!("{}", serde_json::to_string_pretty(&report).unwrap());
1254 Ok(ExitCode::SUCCESS)
1255 }
1256 Commands::Config { command } => match command {
1257 ConfigCommands::ShowEffective { config, jobs, cache_mode, materialize_inputs } => {
1258 let effective = show_effective_config(ShowEffectiveConfigRequest {
1259 config_path: config.as_deref(),
1260 jobs: *jobs,
1261 cache_mode: *cache_mode,
1262 materialize_inputs: *materialize_inputs,
1263 })?;
1264 let payload = serde_json::to_value(&effective).map_err(|_| ExitCode::from(3))?;
1265 if cli.json {
1266 return emit_json(
1267 &cli,
1268 "dag.config.show-effective",
1269 true,
1270 payload,
1271 Vec::new(),
1272 ExitCode::SUCCESS,
1273 );
1274 }
1275 println!("{}", serde_json::to_string_pretty(&payload).unwrap());
1276 Ok(ExitCode::SUCCESS)
1277 }
1278 },
1279 Commands::Policy { command } => match command {
1280 PolicyCommands::ShowEffective {
1281 config,
1282 deny_network,
1283 deny_env,
1284 deny_clock,
1285 clean_env,
1286 allow_env,
1287 } => {
1288 let payload = show_effective_policy(ShowEffectivePolicyRequest {
1289 config_path: config.as_deref(),
1290 deny_network: *deny_network,
1291 deny_env: *deny_env,
1292 deny_clock: *deny_clock,
1293 clean_env: *clean_env,
1294 allow_env,
1295 })?;
1296 if cli.json {
1297 return emit_json(
1298 &cli,
1299 "dag.policy.show-effective",
1300 true,
1301 payload,
1302 Vec::new(),
1303 ExitCode::SUCCESS,
1304 );
1305 }
1306 println!("{}", serde_json::to_string_pretty(&payload).unwrap());
1307 Ok(ExitCode::SUCCESS)
1308 }
1309 },
1310 }
1311}
1312
1313fn emit_command_access_denial(
1314 cli: &DagCli,
1315 denial: CommandAccessDenial,
1316) -> Result<ExitCode, ExitCode> {
1317 let command = format!("dag.{}", denial.root_command);
1318 let lane = lane_label(denial.lane);
1319 let message = denial.message();
1320 let hint = format!(
1321 "set {}=1 to run this {} route intentionally or use `bijux-dag commands --lane {}` to inspect this non-stable access lane",
1322 denial.opt_in_env,
1323 lane,
1324 lane
1325 );
1326 if cli.json {
1327 return emit_json(
1328 cli,
1329 &command,
1330 false,
1331 json!({
1332 "command_family": denial.root_command,
1333 "lane": denial.lane,
1334 "access": "opt-in",
1335 "opt_in_env": denial.opt_in_env,
1336 }),
1337 vec![json!({
1338 "code": "release-boundary-opt-in",
1339 "message": message,
1340 "hint": hint,
1341 })],
1342 ExitCode::from(2),
1343 );
1344 }
1345 eprintln!("{message}");
1346 eprintln!("{hint}");
1347 Err(ExitCode::from(2))
1348}
1349
1350pub(crate) fn read_file(path: &Path) -> Result<String, ExitCode> {
1351 fs_input::read_utf8_file(path).map_err(|_| ExitCode::from(3))
1352}
1353
1354pub(crate) fn read_run_id(run_dir: &Path) -> Result<String, ExitCode> {
1355 let raw = read_file(&run_dir.join("manifest.json"))?;
1356 let value: Value = serde_json::from_str(&raw).map_err(|_| ExitCode::from(3))?;
1357 value
1358 .get("run_id")
1359 .and_then(Value::as_str)
1360 .map(std::string::ToString::to_string)
1361 .ok_or_else(|| ExitCode::from(3))
1362}
1363
1364pub(crate) fn selector_cli_string(selector: &bijux_dag_runtime::Selector) -> String {
1365 match selector {
1366 bijux_dag_runtime::Selector::Id(v) => format!("id:{v}"),
1367 bijux_dag_runtime::Selector::IdPrefix(v) => format!("id-prefix:{v}"),
1368 bijux_dag_runtime::Selector::Tag(v) => format!("tag:{v}"),
1369 bijux_dag_runtime::Selector::Kind(v) => format!("kind:{v}"),
1370 }
1371}
1372
1373pub(crate) fn parse_graph(input: &str) -> Result<Graph, ExitCode> {
1374 match read_graph::parse_graph_with_compat(input) {
1375 Ok(g) => Ok(g),
1376 Err(GraphError::Json(_)) => Err(ExitCode::from(2)),
1377 Err(GraphError::InvalidSpec(_)) => Err(ExitCode::from(1)),
1378 Err(_) => Err(ExitCode::from(3)),
1379 }
1380}
1381
1382pub(crate) fn load_graphs_or_emit(
1383 cli: &commands::DagCli,
1384 command_name: &str,
1385 dags: &[PathBuf],
1386) -> Result<Graph, ExitCode> {
1387 match read_graph::load_graphs(dags) {
1388 Ok(graph) => Ok(graph),
1389 Err(error) => {
1390 let code = error.exit_code();
1391 if cli.json {
1392 let _ = emit_json(
1393 cli,
1394 command_name,
1395 false,
1396 json!({
1397 "error": error.to_string(),
1398 "dags": dags,
1399 }),
1400 Vec::new(),
1401 code,
1402 );
1403 } else if !cli.quiet {
1404 eprintln!("{error}");
1405 }
1406 Err(code)
1407 }
1408 }
1409}
1410
1411pub(crate) fn print_human_diff(diff: &serde_json::Value) {
1412 let manifest = diff["manifest"].as_object().map(|o| o.len()).unwrap_or(0);
1413 let graph_fp = diff["graph_fingerprint"].is_null();
1414 let nodes = diff["nodes"].as_object().map(|o| o.len()).unwrap_or(0);
1415 let outputs = diff["outputs"].as_object().map(|o| o.len()).unwrap_or(0);
1416 if manifest == 0 && graph_fp && nodes == 0 && outputs == 0 {
1417 println!("no differences");
1418 return;
1419 }
1420 println!("manifest changes: {}", manifest);
1421 println!("graph_fingerprint: {}", diff["graph_fingerprint"]);
1422 println!("nodes changed: {}", nodes);
1423 println!("outputs changed: {}", outputs);
1424}
1425
1426fn collect_json_diff_paths(path: &str, left: &Value, right: &Value, out: &mut Vec<String>) {
1427 match (left, right) {
1428 (Value::Object(a), Value::Object(b)) => {
1429 let mut keys = std::collections::BTreeSet::new();
1430 keys.extend(a.keys().cloned());
1431 keys.extend(b.keys().cloned());
1432 for key in keys {
1433 let child =
1434 if path.is_empty() { format!("/{}", key) } else { format!("{}/{}", path, key) };
1435 match (a.get(&key), b.get(&key)) {
1436 (Some(lv), Some(rv)) => collect_json_diff_paths(&child, lv, rv, out),
1437 _ => out.push(child),
1438 }
1439 }
1440 }
1441 (Value::Array(a), Value::Array(b)) => {
1442 let max = a.len().max(b.len());
1443 for idx in 0..max {
1444 let child =
1445 if path.is_empty() { format!("/{}", idx) } else { format!("{}/{}", path, idx) };
1446 match (a.get(idx), b.get(idx)) {
1447 (Some(lv), Some(rv)) => collect_json_diff_paths(&child, lv, rv, out),
1448 _ => out.push(child),
1449 }
1450 }
1451 }
1452 _ => {
1453 if left != right {
1454 out.push(if path.is_empty() { "/".to_string() } else { path.to_string() });
1455 }
1456 }
1457 }
1458}
1459
1460pub(crate) fn verify_bundle_invariants(bundle: &serde_json::Value) -> Vec<String> {
1461 let mut violations = Vec::new();
1462 if bundle.get("bundle_version").and_then(|v| v.as_str()) != Some("export-bundle/v0.1") {
1463 violations.push("INV-EXPORT-VERSION-001 unsupported or missing bundle_version".to_string());
1464 }
1465 match bundle.get("export_mode").and_then(|v| v.as_str()) {
1466 Some("manifest-only")
1467 | Some("with-files")
1468 | Some("without-artifacts")
1469 | Some("provenance-only") => {}
1470 _ => violations.push("INV-EXPORT-MODE-001 unsupported or missing export_mode".to_string()),
1471 }
1472 if bundle.get("manifest").is_none() {
1473 violations.push("INV-EXPORT-VERIFY-001 missing manifest".to_string());
1474 }
1475 if bundle.get("graph_snapshot").is_none() {
1476 violations.push("INV-EXPORT-VERIFY-001 missing graph_snapshot".to_string());
1477 }
1478 if bundle.get("node_traces").and_then(|v| v.as_object()).is_none() {
1479 violations.push("INV-EXPORT-VERIFY-001 missing node_traces map".to_string());
1480 }
1481 if bundle.get("outputs").and_then(|v| v.as_object()).is_none() {
1482 violations.push("INV-EXPORT-VERIFY-001 missing outputs map".to_string());
1483 }
1484 let files = bundle.get("files");
1485 if bundle.get("export_mode").and_then(|v| v.as_str()) == Some("manifest-only")
1486 && !matches!(files, None | Some(serde_json::Value::Null))
1487 {
1488 violations.push(
1489 "INV-EXPORT-MODE-001 manifest-only bundle must not include files payload".to_string(),
1490 );
1491 }
1492 if bundle.get("export_mode").and_then(|v| v.as_str()) == Some("with-files")
1493 && !files.is_some_and(|v| v.is_object())
1494 {
1495 violations.push("INV-EXPORT-MODE-001 with-files bundle must include files map".to_string());
1496 }
1497 if let Some(files_map) = files.and_then(|v| v.as_object()) {
1498 for (node_id, node_files) in files_map {
1499 let Some(node_files) = node_files.as_object() else {
1500 violations.push(format!(
1501 "INV-EXPORT-FILES-001 files entry for node {node_id} must be object"
1502 ));
1503 continue;
1504 };
1505 for (path, encoded) in node_files {
1506 if encoded.as_str().is_none() {
1507 violations.push(format!(
1508 "INV-EXPORT-FILES-001 file payload for {node_id}/{path} must be base64 string"
1509 ));
1510 continue;
1511 }
1512 let value = encoded.as_str().unwrap_or_default();
1513 if BASE64.decode(value).is_err() {
1514 violations.push(format!(
1515 "INV-EXPORT-FILES-001 file payload for {node_id}/{path} is not valid base64"
1516 ));
1517 }
1518 }
1519 }
1520 }
1521 if bundle.get("export_mode").and_then(|v| v.as_str()) == Some("without-artifacts") {
1522 if !bundle.get("outputs").is_some_and(|v| v.as_object().is_some_and(|m| m.is_empty())) {
1523 violations.push(
1524 "INV-EXPORT-MODE-001 without-artifacts bundle must include empty outputs map"
1525 .to_string(),
1526 );
1527 }
1528 if !matches!(files, None | Some(serde_json::Value::Null)) {
1529 violations.push(
1530 "INV-EXPORT-MODE-001 without-artifacts bundle must not include files payload"
1531 .to_string(),
1532 );
1533 }
1534 }
1535 if bundle.get("export_mode").and_then(|v| v.as_str()) == Some("provenance-only") {
1536 if !bundle.get("node_traces").is_some_and(|v| v.as_object().is_some_and(|m| m.is_empty())) {
1537 violations.push(
1538 "INV-EXPORT-MODE-001 provenance-only bundle must include empty node_traces map"
1539 .to_string(),
1540 );
1541 }
1542 if !bundle.get("outputs").is_some_and(|v| v.as_object().is_some_and(|m| m.is_empty())) {
1543 violations.push(
1544 "INV-EXPORT-MODE-001 provenance-only bundle must include empty outputs map"
1545 .to_string(),
1546 );
1547 }
1548 }
1549 if let Some(traces) = bundle.get("node_traces").and_then(|v| v.as_object()) {
1550 for (node_id, trace) in traces {
1551 let trace_node_id = trace.get("node_id").and_then(|v| v.as_str());
1552 if trace_node_id != Some(node_id.as_str()) {
1553 violations.push(format!(
1554 "INV-TRACE-ATTEMPT-001 node_id mismatch for trace key {}",
1555 node_id
1556 ));
1557 }
1558 if trace.get("status").and_then(|v| v.as_str()).is_none() {
1559 violations.push(format!(
1560 "INV-TRACE-ATTEMPT-001 missing status for trace key {}",
1561 node_id
1562 ));
1563 }
1564 }
1565 }
1566 violations
1567}
1568
1569pub(crate) fn build_run_proof_bundle(run_dir: &Path) -> Result<serde_json::Value, ExitCode> {
1570 let report = verify_run(run_dir, true, true)?;
1571 let status = report.get("status").and_then(Value::as_str).unwrap_or("error").to_string();
1572 let errors = report.get("errors").and_then(Value::as_array).cloned().unwrap_or_default();
1573 let invariant_violations =
1574 report.get("invariant_violations").and_then(Value::as_array).cloned().unwrap_or_default();
1575 let has_manifest = run_dir.join("manifest.json").exists();
1576 let has_snapshot = run_dir.join("graph.snapshot.json").exists();
1577 let has_outputs = run_dir.join("outputs").join("index.json").exists();
1578 let run_id = read_run_id(run_dir).unwrap_or_else(|_| "unknown".to_string());
1579 let proof_id = format!("proof-{}", run_id);
1580
1581 let mut incomplete_reasons: Vec<String> = Vec::new();
1582 if !has_manifest {
1583 incomplete_reasons.push("missing manifest".to_string());
1584 }
1585 if !has_snapshot {
1586 incomplete_reasons.push("missing graph snapshot".to_string());
1587 }
1588 if !has_outputs {
1589 incomplete_reasons.push("missing outputs index".to_string());
1590 }
1591 if !errors.is_empty() {
1592 incomplete_reasons.push("verification errors present".to_string());
1593 }
1594 if !invariant_violations.is_empty() {
1595 incomplete_reasons.push("invariant violations present".to_string());
1596 }
1597
1598 let provenance_path = run_dir.join("provenance.json");
1599 let backend_origin = if provenance_path.exists() {
1600 let raw = read_file(&provenance_path).unwrap_or_default();
1601 let value: Value = serde_json::from_str(&raw).unwrap_or_default();
1602 value.get("source").and_then(Value::as_str).unwrap_or("native-run").to_string()
1603 } else {
1604 "native-run".to_string()
1605 };
1606
1607 let complete = incomplete_reasons.is_empty() && status == "ok";
1608 Ok(json!({
1609 "schema_version": "proof-bundle/v0.1",
1610 "proof_id": proof_id,
1611 "run_id": run_id,
1612 "run_dir": run_dir,
1613 "backend_origin": backend_origin,
1614 "status": if complete { "complete" } else { "incomplete" },
1615 "complete": complete,
1616 "determinism": if complete { "verified" } else { "insufficient-evidence" },
1617 "integrity": if complete { "verified" } else { "insufficient-evidence" },
1618 "replay_evidence": {
1619 "available": has_manifest && has_snapshot,
1620 "level": if complete { "complete" } else { "partial" }
1621 },
1622 "integrity_evidence": {
1623 "available": has_outputs,
1624 "level": if complete { "complete" } else { "partial" }
1625 },
1626 "incomplete_reasons": incomplete_reasons,
1627 "verification_errors": errors,
1628 "invariant_violations": invariant_violations,
1629 "signing": {
1630 "signed": false,
1631 "signature_format": Value::Null,
1632 "signature": Value::Null,
1633 "trust_level": "unsigned"
1634 }
1635 }))
1636}
1637
1638#[cfg(test)]
1639mod invariant_bundle_tests {
1640 use super::verify_bundle_invariants;
1641 use serde_json::json;
1642
1643 #[test]
1644 fn bundle_invariants_accept_well_formed_bundle() {
1645 let bundle = json!({
1646 "bundle_version":"export-bundle/v0.1",
1647 "export_mode":"manifest-only",
1648 "manifest": {"status":"completed"},
1649 "graph_snapshot": {"nodes":[],"edges":[]},
1650 "node_traces": {
1651 "n1": {"node_id":"n1","status":"success"}
1652 },
1653 "outputs": {},
1654 "files": null
1655 });
1656 let violations = verify_bundle_invariants(&bundle);
1657 assert!(violations.is_empty());
1658 }
1659
1660 #[test]
1661 fn bundle_invariants_reject_missing_and_incoherent_fields() {
1662 let bundle = json!({
1663 "graph_snapshot": {},
1664 "node_traces": {
1665 "n1": {"node_id":"n2"}
1666 }
1667 });
1668 let violations = verify_bundle_invariants(&bundle);
1669 assert!(!violations.is_empty());
1670 assert!(violations.iter().any(|v| v.contains("INV-EXPORT-VERIFY-001")));
1671 assert!(violations.iter().any(|v| v.contains("INV-TRACE-ATTEMPT-001")));
1672 }
1673}
1674
1675#[cfg(test)]
1676mod cache_archive_hardening_tests {
1677 use super::ExitCode;
1678 use crate::cache::unpack_cache_archive_bounded;
1679 use tar::{Builder, Header};
1680
1681 fn unpack_status(bytes: Vec<u8>) -> Result<(), ExitCode> {
1682 let dec = flate2::read::GzDecoder::new(std::io::Cursor::new(bytes));
1683 let mut archive = tar::Archive::new(dec);
1684 let dst = tempfile::tempdir().expect("tempdir");
1685 unpack_cache_archive_bounded(&mut archive, dst.path())
1686 }
1687
1688 #[test]
1689 fn cache_unpack_rejects_oversized_archive_entries() {
1690 let mut tar_bytes = Vec::new();
1691 {
1692 let enc = flate2::write::GzEncoder::new(&mut tar_bytes, flate2::Compression::default());
1693 let mut builder = Builder::new(enc);
1694 let mut header = Header::new_gnu();
1695 let payload = vec![0u8; (9 * 1024 * 1024) as usize];
1696 header.set_size(payload.len() as u64);
1697 header.set_mode(0o644);
1698 header.set_cksum();
1699 builder.append_data(&mut header, "meta.json", payload.as_slice()).expect("append");
1700 let enc = builder.into_inner().expect("encoder");
1701 enc.finish().expect("finish");
1702 }
1703 assert!(unpack_status(tar_bytes).is_err());
1704 }
1705
1706 #[test]
1707 fn cache_unpack_rejects_symlink_entries() {
1708 let mut tar_bytes = Vec::new();
1709 {
1710 let enc = flate2::write::GzEncoder::new(&mut tar_bytes, flate2::Compression::default());
1711 let mut builder = Builder::new(enc);
1712 let mut header = Header::new_gnu();
1713 header.set_entry_type(tar::EntryType::Symlink);
1714 header.set_size(0);
1715 header.set_mode(0o644);
1716 header.set_cksum();
1717 builder.append_link(&mut header, "bad-link", "/tmp/escape").expect("append symlink");
1718 let enc = builder.into_inner().expect("encoder");
1719 enc.finish().expect("finish");
1720 }
1721 assert!(unpack_status(tar_bytes).is_err());
1722 }
1723
1724 #[test]
1725 fn cache_unpack_accepts_regular_files_and_directories() {
1726 let mut tar_bytes = Vec::new();
1727 {
1728 let enc = flate2::write::GzEncoder::new(&mut tar_bytes, flate2::Compression::default());
1729 let mut builder = Builder::new(enc);
1730
1731 let mut dir_header = Header::new_gnu();
1732 dir_header.set_entry_type(tar::EntryType::Directory);
1733 dir_header.set_size(0);
1734 dir_header.set_mode(0o755);
1735 dir_header.set_cksum();
1736 builder.append_data(&mut dir_header, "node", std::io::empty()).expect("dir");
1737
1738 let mut file_header = Header::new_gnu();
1739 let body = br#"{"node_fingerprint":"k"}"#;
1740 file_header.set_size(body.len() as u64);
1741 file_header.set_mode(0o644);
1742 file_header.set_cksum();
1743 builder.append_data(&mut file_header, "meta.json", &body[..]).expect("file");
1744
1745 let enc = builder.into_inner().expect("encoder");
1746 enc.finish().expect("finish");
1747 }
1748 let status = unpack_status(tar_bytes);
1749 assert!(status.is_ok());
1750 }
1751}