diff --git a/docs/reference/recipe_file.md b/docs/reference/recipe_file.md index f30f05417..0eda0ed72 100644 --- a/docs/reference/recipe_file.md +++ b/docs/reference/recipe_file.md @@ -246,6 +246,19 @@ source: lfs: true # note: defaults to false ``` +##### Verifying commit hash with `expected-commit` + +For security and reproducibility, you can specify an `expected-commit` field to verify that the checked out commit matches the expected SHA hash. This is useful to detect if a tag or branch has been moved to point to a different commit: + +```yaml +source: + git: https://github.com/ilanschnell/bsdiff4.git + tag: "1.1.4" + expected-commit: 50a1f7ed6c168eb0815d424cba2df62790f168f0 +``` + +If the actual commit does not match the expected commit, the build will fail with an error message indicating the mismatch. This feature is inspired by [Wolfi/Melange](https://github.com/wolfi-dev/wolfi-os) and provides an additional layer of security for your builds. + #### Source from a local path If the path is relative, it is taken relative to the recipe directory. The diff --git a/src/recipe/parser/snapshots/rattler_build__recipe__parser__source__tests__git_source_with_expected_commit.snap b/src/recipe/parser/snapshots/rattler_build__recipe__parser__source__tests__git_source_with_expected_commit.snap new file mode 100644 index 000000000..0aeb247b9 --- /dev/null +++ b/src/recipe/parser/snapshots/rattler_build__recipe__parser__source__tests__git_source_with_expected_commit.snap @@ -0,0 +1,8 @@ +--- +source: src/recipe/parser/source.rs +assertion_line: 735 +expression: yaml +--- +git: https://test.com/test.git +rev: refs/tags/v1.0.0 +expected_commit: abc123def456 diff --git a/src/recipe/parser/source.rs b/src/recipe/parser/source.rs index 254fc78d2..f6f8231ff 100644 --- a/src/recipe/parser/source.rs +++ b/src/recipe/parser/source.rs @@ -192,6 +192,9 @@ pub struct GitSource { /// Optionally request the lfs pull in git source #[serde(default, skip_serializing_if = "should_not_serialize_lfs")] pub lfs: bool, + /// Optionally an expected commit hash to verify after checkout + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expected_commit: Option, } /// A helper method to skip serializing the lfs flag if it is false. @@ -217,6 +220,7 @@ impl GitSource { patches, target_directory, lfs, + expected_commit: None, } } @@ -249,6 +253,11 @@ impl GitSource { pub const fn lfs(&self) -> bool { self.lfs } + + /// Get the expected commit hash. + pub fn expected_commit(&self) -> Option<&str> { + self.expected_commit.as_deref() + } } impl TryConvertNode for RenderedMappingNode { @@ -259,6 +268,7 @@ impl TryConvertNode for RenderedMappingNode { let mut patches = Vec::new(); let mut target_directory = None; let mut lfs = false; + let mut expected_commit = None; self.iter().map(|(k, v)| { match k.as_str() { @@ -317,11 +327,14 @@ impl TryConvertNode for RenderedMappingNode { "lfs" => { lfs = v.try_convert("lfs")?; } + "expected-commit" => { + expected_commit = Some(v.try_convert("expected-commit")?); + } _ => { return Err(vec![_partialerror!( *k.span(), ErrorKind::InvalidField(k.as_str().to_owned().into()), - help = "valid fields for git `source` are `git`, `rev`, `tag`, `branch`, `depth`, `patches`, `lfs` and `target_directory`" + help = "valid fields for git `source` are `git`, `rev`, `tag`, `branch`, `depth`, `patches`, `lfs`, `expected-commit` and `target_directory`" )]) } } @@ -354,6 +367,7 @@ impl TryConvertNode for RenderedMappingNode { patches, target_directory, lfs, + expected_commit, }) } } @@ -652,6 +666,7 @@ mod tests { patches: Vec::new(), target_directory: None, lfs: false, + expected_commit: None, }; let yaml = serde_yaml::to_string(&git).unwrap(); @@ -672,6 +687,7 @@ mod tests { patches: Vec::new(), target_directory: None, lfs: false, + expected_commit: None, }; let yaml = serde_yaml::to_string(&git).unwrap(); @@ -701,4 +717,49 @@ mod tests { let json = serde_json::to_string(&path_source).unwrap(); serde_json::from_str::(&json).unwrap(); } + + #[test] + fn test_git_source_with_expected_commit() { + let git = GitSource { + url: GitUrl::Url(Url::parse("https://test.com/test.git").unwrap()), + rev: GitRev::Tag("v1.0.0".into()), + depth: None, + patches: Vec::new(), + target_directory: None, + lfs: false, + expected_commit: Some("abc123def456".to_string()), + }; + + let yaml = serde_yaml::to_string(&git).unwrap(); + + insta::assert_snapshot!(yaml); + + let parsed_git: GitSource = serde_yaml::from_str(&yaml).unwrap(); + + assert_eq!(parsed_git.expected_commit, git.expected_commit); + assert_eq!(parsed_git.expected_commit(), Some("abc123def456")); + } + + #[test] + fn test_git_source_without_expected_commit() { + let git = GitSource { + url: GitUrl::Url(Url::parse("https://test.com/test.git").unwrap()), + rev: GitRev::Tag("v1.0.0".into()), + depth: None, + patches: Vec::new(), + target_directory: None, + lfs: false, + expected_commit: None, + }; + + let yaml = serde_yaml::to_string(&git).unwrap(); + + // expected_commit should not appear in serialized yaml when None + assert!(!yaml.contains("expected")); + + let parsed_git: GitSource = serde_yaml::from_str(&yaml).unwrap(); + + assert_eq!(parsed_git.expected_commit, None); + assert_eq!(parsed_git.expected_commit(), None); + } } diff --git a/src/source/git_source.rs b/src/source/git_source.rs index f61d799fd..9f9a5c6e3 100644 --- a/src/source/git_source.rs +++ b/src/source/git_source.rs @@ -280,6 +280,18 @@ pub fn git_src( .trim() .to_owned(); + // Verify expected commit if specified + if let Some(expected) = source.expected_commit() { + if ref_git != expected { + return Err(SourceError::GitCommitMismatch { + expected: expected.to_string(), + actual: ref_git, + rev: rev.to_string(), + }); + } + tracing::info!("Verified expected commit: {}", expected); + } + // only do lfs pull if a requirement! if source.lfs() { git_lfs_pull(&ref_git)?; @@ -392,4 +404,114 @@ mod tests { ); } } + + #[tracing_test::traced_test] + #[test] + fn test_expected_commit_success() { + let temp_dir = tempfile::tempdir().unwrap(); + let cache_dir = temp_dir.path().join("rattler-build-test-expected-commit"); + let recipe_dir = temp_dir.path().join("recipe"); + fs_err::create_dir_all(&recipe_dir).unwrap(); + + let system_tools = crate::system_tools::SystemTools::new(); + + // First, fetch without expected commit to find out what the actual commit is + let source_without_expected = GitSource { + url: GitUrl::Url( + "https://github.com/prefix-dev/rattler-build" + .parse() + .unwrap(), + ), + rev: GitRev::Tag("v0.1.0".to_owned()), + depth: None, + patches: vec![], + target_directory: None, + lfs: false, + expected_commit: None, + }; + + let (_, actual_commit) = git_src( + &system_tools, + &source_without_expected, + &cache_dir, + &recipe_dir, + ) + .unwrap(); + + // Now test with the correct expected commit + let source_with_expected = GitSource { + url: GitUrl::Url( + "https://github.com/prefix-dev/rattler-build" + .parse() + .unwrap(), + ), + rev: GitRev::Tag("v0.1.0".to_owned()), + depth: None, + patches: vec![], + target_directory: None, + lfs: false, + expected_commit: Some(actual_commit.clone()), + }; + + let result = git_src( + &system_tools, + &source_with_expected, + &cache_dir, + &recipe_dir, + ); + assert!(result.is_ok()); + let (_, commit) = result.unwrap(); + assert_eq!(commit, actual_commit); + } + + #[tracing_test::traced_test] + #[test] + fn test_expected_commit_mismatch() { + let temp_dir = tempfile::tempdir().unwrap(); + let cache_dir = temp_dir + .path() + .join("rattler-build-test-expected-commit-mismatch"); + let recipe_dir = temp_dir.path().join("recipe"); + fs_err::create_dir_all(&recipe_dir).unwrap(); + + let system_tools = crate::system_tools::SystemTools::new(); + + // Test with an incorrect expected commit + let source_with_wrong_expected = GitSource { + url: GitUrl::Url( + "https://github.com/prefix-dev/rattler-build" + .parse() + .unwrap(), + ), + rev: GitRev::Tag("v0.1.0".to_owned()), + depth: None, + patches: vec![], + target_directory: None, + lfs: false, + expected_commit: Some("0000000000000000000000000000000000000000".to_string()), + }; + + let result = git_src( + &system_tools, + &source_with_wrong_expected, + &cache_dir, + &recipe_dir, + ); + assert!(result.is_err()); + + // Verify the error is specifically a GitCommitMismatch + let err = result.unwrap_err(); + match err { + crate::source::SourceError::GitCommitMismatch { + expected, + actual, + rev, + } => { + assert_eq!(expected, "0000000000000000000000000000000000000000"); + assert!(!actual.is_empty()); + assert_eq!(rev, "refs/tags/v0.1.0"); + } + _ => panic!("Expected GitCommitMismatch error, got: {:?}", err), + } + } } diff --git a/src/source/mod.rs b/src/source/mod.rs index 21b09bdb0..d4b015907 100644 --- a/src/source/mod.rs +++ b/src/source/mod.rs @@ -91,6 +91,15 @@ pub enum SourceError { #[error("Failed to run git command: {0}")] GitErrorStr(&'static str), + #[error( + "Git commit mismatch: expected commit '{expected}' but got '{actual}' for revision '{rev}'" + )] + GitCommitMismatch { + expected: String, + actual: String, + rev: String, + }, + #[error("{0}")] UnknownError(String), diff --git a/test-data/recipes/git_source_expected_commit/recipe.yaml b/test-data/recipes/git_source_expected_commit/recipe.yaml new file mode 100644 index 000000000..ff94ba978 --- /dev/null +++ b/test-data/recipes/git_source_expected_commit/recipe.yaml @@ -0,0 +1,12 @@ +package: + name: "git_source_expected_commit" + version: "1" + +source: + - git: https://github.com/prefix-dev/rattler-build + tag: v0.1.0 + expected-commit: df83c1edf287a756b8fc995e03e9632af0344777 + +build: + script: + - test -f README.md