Skip to content

Commit a2cef20

Browse files
ref(build): Add client-side validation for SHA fields (#2945)
### Description Add client-side validation for SHA fields. Also, don't send these fields' values if the user overrides them with an empty string like `--head-sha ""`. ### Issues - Ref #2941 - Ref [CLI-224](https://linear.app/getsentry/issue/CLI-224/skip-serializing-certain-fields-for-build-upload)
1 parent c550aa7 commit a2cef20

File tree

12 files changed

+157
-52
lines changed

12 files changed

+157
-52
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
### Improvements
6+
7+
- Added validation for the `sentry-cli build upload` command's `--head-sha` and `--base-sha` arguments ([#2945](https://github.com/getsentry/sentry-cli/pull/2945)). The CLI now validates that these are valid SHA1 sums. Passing an empty string is also allowed; this prevents the default values from being used, causing the values to instead be unset.
8+
39
## 2.58.1
410

511
### Deprecations

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ sentry = { version = "0.34.0", default-features = false, features = [
6262
] }
6363
serde = { version = "1.0.152", features = ["derive"] }
6464
serde_json = "1.0.93"
65-
sha1_smol = { version = "1.0.0", features = ["serde"] }
65+
sha1_smol = { version = "1.0.0", features = ["serde", "std"] }
6666
sourcemap = { version = "9.2.0", features = ["ram_bundle"] }
6767
symbolic = { version = "12.13.3", features = ["debuginfo-serde", "il2cpp"] }
6868
thiserror = "1.0.38"

src/api/data_types/chunking/build.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,9 @@ pub struct AssembleBuildResponse {
2828
#[derive(Debug, Serialize)]
2929
pub struct VcsInfo<'a> {
3030
#[serde(skip_serializing_if = "Option::is_none")]
31-
pub head_sha: Option<&'a str>,
31+
pub head_sha: Option<Digest>,
3232
#[serde(skip_serializing_if = "Option::is_none")]
33-
pub base_sha: Option<&'a str>,
33+
pub base_sha: Option<Digest>,
3434
#[serde(skip_serializing_if = "Option::is_none", rename = "provider")]
3535
pub vcs_provider: Option<&'a str>,
3636
#[serde(skip_serializing_if = "Option::is_none")]

src/commands/build/upload.rs

Lines changed: 38 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use anyhow::{anyhow, bail, Context as _, Result};
66
use clap::{Arg, ArgAction, ArgMatches, Command};
77
use indicatif::ProgressStyle;
88
use log::{debug, info, warn};
9+
use sha1_smol::Digest;
910
use symbolic::common::ByteView;
1011
use zip::write::SimpleFileOptions;
1112
use zip::{DateTime, ZipWriter};
@@ -53,11 +54,13 @@ pub fn make_command(command: Command) -> Command {
5354
.arg(
5455
Arg::new("head_sha")
5556
.long("head-sha")
57+
.value_parser(parse_sha_allow_empty)
5658
.help("The VCS commit sha to use for the upload. If not provided, the current commit sha will be used.")
5759
)
5860
.arg(
5961
Arg::new("base_sha")
6062
.long("base-sha")
63+
.value_parser(parse_sha_allow_empty)
6164
.help("The VCS commit's base sha to use for the upload. If not provided, the merge-base of the current and remote branch will be used.")
6265
)
6366
.arg(
@@ -112,10 +115,10 @@ pub fn execute(matches: &ArgMatches) -> Result<()> {
112115
.expect("paths argument is required");
113116

114117
let head_sha = matches
115-
.get_one("head_sha")
116-
.map(String::as_str)
117-
.map(Cow::Borrowed)
118-
.or_else(|| vcs::find_head_sha().ok().map(Cow::Owned));
118+
.get_one::<Option<Digest>>("head_sha")
119+
.map(|d| d.as_ref().cloned())
120+
.or_else(|| Some(vcs::find_head_sha().ok()))
121+
.flatten();
119122

120123
let cached_remote = config.get_cached_vcs_remote();
121124
// Try to open the git repository and find the remote, but handle errors gracefully.
@@ -232,20 +235,21 @@ pub fn execute(matches: &ArgMatches) -> Result<()> {
232235
};
233236

234237
// Track whether base_sha and base_ref were explicitly provided by the user
235-
let base_sha_from_user = matches.get_one::<String>("base_sha").is_some();
238+
let base_sha_from_user = matches.get_one::<Option<Digest>>("base_sha").is_some();
236239
let base_ref_from_user = matches.get_one::<String>("base_ref").is_some();
237240

238241
let mut base_sha = matches
239-
.get_one("base_sha")
240-
.map(String::as_str)
241-
.map(Cow::Borrowed)
242+
.get_one::<Option<Digest>>("base_sha")
243+
.map(|d| d.as_ref().cloned())
242244
.or_else(|| {
243-
vcs::find_base_sha(&cached_remote)
244-
.inspect_err(|e| debug!("Error finding base SHA: {e}"))
245-
.ok()
246-
.flatten()
247-
.map(Cow::Owned)
248-
});
245+
Some(
246+
vcs::find_base_sha(&cached_remote)
247+
.inspect_err(|e| debug!("Error finding base SHA: {e}"))
248+
.ok()
249+
.flatten(),
250+
)
251+
})
252+
.flatten();
249253

250254
let mut base_ref = base_ref;
251255

@@ -255,11 +259,11 @@ pub fn execute(matches: &ArgMatches) -> Result<()> {
255259
&& !base_ref_from_user
256260
&& base_sha.is_some()
257261
&& head_sha.is_some()
258-
&& base_sha.as_deref() == head_sha.as_deref()
262+
&& base_sha == head_sha
259263
{
260264
debug!(
261265
"Base SHA equals head SHA ({}), and both were auto-inferred. Skipping base_sha and base_ref, but keeping head_sha.",
262-
base_sha.as_deref().expect("base_sha is Some at this point")
266+
base_sha.expect("base_sha is Some at this point")
263267
);
264268
base_sha = None;
265269
base_ref = None;
@@ -325,8 +329,8 @@ pub fn execute(matches: &ArgMatches) -> Result<()> {
325329
info!("Uploading file: {}", path.display());
326330
let bytes = ByteView::open(zip.path())?;
327331
let vcs_info = VcsInfo {
328-
head_sha: head_sha.as_deref(),
329-
base_sha: base_sha.as_deref(),
332+
head_sha,
333+
base_sha,
330334
vcs_provider: vcs_provider.as_deref(),
331335
head_repo_name: head_repo_name.as_deref(),
332336
base_repo_name: base_repo_name.as_deref(),
@@ -598,6 +602,22 @@ fn upload_file(
598602
result
599603
}
600604

605+
/// Utility function to parse a SHA1 digest, allowing empty strings.
606+
///
607+
/// Empty strings result in Ok(None), otherwise we return the parsed digest
608+
/// or an error if the SHA is invalid.
609+
fn parse_sha_allow_empty(sha: &str) -> Result<Option<Digest>> {
610+
if sha.is_empty() {
611+
return Ok(None);
612+
}
613+
614+
let digest = sha
615+
.parse()
616+
.with_context(|| format!("{sha} is not a valid SHA1 digest"))?;
617+
618+
Ok(Some(digest))
619+
}
620+
601621
#[cfg(not(windows))]
602622
#[cfg(test)]
603623
mod tests {

src/utils/releases.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ pub fn detect_release_name() -> Result<String> {
160160

161161
// finally try the git sha
162162
match vcs::find_head_sha() {
163-
Ok(head) => Ok(head),
163+
Ok(head) => Ok(head.to_string()),
164164
Err(e) => Err(anyhow!(
165165
"Could not automatically determine release name:\n\t {e} \n\n\
166166
Please ensure your version control system is configured correctly, \

src/utils/vcs.rs

Lines changed: 40 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ use lazy_static::lazy_static;
1111
use log::{debug, info, warn};
1212
use regex::Regex;
1313
use serde_json::Value;
14+
use sha1_smol::Digest;
1415

1516
use crate::api::{GitCommit, PatchSet, Ref, Repo};
1617

@@ -550,7 +551,7 @@ fn find_matching_revs(
550551
Ok((prev_rev, rev))
551552
}
552553

553-
pub fn find_head_sha() -> Result<String> {
554+
pub fn find_head_sha() -> Result<Digest> {
554555
if let Some(pr_head_sha) = std::env::var("GITHUB_EVENT_PATH")
555556
.ok()
556557
.and_then(|event_path| std::fs::read_to_string(event_path).ok())
@@ -562,10 +563,14 @@ pub fn find_head_sha() -> Result<String> {
562563

563564
let repo = git2::Repository::open_from_env()?;
564565
let head = repo.revparse_single("HEAD")?;
565-
Ok(head.id().to_string())
566+
Ok(head
567+
.id()
568+
.to_string()
569+
.parse()
570+
.expect("Repo SHA should be a valid SHA1 digest"))
566571
}
567572

568-
pub fn find_base_sha(remote_name: &str) -> Result<Option<String>> {
573+
pub fn find_base_sha(remote_name: &str) -> Result<Option<Digest>> {
569574
if let Some(pr_base_sha) = std::env::var("GITHUB_EVENT_PATH")
570575
.ok()
571576
.and_then(|event_path| std::fs::read_to_string(event_path).ok())
@@ -587,15 +592,19 @@ pub fn find_base_sha(remote_name: &str) -> Result<Option<String>> {
587592
Ok(remote_ref
588593
.peel_to_commit()
589594
.and_then(|remote_commit| repo.merge_base(head_commit.id(), remote_commit.id()))
590-
.map(|oid| oid.to_string())
595+
.map(|oid| {
596+
oid.to_string()
597+
.parse()
598+
.expect("Repo SHA should be a valid SHA1 digest")
599+
})
591600
.ok()
592601
.inspect(|sha| debug!("Found merge-base commit as base reference: {sha}")))
593602
}
594603

595604
/// Extracts the PR head SHA from GitHub Actions event payload JSON.
596605
/// Returns None if not a PR event or if SHA cannot be extracted.
597606
/// Panics if json is malformed.
598-
fn extract_pr_head_sha_from_event(json_content: &str) -> Option<String> {
607+
fn extract_pr_head_sha_from_event(json_content: &str) -> Option<Digest> {
599608
let v: Value = match serde_json::from_str(json_content) {
600609
Ok(v) => v,
601610
Err(_) => {
@@ -605,13 +614,13 @@ fn extract_pr_head_sha_from_event(json_content: &str) -> Option<String> {
605614

606615
v.pointer("/pull_request/head/sha")
607616
.and_then(|s| s.as_str())
608-
.map(|s| s.to_owned())
617+
.map(|s| s.parse().expect("GitHub Actions provided an invalid SHA"))
609618
}
610619

611620
/// Extracts the PR base SHA from GitHub Actions event payload JSON.
612621
/// Returns None if not a PR event or if SHA cannot be extracted.
613622
/// Panics if json is malformed.
614-
fn extract_pr_base_sha_from_event(json_content: &str) -> Option<String> {
623+
fn extract_pr_base_sha_from_event(json_content: &str) -> Option<Digest> {
615624
let v: Value = match serde_json::from_str(json_content) {
616625
Ok(v) => v,
617626
Err(_) => {
@@ -621,7 +630,7 @@ fn extract_pr_base_sha_from_event(json_content: &str) -> Option<String> {
621630

622631
v.pointer("/pull_request/base/sha")
623632
.and_then(|s| s.as_str())
624-
.map(|s| s.to_owned())
633+
.map(|s| s.parse().expect("GitHub Actions provided an invalid SHA"))
625634
}
626635

627636
/// Given commit specs, repos and remote_name this returns a list of head
@@ -810,10 +819,7 @@ mod tests {
810819
crate::api::RepoProvider,
811820
insta::{assert_debug_snapshot, assert_yaml_snapshot},
812821
serial_test::serial,
813-
std::fs::File,
814-
std::io::Write as _,
815-
std::path::Path,
816-
std::process::Command,
822+
std::{fs::File, io::Write as _, path::Path, process::Command},
817823
tempfile::{tempdir, TempDir},
818824
};
819825

@@ -1600,7 +1606,7 @@ mod tests {
16001606

16011607
assert_eq!(
16021608
extract_pr_head_sha_from_event(&pr_json),
1603-
Some("19ef6adc4dbddf733db6e833e1f96fb056b6dba5".to_owned())
1609+
Some("19ef6adc4dbddf733db6e833e1f96fb056b6dba5".parse().unwrap())
16041610
);
16051611

16061612
let push_json = r#"{
@@ -1642,7 +1648,7 @@ mod tests {
16421648

16431649
assert_eq!(
16441650
extract_pr_head_sha_from_event(real_gh_json),
1645-
Some("19ef6adc4dbddf733db6e833e1f96fb056b6dba4".to_owned())
1651+
Some("19ef6adc4dbddf733db6e833e1f96fb056b6dba4".parse().unwrap())
16461652
);
16471653
let malicious_json = r#"{
16481654
"action": "opened",
@@ -1658,20 +1664,23 @@ mod tests {
16581664

16591665
assert_eq!(
16601666
extract_pr_head_sha_from_event(malicious_json),
1661-
Some("19ef6adc4dbddf733db6e833e1f96fb056b6dba5".to_owned())
1667+
Some("19ef6adc4dbddf733db6e833e1f96fb056b6dba5".parse().unwrap())
16621668
);
1663-
let any_sha_json = r#"{
1664-
"pull_request": {
1665-
"head": {
1666-
"sha": "invalid-sha-123"
16671669
}
1668-
}
1669-
}"#;
16701670

1671-
assert_eq!(
1672-
extract_pr_head_sha_from_event(any_sha_json),
1673-
Some("invalid-sha-123".to_owned())
1674-
);
1671+
#[test]
1672+
#[should_panic]
1673+
fn test_extract_pr_head_sha_from_event_invalid_sha() {
1674+
let any_sha_json = serde_json::json!({
1675+
"pull_request": {
1676+
"head": {
1677+
"sha": "invalid-sha-123"
1678+
}
1679+
}
1680+
})
1681+
.to_string();
1682+
1683+
extract_pr_head_sha_from_event(&any_sha_json);
16751684
}
16761685

16771686
#[test]
@@ -1710,7 +1719,10 @@ mod tests {
17101719
std::env::remove_var("GITHUB_EVENT_PATH");
17111720

17121721
assert!(result.is_ok());
1713-
assert_eq!(result.unwrap(), "19ef6adc4dbddf733db6e833e1f96fb056b6dba5");
1722+
assert_eq!(
1723+
result.unwrap(),
1724+
"19ef6adc4dbddf733db6e833e1f96fb056b6dba5".parse().unwrap()
1725+
);
17141726
}
17151727

17161728
#[test]
@@ -1734,7 +1746,7 @@ mod tests {
17341746

17351747
assert_eq!(
17361748
extract_pr_base_sha_from_event(&pr_json),
1737-
Some("55e6bc8c264ce95164314275d805f477650c440d".to_owned())
1749+
Some("55e6bc8c264ce95164314275d805f477650c440d".parse().unwrap())
17381750
);
17391751

17401752
// Test with push event (should return None)
@@ -1790,7 +1802,7 @@ mod tests {
17901802
let result = find_base_sha("origin");
17911803
assert_eq!(
17921804
result.unwrap().unwrap(),
1793-
"55e6bc8c264ce95164314275d805f477650c440d"
1805+
"55e6bc8c264ce95164314275d805f477650c440d".parse().unwrap()
17941806
);
17951807

17961808
// Test without GITHUB_EVENT_PATH
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
```
2+
$ sentry-cli build upload tests/integration/_fixtures/build/apk.apk --base-sha test_base_sha
3+
? failed
4+
error: invalid value 'test_base_sha' for '--base-sha <base_sha>': test_base_sha is not a valid SHA1 digest
5+
6+
For more information, try '--help'.
7+
8+
```
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
```
2+
$ sentry-cli build upload tests/integration/_fixtures/build/apk.apk --head-sha test_head_sha
3+
? failed
4+
error: invalid value 'test_head_sha' for '--head-sha <head_sha>': test_head_sha is not a valid SHA1 digest
5+
6+
For more information, try '--help'.
7+
8+
```

tests/integration/_cases/build/build-upload-apk.trycmd

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
```
2-
$ sentry-cli build upload tests/integration/_fixtures/build/apk.apk --head-sha test_head_sha
2+
$ sentry-cli build upload tests/integration/_fixtures/build/apk.apk --head-sha 12345678deadbeef78900987feebdaed87654321
33
? success
44
> Preparing for upload completed in [..]
55
> Uploading completed in [..]
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
```
2+
$ sentry-cli build upload tests/integration/_fixtures/build/apk.apk --head-sha "" --base-sha ""
3+
? success
4+
> Preparing for upload completed in [..]
5+
Successfully uploaded 1 file to Sentry
6+
- tests/integration/_fixtures/build/apk.apk (http://sentry.io/wat-org/preprod/wat-project/42)
7+
8+
```

0 commit comments

Comments
 (0)