@@ -127,12 +127,15 @@ pub(crate) fn verify_lab_workspace_git_root(
127127 if head != provenance. source_revision {
128128 return Err ( "Git HEAD does not match the verified source revision" . to_string ( ) ) ;
129129 }
130+ if !git ( workspace, & [ "status" , "--porcelain" ] ) ?. is_empty ( ) {
131+ return Err ( "Git workspace is not clean" . to_string ( ) ) ;
132+ }
130133 Ok ( ( ) )
131134}
132135
133- /// Verifies a Lab-materialized workspace against the controller-produced
134- /// content digest. Environment values transport the contract; the remote bytes
135- /// are the authority for the content match .
136+ /// Verifies a Lab-materialized workspace against its declared provenance.
137+ /// Snapshot modes require byte-for-byte content parity; Git mode validates its
138+ /// checkout identity separately because checkout normalization can change bytes .
136139pub ( crate ) fn verify_lab_workspace_from_env (
137140 expected_remote_component_path : & str ,
138141 materialized_workspace_path : & Path ,
@@ -331,42 +334,44 @@ pub(crate) fn verify_lab_workspace(
331334 if workspace_identity != verification_identity {
332335 return Err ( "workspace identity does not match workspace verification" . to_string ( ) ) ;
333336 }
334- let actual_content_hash = match content_hash_algorithm. as_str ( ) {
335- "homeboy-workspace-content-v1" => {
336- workspace_content_hash_v1 ( materialized_workspace_path, & snapshot. sync_excludes )
337- }
338- algorithm if algorithm. starts_with ( "homeboy-workspace-content-v2+" ) => {
339- workspace_content_hash_for_policy (
340- materialized_workspace_path,
341- & snapshot. sync_excludes ,
342- permission_policy. expect ( "v2 policy validated above" ) ,
343- )
337+ if materialization_mode != "git" {
338+ let actual_content_hash = match content_hash_algorithm. as_str ( ) {
339+ "homeboy-workspace-content-v1" => {
340+ workspace_content_hash_v1 ( materialized_workspace_path, & snapshot. sync_excludes )
341+ }
342+ algorithm if algorithm. starts_with ( "homeboy-workspace-content-v2+" ) => {
343+ workspace_content_hash_for_policy (
344+ materialized_workspace_path,
345+ & snapshot. sync_excludes ,
346+ permission_policy. expect ( "v2 policy validated above" ) ,
347+ )
348+ }
349+ _ => unreachable ! ( "workspace verification algorithm was validated above" ) ,
344350 }
345- _ => unreachable ! ( "workspace verification algorithm was validated above" ) ,
346- }
347- . map_err ( |error| format ! ( "could not hash materialized workspace: {}" , error. message) ) ?;
348- if actual_content_hash != expected_content_hash {
349- let diagnostic = match materialization_mode {
350- "git" | "snapshot-git" => format ! (
351- "homeboy runner exec {} --cwd {} -- git status --short" ,
352- shell:: quote_arg( runner_id) ,
353- shell:: quote_arg( recorded_remote_path) ,
354- ) ,
355- "snapshot" => format ! (
356- "homeboy runner workspace sync --mode snapshot --path {} {}" ,
357- shell:: quote_arg(
358- snapshot
359- . local_path
360- . as_deref( )
361- . expect( "source path validated above" )
351+ . map_err ( |error| format ! ( "could not hash materialized workspace: {}" , error. message) ) ?;
352+ if actual_content_hash != expected_content_hash {
353+ let diagnostic = match materialization_mode {
354+ "snapshot-git" => format ! (
355+ "homeboy runner exec {} --cwd {} -- git status --short" ,
356+ shell:: quote_arg( runner_id) ,
357+ shell:: quote_arg( recorded_remote_path) ,
362358 ) ,
363- shell:: quote_arg( runner_id) ,
364- ) ,
365- _ => unreachable ! ( "materialization mode was validated above" ) ,
366- } ;
367- return Err ( format ! (
368- "workspace content hash does not match the controller materialization using {content_hash_algorithm} (expected {expected_content_hash}, got {actual_content_hash}); operator diagnostic: `{diagnostic}`"
369- ) ) ;
359+ "snapshot" => format ! (
360+ "homeboy runner workspace sync --mode snapshot --path {} {}" ,
361+ shell:: quote_arg(
362+ snapshot
363+ . local_path
364+ . as_deref( )
365+ . expect( "source path validated above" )
366+ ) ,
367+ shell:: quote_arg( runner_id) ,
368+ ) ,
369+ _ => unreachable ! ( "materialization mode was validated above" ) ,
370+ } ;
371+ return Err ( format ! (
372+ "workspace content hash does not match the controller materialization using {content_hash_algorithm} (expected {expected_content_hash}, got {actual_content_hash}); operator diagnostic: `{diagnostic}`"
373+ ) ) ;
374+ }
370375 }
371376
372377 Ok ( VerifiedLabWorkspaceProvenance {
@@ -490,6 +495,136 @@ mod tests {
490495 } )
491496 }
492497
498+ fn git_workspace ( ) -> tempfile:: TempDir {
499+ let workspace = tempfile:: tempdir ( ) . expect ( "workspace" ) ;
500+ std:: fs:: write ( workspace. path ( ) . join ( "file.txt" ) , "baseline\n " ) . expect ( "source file" ) ;
501+ git ( workspace. path ( ) , & [ "init" , "--quiet" ] ) . expect ( "initialize repository" ) ;
502+ git ( workspace. path ( ) , & [ "add" , "--all" ] ) . expect ( "stage source" ) ;
503+ git (
504+ workspace. path ( ) ,
505+ & [
506+ "-c" ,
507+ "user.name=Homeboy Test" ,
508+ "-c" ,
509+ "user.email=test@homeboy.invalid" ,
510+ "commit" ,
511+ "--quiet" ,
512+ "-m" ,
513+ "baseline" ,
514+ ] ,
515+ )
516+ . expect ( "commit source" ) ;
517+ workspace
518+ }
519+
520+ fn git_snapshot ( path : & Path ) -> SourceSnapshot {
521+ let mut snapshot = snapshot ( path) ;
522+ snapshot. git_sha = Some ( git ( path, & [ "rev-parse" , "HEAD" ] ) . expect ( "source revision" ) ) ;
523+ snapshot. sync_excludes = Vec :: new ( ) ;
524+ snapshot
525+ }
526+
527+ fn git_lab ( path : & Path , snapshot : & SourceSnapshot ) -> serde_json:: Value {
528+ let mut lab = lab ( path, snapshot) ;
529+ lab[ "sync_mode" ] = serde_json:: json!( "git" ) ;
530+ lab[ "workspace_verification" ] [ "content_hash" ] = serde_json:: json!( "controller-byte-hash" ) ;
531+ lab
532+ }
533+
534+ #[ test]
535+ fn git_materialization_accepts_checkout_normalization_hash_difference ( ) {
536+ let workspace = git_workspace ( ) ;
537+ let snapshot = git_snapshot ( workspace. path ( ) ) ;
538+ let provenance = verify_lab_workspace (
539+ & workspace. path ( ) . display ( ) . to_string ( ) ,
540+ workspace. path ( ) ,
541+ snapshot. clone ( ) ,
542+ git_lab ( workspace. path ( ) , & snapshot) ,
543+ )
544+ . expect ( "Git provenance accepts non-authoritative byte hash" ) ;
545+
546+ verify_lab_workspace_git_root ( workspace. path ( ) , & provenance)
547+ . expect ( "clean checkout at expected revision" ) ;
548+ }
549+
550+ #[ test]
551+ fn git_materialization_rejects_wrong_head_root_identity_and_dirty_workspace ( ) {
552+ let workspace = git_workspace ( ) ;
553+ let snapshot = git_snapshot ( workspace. path ( ) ) ;
554+ let lab = git_lab ( workspace. path ( ) , & snapshot) ;
555+ let provenance = verify_lab_workspace (
556+ & workspace. path ( ) . display ( ) . to_string ( ) ,
557+ workspace. path ( ) ,
558+ snapshot. clone ( ) ,
559+ lab. clone ( ) ,
560+ )
561+ . expect ( "initial Git provenance" ) ;
562+
563+ std:: fs:: write ( workspace. path ( ) . join ( "file.txt" ) , "changed\n " ) . expect ( "change source" ) ;
564+ assert ! ( verify_lab_workspace_git_root( workspace. path( ) , & provenance)
565+ . expect_err( "dirty Git workspace must fail closed" )
566+ . contains( "not clean" ) ) ;
567+ git ( workspace. path ( ) , & [ "checkout" , "--" , "file.txt" ] ) . expect ( "restore source" ) ;
568+ git (
569+ workspace. path ( ) ,
570+ & [
571+ "-c" ,
572+ "user.name=Homeboy Test" ,
573+ "-c" ,
574+ "user.email=test@homeboy.invalid" ,
575+ "commit" ,
576+ "--allow-empty" ,
577+ "--quiet" ,
578+ "-m" ,
579+ "wrong head" ,
580+ ] ,
581+ )
582+ . expect ( "advance head" ) ;
583+ assert ! ( verify_lab_workspace_git_root( workspace. path( ) , & provenance)
584+ . expect_err( "wrong Git HEAD must fail closed" )
585+ . contains( "HEAD does not match" ) ) ;
586+
587+ let nested_root = workspace. path ( ) . join ( "nested" ) ;
588+ std:: fs:: create_dir ( & nested_root) . expect ( "nested path" ) ;
589+ assert ! ( verify_lab_workspace_git_root( & nested_root, & provenance)
590+ . expect_err( "wrong managed root must fail closed" )
591+ . contains( "top-level does not exactly match" ) ) ;
592+
593+ let mut wrong_identity = lab;
594+ wrong_identity[ "workspace_verification" ] [ "identity" ] = serde_json:: json!( "other" ) ;
595+ wrong_identity[ "workspace_verification" ] [ "primary_workspace" ] [ "identity" ] =
596+ serde_json:: json!( "other" ) ;
597+ assert ! ( verify_lab_workspace(
598+ & workspace. path( ) . display( ) . to_string( ) ,
599+ workspace. path( ) ,
600+ snapshot,
601+ wrong_identity,
602+ )
603+ . expect_err( "wrong declared identity must fail closed" )
604+ . contains( "workspace identity" ) ) ;
605+ }
606+
607+ #[ test]
608+ fn snapshot_materializations_reject_content_hash_mismatch ( ) {
609+ for mode in [ "snapshot" , "snapshot-git" ] {
610+ let workspace = tempfile:: tempdir ( ) . expect ( "workspace" ) ;
611+ std:: fs:: write ( workspace. path ( ) . join ( "file.txt" ) , "baseline\n " ) . expect ( "source file" ) ;
612+ let snapshot = snapshot ( workspace. path ( ) ) ;
613+ let mut lab = lab ( workspace. path ( ) , & snapshot) ;
614+ lab[ "sync_mode" ] = serde_json:: json!( mode) ;
615+ lab[ "workspace_verification" ] [ "content_hash" ] = serde_json:: json!( "wrong-hash" ) ;
616+
617+ assert ! ( verify_lab_workspace(
618+ & workspace. path( ) . display( ) . to_string( ) ,
619+ workspace. path( ) ,
620+ snapshot,
621+ lab,
622+ )
623+ . expect_err( "snapshot hash mismatch must fail closed" )
624+ . contains( "content hash" ) ) ;
625+ }
626+ }
627+
493628 #[ test]
494629 fn verified_snapshot_baseline_supports_committed_and_uncommitted_candidate_harvesting ( ) {
495630 let workspace = tempfile:: tempdir ( ) . expect ( "workspace" ) ;
0 commit comments