Skip to content

Commit 8eab246

Browse files
autarchclaude
andcommitted
Add a new --min-age-days flag
When set, `ubi` will only download a release that is at least X days old. This is useful for mitigating supply chain attacks, especially for projects that use GitHub's immutable releases feature. This also includes some refactoring to better use serde. This eliminates the need for per-forge `Release` types. This was mostly: 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 590e608 commit 8eab246

11 files changed

Lines changed: 618 additions & 50 deletions

File tree

Cargo.lock

Lines changed: 119 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ anyhow = { version = "1.0.100", default-features = false }
1515
async-trait = { version = "0.1.89", default-features = false }
1616
binstall-tar = { version = "0.4.42", default-features = false }
1717
bzip2 = { version = "0.6.1" }
18+
chrono = { version = "0.4", default-features = false, features = ["serde", "clock"] }
1819
document-features = { version = "0.2" }
1920
# Used in some test code which can't use test_log.
2021
env_logger = { version = "0.11.8", default-features = false }

Changes.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
## 0.9.0
2+
3+
- Added a new `--min-age-days` flag that tells `ubi` to only consider releases at least that old.
4+
This is useful for mitigating supply chain attacks, especially for projects that use GitHub's
5+
immutable releases feature. GH #145.
6+
17
## 0.8.4 2025-11-01
28

39
- This is the same as 0.8.3, but I had to publish this with a new tag because of a failed experiment

ubi-cli/src/main.rs

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,8 @@ fn cmd() -> Command {
7272
.requires("project")
7373
.help(concat!(
7474
"The tag to download. Defaults to the latest release.",
75-
" This can only be passed with `--project`."
75+
" This can only be passed with `--project`. You cannot pass this when",
76+
" `--url` or `--min-age-days` are passed.",
7677
)),
7778
)
7879
.arg(
@@ -84,7 +85,8 @@ fn cmd() -> Command {
8485
"The url of the file to download. This can be provided instead of a project or",
8586
" tag. This will not use the forge site's API, so you will never hit its API",
8687
" limits. With this parameter, you do not need to set a token env var except for",
87-
" private repos. You cannot pass this when `--project` or `--tag` are passed."
88+
" private repos. You cannot pass this when `--project`, `--tag`, or `--min-age-days`",
89+
" are passed."
8890
)),
8991
)
9092
.arg(
@@ -132,6 +134,19 @@ fn cmd() -> Command {
132134
" `--rename-exe-to` are passed.",
133135
)),
134136
)
137+
.arg(
138+
Arg::new("min-age-days")
139+
.long("min-age-days")
140+
.value_parser(clap::value_parser!(u32))
141+
.requires("project")
142+
.conflicts_with_all(["tag", "url"])
143+
.help(concat!(
144+
"Minimum age in days for releases. Only releases at least this many days old",
145+
" will be installed. This is useful for mitigating supply chain attacks. It's",
146+
" especially useful for projects that use GitHub's immutable releases",
147+
" feature. You cannot pass this with --tag or --url.",
148+
)),
149+
)
135150
.arg(
136151
Arg::new("matching")
137152
.long("matching")
@@ -146,13 +161,13 @@ fn cmd() -> Command {
146161
)
147162
.arg(
148163
Arg::new("matching-regex")
149-
.long("matching-regex")
150-
.short('r')
151-
.help(concat!(
152-
"A regular expression string that will be matched against release filenames before",
153-
" matching against your OS/arch. If the pattern yields a single match, that release",
154-
" will be selected. If no matches are found, this will result in an error.",
155-
)),
164+
.long("matching-regex")
165+
.short('r')
166+
.help(concat!(
167+
"A regular expression string that will be matched against release filenames before",
168+
" matching against your OS/arch. If the pattern yields a single match, that release",
169+
" will be selected. If no matches are found, this will result in an error.",
170+
)),
156171
)
157172
.arg(
158173
Arg::new("forge")
@@ -273,6 +288,9 @@ fn make_ubi<'a>(
273288
if let Some(url) = matches.get_one::<String>("api-base-url") {
274289
builder = builder.api_base_url(url);
275290
}
291+
if let Some(days) = matches.get_one::<u32>("min-age-days") {
292+
builder = builder.min_age_days(*days);
293+
}
276294

277295
Ok((builder.build()?, None))
278296
}

ubi/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ anyhow.workspace = true
1414
async-trait.workspace = true
1515
binstall-tar.workspace = true
1616
bzip2.workspace = true
17+
chrono.workspace = true
1718
document-features.workspace = true
1819
fern = { workspace = true, optional = true }
1920
flate2.workspace = true

ubi/src/builder.rs

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ pub struct UbiBuilder<'a> {
3838
is_musl: Option<bool>,
3939
api_base_url: Option<&'a str>,
4040
forge: Option<ForgeType>,
41+
min_age_days: Option<u32>,
4142
}
4243

