@@ -246,6 +246,7 @@ def verify(
246246 manifest_path : str | Path ,
247247 detect_new : bool = False ,
248248 accepted_manifest_sha256 : str | None = None ,
249+ expected_roots : list [str | Path ] | None = None ,
249250) -> ModuleResult :
250251 findings = []
251252 manifest = Path (manifest_path )
@@ -308,6 +309,67 @@ def verify(
308309 if record .get ("record_type" ) not in {"manifest_header" , "source_record" , "duplicate_group" , "manifest_summary" }:
309310 findings .append (finding ("CI09_MALFORMED_MANIFEST" , "ERROR" , str (manifest ), f"line { index } " , "unknown manifest record type" , record .get ("record_type" )))
310311
312+ if expected_roots is not None :
313+ roots = header .get ("roots" ) if header else None
314+ real_roots = header .get ("real_roots" ) if header else None
315+ header_is_supported = (
316+ bool (header )
317+ and header .get ("schema_version" ) == SCHEMA_VERSION
318+ and header .get ("rule_version" ) == RULE_VERSION
319+ )
320+ roots_are_valid = (
321+ isinstance (roots , list )
322+ and isinstance (real_roots , list )
323+ and all (isinstance (item , str ) for item in roots )
324+ and all (isinstance (item , str ) for item in real_roots )
325+ and len (roots ) == len (real_roots )
326+ )
327+ if not header_is_supported or not roots_are_valid :
328+ if header_is_supported and not roots_are_valid :
329+ findings .append (finding (
330+ "CI09_MALFORMED_MANIFEST" ,
331+ "ERROR" ,
332+ str (manifest ),
333+ "manifest_header.roots" ,
334+ "expected-root assertion requires equally sized string roots and real_roots arrays" ,
335+ {"roots_type" : type (roots ).__name__ , "real_roots_type" : type (real_roots ).__name__ },
336+ ))
337+ sorted_items = sort_findings (findings )
338+ result , exit_code = outcome (sorted_items , "normal" , family = "integrity" )
339+ return ModuleResult (result , MODULE_ID , rule_set_version = RULE_VERSION , findings = sorted_items , facts = [], exit_code = exit_code , data = {"counts" : {name .lower (): 0 for name in ("MATCH" , "MISSING" , "CHANGED" , "TYPE_CHANGED" , "SELF_INGESTED" )}, "detect_new" : detect_new })
340+
341+ expected_forms = [_path_forms (Path (item )) for item in expected_roots ]
342+ expected_lexical = [str (lexical ) for lexical , _ in expected_forms ]
343+ expected_real = [str (real ) for _ , real in expected_forms ]
344+ mismatch_classes = []
345+ if len (expected_lexical ) != len (roots ):
346+ mismatch_classes .append ("CARDINALITY_MISMATCH" )
347+ else :
348+ if expected_lexical != roots :
349+ mismatch_classes .append ("LEXICAL_MISMATCH" )
350+ if expected_real != real_roots :
351+ mismatch_classes .append ("REAL_ROOT_MISMATCH" )
352+ expected_pairs = list (zip (expected_lexical , expected_real ))
353+ recorded_pairs = list (zip (roots , real_roots ))
354+ if expected_pairs != recorded_pairs and sorted (expected_pairs ) == sorted (recorded_pairs ):
355+ mismatch_classes .append ("ORDER_MISMATCH" )
356+ if mismatch_classes :
357+ findings .append (finding (
358+ "CI13_EXPECTED_ROOT_MISMATCH" ,
359+ "HOLD" ,
360+ str (manifest ),
361+ "expected_root" ,
362+ "caller expected roots do not match the manifest-declared root identities" ,
363+ {
364+ "mismatch_classes" : mismatch_classes ,
365+ "expected" : {"roots" : expected_lexical , "real_roots" : expected_real },
366+ "recorded" : {"roots" : roots , "real_roots" : real_roots },
367+ },
368+ ))
369+ sorted_items = sort_findings (findings )
370+ result , exit_code = outcome (sorted_items , "normal" , family = "integrity" )
371+ return ModuleResult (result , MODULE_ID , rule_set_version = RULE_VERSION , findings = sorted_items , facts = [], exit_code = exit_code , data = {"counts" : {name .lower (): 0 for name in ("MATCH" , "MISSING" , "CHANGED" , "TYPE_CHANGED" , "SELF_INGESTED" )}, "detect_new" : detect_new })
372+
311373 exclusions = [Path (value ) for value in header .get ("exclusions" , []) if isinstance (value , str )]
312374 states : list [dict [str , Any ]] = []
313375 source_records = [item for item in records if item .get ("record_type" ) == "source_record" ]
0 commit comments