Skip to content

Commit 8234256

Browse files
authored
Fix Git Lab harvest provenance validation (#8185)
* Fix Git Lab harvest provenance validation * Fix Git Lab harvest provenance validation * test: update snapshot-git fixture origin
1 parent 100d659 commit 8234256

2 files changed

Lines changed: 173 additions & 38 deletions

File tree

src/core/runner/workspace/provenance.rs

Lines changed: 172 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
136139
pub(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");

src/core/runner/workspace/tests/snapshot.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -510,7 +510,7 @@ fn snapshot_git_sync_materializes_dirty_source_as_synthetic_git_checkout() {
510510
source.path(),
511511
&[
512512
"remote",
513-
"add",
513+
"set-url",
514514
"origin",
515515
"https://github.com/example/app.git",
516516
],

0 commit comments

Comments
 (0)