4344
impl<'a> UbiBuilder<'a> {
@@ -60,8 +61,9 @@ impl<'a> UbiBuilder<'a> {
6061
self
6162
}
6263

63-
/// Set the tag to download. By default the most recent release is downloaded. You cannot set
64-
/// this with the `url` option.
64+
/// Set the tag to download. By default the most recent release is downloaded.
65+
///
66+
/// You cannot set this with the `url` or `min_age_days` options.
6567
#[must_use]
6668
pub fn tag(mut self, tag: &'a str) -> Self {
6769
self.tag = Some(tag);
@@ -73,7 +75,8 @@ impl<'a> UbiBuilder<'a> {
7375
/// to set a token env var except when downloading a release from a private repo when the URL is
7476
/// set.
7577
///
76-
/// You must set this or set `project`, but not both.
78+
/// You must set this or set `project`, but not both. You cannot set this with the `tag` or
79+
/// `min_age_days` options.
7780
#[must_use]
7881
pub fn url(mut self, url: &'a str) -> Self {
7982
self.url = Some(url);
@@ -142,6 +145,17 @@ impl<'a> UbiBuilder<'a> {
142145
self
143146
}
144147

148+
/// Set the minimum age in days for releases. Only releases at least this many days old will be
149+
/// installed. This is useful for mitigating supply chain attacks. It's especially useful for
150+
/// projects that use GitHub's immutable releases feature.
151+
///
152+
/// You cannot set this with the `tag` or `url` options.
153+
#[must_use]
154+
pub fn min_age_days(mut self, days: u32) -> Self {
155+
self.min_age_days = Some(days);
156+
self
157+
}
158+
145159
/// Set a token to use for API requests. If this is not set, then `ubi` will look for a token in
146160
/// the appropriate env var:
147161
///
@@ -233,6 +247,19 @@ impl<'a> UbiBuilder<'a> {
233247
"You cannot set rename_exe_to and enable extract_all"
234248
));
235249
}
250+
if let Some(days) = self.min_age_days {
251+
if self.url.is_some() {
252+
return Err(anyhow!("You cannot set min_age_days with url"));
253+
}
254+
if self.tag.is_some() {
255+
return Err(anyhow!("You cannot set min_age_days with tag"));
256+
}
257+
if days == 0 {
258+
return Err(anyhow!(
259+
"min_age_days must be a positive number (greater than 0)"
260+
));
261+
}
262+
}
236263

237264
let platform = self.determine_platform()?;
238265

@@ -267,6 +294,7 @@ impl<'a> UbiBuilder<'a> {
267294
),
268295
installer,
269296
reqwest_client()?,
297+
self.min_age_days,
270298
))
271299
}
272300

@@ -492,4 +520,18 @@ mod test {
492520
) {
493521
assert_eq!(super::expect_exe_stem_name(exe, project_name), expect);
494522
}
523+
524+
#[test]
525+
fn min_age_days_zero_validation() {
526+
let result = UbiBuilder::new()
527+
.project("houseabsolute/ubi")
528+
.min_age_days(0)
529+
.build();
530+
531+
assert!(result.is_err());
532+
assert!(result
533+
.unwrap_err()
534+
.to_string()
535+
.contains("min_age_days must be a positive number"));
536+
}
495537
}

0 commit comments

Comments
 (0)