33
44use std:: path:: { Path , PathBuf } ;
55
6+ use crate :: corpus:: { ArtifactOrigin , CorpusLayer } ;
67use crate :: output;
78use crate :: parse:: { parse_file, parse_text, Artifact , Issue } ;
89use crate :: relationships:: {
@@ -59,6 +60,10 @@ pub struct FileValidation {
5960 pub artifact_type : String ,
6061 pub status : & ' static str ,
6162 pub issues : Vec < Issue > ,
63+ /// Stable source and layer identity for rows built from a composed corpus.
64+ /// Released single-corpus validation leaves this absent so its rendered
65+ /// output remains byte-identical.
66+ pub origin : Option < ArtifactOrigin > ,
6267}
6368
6469pub struct DirectoryValidation {
@@ -126,6 +131,7 @@ pub fn validate_directory(directory: &str, recursive: bool) -> DirectoryValidati
126131 artifact_type,
127132 status : STATUS_SKIPPED ,
128133 issues : Vec :: new ( ) ,
134+ origin : None ,
129135 } ;
130136 }
131137 let issues = apply_overrides (
@@ -143,6 +149,7 @@ pub fn validate_directory(directory: &str, recursive: bool) -> DirectoryValidati
143149 artifact_type,
144150 status,
145151 issues,
152+ origin : None ,
146153 }
147154 } )
148155 . collect ( ) ;
@@ -191,6 +198,7 @@ pub(crate) fn validate_directory_from_items(
191198 artifact_type,
192199 status : STATUS_SKIPPED ,
193200 issues : Vec :: new ( ) ,
201+ origin : Some ( item. origin . clone ( ) ) ,
194202 } ;
195203 }
196204 let issues = if item. origin . layer == crate :: corpus:: Layer :: Inherited {
@@ -212,6 +220,7 @@ pub(crate) fn validate_directory_from_items(
212220 artifact_type,
213221 status,
214222 issues,
223+ origin : Some ( item. origin . clone ( ) ) ,
215224 }
216225 } )
217226 . collect ( ) ;
@@ -448,6 +457,7 @@ pub fn validate_directory_incremental_in(
448457 line : i. line . map ( i64:: from) ,
449458 } )
450459 . collect ( ) ,
460+ origin : None ,
451461 } ) ;
452462 let file_name = entry
453463 . display
@@ -565,6 +575,25 @@ fn read_validate_input(target: &str) -> Result<Artifact, i32> {
565575 read_named_file ( target)
566576}
567577
578+ /// Recover the declared inherited identity for a composed-corpus load error.
579+ /// A malformed or unreadable manifest has no trustworthy provenance, while a
580+ /// successfully parsed declaration can still identify failures from later
581+ /// materialisation, pin, or composition checks.
582+ fn manifest_failure_origin ( directory : & str ) -> Option < ArtifactOrigin > {
583+ let repository_root = crate :: validate:: repository_root ( directory) ;
584+ let manifest = crate :: federation:: load_manifest ( & repository_root)
585+ . ok ( )
586+ . flatten ( ) ?;
587+ Some (
588+ CorpusLayer :: inherited (
589+ manifest. inherits . source ,
590+ manifest. inherits . alias ,
591+ manifest. inherits . digest ,
592+ )
593+ . origin ( ) ,
594+ )
595+ }
596+
568597pub fn cmd_validate ( args : & ValidateArgs ) -> i32 {
569598 // Directory? Validate every recognized artifact beneath it.
570599 if args. file != "-" && Path :: new ( & args. file ) . is_dir ( ) {
@@ -586,22 +615,26 @@ pub fn cmd_validate(args: &ValidateArgs) -> i32 {
586615 validate_directory_incremental ( & args. file , !args. top_level , args. verify )
587616 }
588617 Ok ( None ) => validate_directory ( & args. file , !args. top_level ) ,
589- Err ( error) => DirectoryValidation {
590- directory : args. file . clone ( ) ,
591- recursive : !args. top_level ,
592- files : vec ! [ FileValidation {
593- path: crate :: federation:: MANIFEST_RELATIVE_PATH . to_string( ) ,
594- artifact_type: "corpus-manifest" . to_string( ) ,
595- status: STATUS_INVALID ,
596- issues: vec![ Issue :: new(
597- "error" ,
598- error. stable_code( ) ,
599- error. to_string( ) ,
600- None ,
601- ) ] ,
602- } ] ,
603- okf : None ,
604- } ,
618+ Err ( error) => {
619+ let origin = manifest_failure_origin ( & args. file ) ;
620+ DirectoryValidation {
621+ directory : args. file . clone ( ) ,
622+ recursive : !args. top_level ,
623+ files : vec ! [ FileValidation {
624+ path: crate :: federation:: MANIFEST_RELATIVE_PATH . to_string( ) ,
625+ artifact_type: "corpus-manifest" . to_string( ) ,
626+ status: STATUS_INVALID ,
627+ issues: vec![ Issue :: new(
628+ "error" ,
629+ error. stable_code( ) ,
630+ error. to_string( ) ,
631+ None ,
632+ ) ] ,
633+ origin,
634+ } ] ,
635+ okf : None ,
636+ }
637+ }
605638 } ;
606639 if args. sarif {
607640 emit ( output:: render_validate_sarif ( & result) ) ;
@@ -2751,3 +2784,116 @@ pub fn cmd_telemetry(args: &TelemetryArgs) -> i32 {
27512784 }
27522785 EXIT_OK
27532786}
2787+
2788+ #[ cfg( test) ]
2789+ mod validation_provenance_tests {
2790+ use std:: fs;
2791+ use std:: sync:: atomic:: { AtomicUsize , Ordering } ;
2792+
2793+ use super :: * ;
2794+
2795+ static COUNTER : AtomicUsize = AtomicUsize :: new ( 0 ) ;
2796+
2797+ fn scratch ( ) -> PathBuf {
2798+ let count = COUNTER . fetch_add ( 1 , Ordering :: SeqCst ) ;
2799+ let root = std:: env:: temp_dir ( ) . join ( format ! (
2800+ "asdecided-validation-provenance-{}-{count}" ,
2801+ std:: process:: id( )
2802+ ) ) ;
2803+ fs:: create_dir_all ( root. join ( ".decided" ) ) . unwrap ( ) ;
2804+ fs:: create_dir_all ( root. join ( "decisions" ) ) . unwrap ( ) ;
2805+ root
2806+ }
2807+
2808+ fn validation ( origin : Option < ArtifactOrigin > ) -> DirectoryValidation {
2809+ DirectoryValidation {
2810+ directory : "decisions" . to_string ( ) ,
2811+ recursive : true ,
2812+ files : vec ! [ FileValidation {
2813+ path: "decisions/example.md" . to_string( ) ,
2814+ artifact_type: "Decision" . to_string( ) ,
2815+ status: STATUS_INVALID ,
2816+ issues: vec![ Issue :: new(
2817+ "error" ,
2818+ "missing-title" ,
2819+ "Title is required." . to_string( ) ,
2820+ Some ( 3 ) ,
2821+ ) ] ,
2822+ origin,
2823+ } ] ,
2824+ okf : None ,
2825+ }
2826+ }
2827+
2828+ #[ test]
2829+ fn composed_validation_adds_machine_provenance_only ( ) {
2830+ let legacy = validation ( None ) ;
2831+ let composed = validation ( Some (
2832+ CorpusLayer :: inherited (
2833+ "acme/standards" ,
2834+ "standards" ,
2835+ "sha256:0123456789abcdef" ,
2836+ )
2837+ . origin ( ) ,
2838+ ) ) ;
2839+
2840+ assert_eq ! (
2841+ output:: render_validate_dir_human( & legacy) ,
2842+ output:: render_validate_dir_human( & composed)
2843+ ) ;
2844+
2845+ let legacy_json: serde_json:: Value =
2846+ serde_json:: from_str ( & output:: render_validate_dir_json ( & legacy) ) . unwrap ( ) ;
2847+ assert ! ( legacy_json[ "files" ] [ 0 ] . get( "provenance" ) . is_none( ) ) ;
2848+ let composed_json: serde_json:: Value =
2849+ serde_json:: from_str ( & output:: render_validate_dir_json ( & composed) ) . unwrap ( ) ;
2850+ assert_eq ! (
2851+ composed_json[ "files" ] [ 0 ] [ "provenance" ] ,
2852+ serde_json:: json!( {
2853+ "layer" : "inherited" ,
2854+ "pin" : "sha256:0123456789abcdef" ,
2855+ "source" : "acme/standards" ,
2856+ } )
2857+ ) ;
2858+
2859+ let legacy_sarif: serde_json:: Value =
2860+ serde_json:: from_str ( & output:: render_validate_sarif ( & legacy) ) . unwrap ( ) ;
2861+ assert ! ( legacy_sarif[ "runs" ] [ 0 ] [ "results" ] [ 0 ]
2862+ . get( "properties" )
2863+ . is_none( ) ) ;
2864+ let composed_sarif: serde_json:: Value =
2865+ serde_json:: from_str ( & output:: render_validate_sarif ( & composed) ) . unwrap ( ) ;
2866+ assert_eq ! (
2867+ composed_sarif[ "runs" ] [ 0 ] [ "results" ] [ 0 ] [ "properties" ] ,
2868+ composed_json[ "files" ] [ 0 ] [ "provenance" ]
2869+ ) ;
2870+ }
2871+
2872+ #[ test]
2873+ fn parsed_manifest_identity_provenances_later_load_failures ( ) {
2874+ let root = scratch ( ) ;
2875+ fs:: write (
2876+ root. join ( ".decided/config.yaml" ) ,
2877+ "repository_key: APP\n corpus:\n source: acme/app\n " ,
2878+ )
2879+ . unwrap ( ) ;
2880+ let pin = format ! ( "sha256:{}" , "0" . repeat( 64 ) ) ;
2881+ fs:: write (
2882+ root. join ( crate :: federation:: MANIFEST_RELATIVE_PATH ) ,
2883+ format ! (
2884+ "# Corpus\n \n ## inherits\n \n ```yaml\n version: 1\n alias: standards\n \
2885+ source: acme/standards\n root: vendor/standards\n corpus: decisions\n \
2886+ digest: {pin}\n ```\n "
2887+ ) ,
2888+ )
2889+ . unwrap ( ) ;
2890+
2891+ let origin = manifest_failure_origin ( & root. join ( "decisions" ) . to_string_lossy ( ) ) . unwrap ( ) ;
2892+ assert_eq ! ( origin. source, "acme/standards" ) ;
2893+ assert_eq ! ( origin. layer, crate :: corpus:: Layer :: Inherited ) ;
2894+ assert_eq ! ( origin. alias. as_deref( ) , Some ( "standards" ) ) ;
2895+ assert_eq ! ( origin. pin. as_deref( ) , Some ( pin. as_str( ) ) ) ;
2896+
2897+ fs:: remove_dir_all ( root) . unwrap ( ) ;
2898+ }
2899+ }
0 commit comments