Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/reference/recipe_file.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
63 changes: 62 additions & 1 deletion src/recipe/parser/source.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
}

/// A helper method to skip serializing the lfs flag if it is false.
Expand All @@ -217,6 +220,7 @@ impl GitSource {
patches,
target_directory,
lfs,
expected_commit: None,
}
}

Expand Down Expand Up @@ -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<GitSource> for RenderedMappingNode {
Expand All @@ -259,6 +268,7 @@ impl TryConvertNode<GitSource> 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() {
Expand Down Expand Up @@ -317,11 +327,14 @@ impl TryConvertNode<GitSource> 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`"
)])
}
}
Expand Down Expand Up @@ -354,6 +367,7 @@ impl TryConvertNode<GitSource> for RenderedMappingNode {
patches,
target_directory,
lfs,
expected_commit,
})
}
}
Expand Down Expand Up @@ -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();
Expand All @@ -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();
Expand Down Expand Up @@ -701,4 +717,49 @@ mod tests {
let json = serde_json::to_string(&path_source).unwrap();
serde_json::from_str::<PathSource>(&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);
}
}
122 changes: 122 additions & 0 deletions src/source/git_source.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?;
Expand Down Expand Up @@ -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),
}
}
}
9 changes: 9 additions & 0 deletions src/source/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),

Expand Down
12 changes: 12 additions & 0 deletions test-data/recipes/git_source_expected_commit/recipe.yaml
Original file line number Diff line number Diff line change
@@ -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