Skip to content

Commit d0bdb07

Browse files
committed
add Tarball Pins with optional support for flakes "Lockabel HTTP Tarball Protocol"
1 parent 37a4254 commit d0bdb07

File tree

8 files changed

+296
-33
lines changed

8 files changed

+296
-33
lines changed

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ Commands:
207207
gitlab Track a GitLab repository
208208
git Track a git repository
209209
pypi Track a package on PyPi
210+
tarball Track a tarball
210211
help Print this message or the help of the given subcommand(s)
211212

212213
Options:

src/cli.rs

+48-29
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,9 @@ pub struct ChannelAddOpts {
6262
}
6363

6464
impl ChannelAddOpts {
65-
pub fn add(&self) -> Result<(String, Pin)> {
65+
pub fn add(&self) -> Result<(Option<String>, Pin)> {
6666
Ok((
67-
self.name.clone(),
67+
Some(self.name.clone()),
6868
channel::Pin {
6969
name: self.name.clone(),
7070
}
@@ -119,9 +119,9 @@ pub struct GitHubAddOpts {
119119
}
120120

121121
impl GitHubAddOpts {
122-
pub fn add(&self) -> Result<(String, Pin)> {
122+
pub fn add(&self) -> Result<(Option<String>, Pin)> {
123123
Ok((
124-
self.repository.clone(),
124+
Some(self.repository.clone()),
125125
match &self.more.branch {
126126
Some(branch) => {
127127
let pin = git::GitPin::github(
@@ -164,7 +164,7 @@ pub struct ForgejoAddOpts {
164164
pub more: GenericGitAddOpts,
165165
}
166166
impl ForgejoAddOpts {
167-
pub fn add(&self) -> Result<(String, Pin)> {
167+
pub fn add(&self) -> Result<(Option<String>, Pin)> {
168168
let server_url = Url::parse(&self.server).or_else(|err| match err {
169169
ParseError::RelativeUrlWithoutBase => {
170170
Url::parse(&("https://".to_string() + self.server.as_str()))
@@ -173,7 +173,7 @@ impl ForgejoAddOpts {
173173
})?;
174174

175175
Ok((
176-
self.repository.clone(),
176+
Some(self.repository.clone()),
177177
match &self.more.branch {
178178
Some(branch) => {
179179
let pin = git::GitPin::forgejo(
@@ -234,26 +234,26 @@ pub struct GitLabAddOpts {
234234
}
235235

236236
impl GitLabAddOpts {
237-
pub fn add(&self) -> Result<(String, Pin)> {
237+
pub fn add(&self) -> Result<(Option<String>, Pin)> {
238238
Ok((
239-
self.repo_path
239+
Some(self.repo_path
240240
.last()
241241
.ok_or_else(|| anyhow::format_err!("GitLab repository path must at least have one element (usually two: owner, repo)"))?
242-
.clone(),
242+
.clone()),
243243
match &self.more.branch {
244-
Some(branch) =>{
244+
Some(branch) => {
245245
let pin = git::GitPin::gitlab(
246246
self.repo_path.join("/"),
247247
branch.clone(),
248248
Some(self.server.clone()),
249249
self.private_token.clone(),
250250
self.more.submodules,
251251
);
252-
let version = self.more.at.as_ref()
253-
.map(|at| git::GitRevision {
252+
let version = self.more.at.as_ref().map(|at| git::GitRevision {
254253
revision: at.clone(),
255254
});
256-
(pin, version).into()},
255+
(pin, version).into()
256+
},
257257
None => {
258258
let pin = git::GitReleasePin::gitlab(
259259
self.repo_path.join("/"),
@@ -264,10 +264,9 @@ impl GitLabAddOpts {
264264
self.more.release_prefix.clone(),
265265
self.more.submodules,
266266
);
267-
let version = self.more.at.as_ref()
268-
.map(|at| GenericVersion {
269-
version: at.clone(),
270-
});
267+
let version = self.more.at.as_ref().map(|at| GenericVersion {
268+
version: at.clone(),
269+
});
271270
(pin, version).into()
272271
},
273272
},
@@ -285,7 +284,7 @@ pub struct GitAddOpts {
285284
}
286285

287286
impl GitAddOpts {
288-
pub fn add(&self) -> Result<(String, Pin)> {
287+
pub fn add(&self) -> Result<(Option<String>, Pin)> {
289288
let url = Url::parse(&self.url)
290289
.map_err(|e| {
291290
match e {
@@ -311,7 +310,7 @@ impl GitAddOpts {
311310
let name = name.strip_suffix(".git").unwrap_or(&name);
312311

313312
Ok((
314-
name.to_owned(),
313+
Some(name.to_owned()),
315314
match &self.more.branch {
316315
Some(branch) => {
317316
let pin = git::GitPin::git(url, branch.clone(), self.more.submodules);
@@ -354,8 +353,8 @@ pub struct PyPiAddOpts {
354353
}
355354

356355
impl PyPiAddOpts {
357-
pub fn add(&self) -> Result<(String, Pin)> {
358-
Ok((self.name.clone(), {
356+
pub fn add(&self) -> Result<(Option<String>, Pin)> {
357+
Ok((Some(self.name.clone()), {
359358
let pin = pypi::Pin {
360359
name: self.name.clone(),
361360
version_upper_bound: self.version_upper_bound.clone(),
@@ -368,6 +367,19 @@ impl PyPiAddOpts {
368367
}
369368
}
370369

370+
#[derive(Debug, Parser)]
371+
pub struct TarballAddOpts {
372+
/// Tarball URL
373+
pub url: Url,
374+
}
375+
376+
impl TarballAddOpts {
377+
pub fn add(&self) -> Result<(Option<String>, Pin)> {
378+
let url = self.url.clone();
379+
Ok((None, tarball::TarballPin { url }.into()))
380+
}
381+
}
382+
371383
#[derive(Debug, Subcommand)]
372384
pub enum AddCommands {
373385
/// Track a Nix channel
@@ -388,6 +400,12 @@ pub enum AddCommands {
388400
/// Track a package on PyPi
389401
#[command(name = "pypi")]
390402
PyPi(PyPiAddOpts),
403+
/// Track a tarball
404+
///
405+
/// This can be either a static URL that never changes its contents or a
406+
/// URL which supports flakes "Lockable HTTP Tarball" API.
407+
#[command(name = "tarball")]
408+
Tarball(TarballAddOpts),
391409
}
392410

393411
#[derive(Debug, Parser)]
@@ -415,20 +433,21 @@ impl AddOpts {
415433
AddCommands::Forgejo(fg) => fg.add()?,
416434
AddCommands::GitLab(gl) => gl.add()?,
417435
AddCommands::PyPi(p) => p.add()?,
436+
AddCommands::Tarball(p) => p.add()?,
418437
};
419438

420-
let name = if let Some(ref n) = self.name {
421-
n.clone()
422-
} else {
423-
name
439+
let name = match (&self.name, name) {
440+
(Some(user_specified), _) => user_specified.clone(),
441+
(None, Some(guess_from_pin)) => guess_from_pin,
442+
(None, None) => {
443+
anyhow::bail!(
444+
"Couldn't pick a Pin name automatically. Use --name to specify one manually"
445+
)
446+
},
424447
};
425448
if self.frozen {
426449
pin.freeze();
427450
}
428-
anyhow::ensure!(
429-
!name.is_empty(),
430-
"Pin name cannot be empty. Use --name to specify one manually",
431-
);
432451

433452
Ok((name, pin))
434453
}

src/default.nix

+15-1
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ let
5959
mkPyPiSource spec
6060
else if spec.type == "Channel" then
6161
mkChannelSource spec
62+
else if spec.type == "Tarball" then
63+
mkTarballSource spec
6264
else
6365
builtins.throw "Unknown source type ${spec.type}";
6466
in
@@ -125,8 +127,20 @@ let
125127
inherit url;
126128
sha256 = hash;
127129
};
130+
131+
mkTarballSource =
132+
{
133+
url,
134+
locked_url ? url,
135+
hash,
136+
...
137+
}:
138+
builtins.fetchTarball {
139+
url = locked_url;
140+
sha256 = hash;
141+
};
128142
in
129-
if version == 4 then
143+
if version == 5 then
130144
builtins.mapAttrs mkSource data.pins
131145
else
132146
throw "Unsupported format version ${toString version} in sources.json. Try running `npins upgrade`"

src/flake.rs

+10
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ enum FlakeType {
2424
Github,
2525
Git,
2626
Path,
27+
Tarball,
2728
}
2829

2930
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -52,6 +53,8 @@ pub struct FlakeOriginal {
5253
ref_: Option<String>,
5354
#[serde(rename = "type")]
5455
type_: String,
56+
/// the url of a lockable tarball
57+
url: Option<Url>,
5558
}
5659

5760
impl FlakePin {
@@ -116,6 +119,13 @@ impl FlakePin {
116119
)
117120
.into()
118121
},
122+
Tarball => {
123+
let url = self
124+
.original
125+
.url
126+
.context("missing url on a tarball flake input")?;
127+
tarball::TarballPin { url }.into()
128+
},
119129
Path => anyhow::bail!("Path inputs are currently not supported by npins."),
120130
})
121131
}

src/main.rs

+13
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ pub mod git;
1515
pub mod niv;
1616
pub mod nix;
1717
pub mod pypi;
18+
pub mod tarball;
1819
pub mod versions;
1920

2021
/// Helper method to build you a client.
@@ -250,6 +251,7 @@ mkPin! {
250251
(GitRelease, git_release, "git release tag", git::GitReleasePin),
251252
(PyPi, pypi, "pypi package", pypi::Pin),
252253
(Channel, channel, "Nix channel", channel::Pin),
254+
(Tarball, tarball, "tarball", tarball::TarballPin),
253255
}
254256

255257
/// The main struct the CLI operates on
@@ -285,6 +287,17 @@ impl diff::Diff for GenericVersion {
285287
}
286288
}
287289

290+
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)]
291+
pub struct GenericHash {
292+
pub hash: String,
293+
}
294+
295+
impl diff::Diff for GenericHash {
296+
fn properties(&self) -> Vec<(String, String)> {
297+
vec![("hash".into(), self.hash.clone())]
298+
}
299+
}
300+
288301
/// The Frozen field in a Pin
289302
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
290303
pub struct Frozen(pub bool);

src/tarball.rs

+97
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
//! Pin a tarball URL source
2+
//!
3+
//! Optionally (if the host supports it) can use the "Lockable HTTP Tarball Protocol" from flakes.
4+
//! Reference: <https://github.com/nixos/nix/blob/56763ff918eb308db23080e560ed2ea3e00c80a7/doc/manual/src/protocols/tarball-fetcher.md>
5+
6+
use anyhow::{Context, Result};
7+
use reqwest::header::HeaderName;
8+
use serde::{Deserialize, Serialize};
9+
use url::Url;
10+
11+
use crate::*;
12+
13+
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)]
14+
pub struct TarballPin {
15+
/// URL provided as user input
16+
pub url: Url,
17+
}
18+
19+
impl diff::Diff for TarballPin {
20+
fn properties(&self) -> Vec<(String, String)> {
21+
vec![("url".into(), self.url.to_string())]
22+
}
23+
}
24+
25+
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)]
26+
pub struct LockedTarball {
27+
/// If the given URL supports the Lockable Tarball Protocol we store the
28+
/// flakeref here
29+
#[serde(skip_serializing_if = "Option::is_none")]
30+
pub locked_url: Option<Url>,
31+
}
32+
33+
impl diff::Diff for LockedTarball {
34+
fn properties(&self) -> Vec<(String, String)> {
35+
self.locked_url
36+
.iter()
37+
.map(|locked_url| ("locked_url".into(), locked_url.to_string()))
38+
.collect()
39+
}
40+
}
41+
42+
#[async_trait::async_trait]
43+
impl Updatable for TarballPin {
44+
type Version = LockedTarball;
45+
type Hashes = GenericHash;
46+
47+
async fn update(&self, old: Option<&LockedTarball>) -> Result<LockedTarball> {
48+
const LINK: HeaderName = HeaderName::from_static("link");
49+
50+
// Attempt to use the Lockable HTTP Tarball Protocol, if that fails (the
51+
// expected Link header is missing) we fail back to using whatever was
52+
// the input.
53+
let headers = build_client()?
54+
.head(self.url.clone())
55+
.send()
56+
.await?
57+
.headers()
58+
.clone();
59+
let flakerefs = headers
60+
.get_all(LINK)
61+
.into_iter()
62+
.filter_map(|header| header.to_str().ok())
63+
.filter_map(|link| {
64+
// Naive parsing of the `Link: <flakeref>; rel="immutable"` header
65+
link.strip_suffix(r#">; rel="immutable""#)?
66+
.strip_prefix("<")
67+
})
68+
.collect::<Vec<_>>();
69+
let locked_url = if let [flakeref] = flakerefs[..] {
70+
Some(
71+
flakeref
72+
.parse::<Url>()
73+
.context("immutable link contained an invalid URL")?,
74+
)
75+
} else {
76+
if matches!(old, Some(old) if old.locked_url.is_some()) {
77+
log::warn!(
78+
"url `{url}` of a locked tarball pin did not respond with the expected `Link` header. \
79+
if you changed the `url` manually to one that doesn't support this protocol make sure to also remove the `locked_url` field. \
80+
https://docs.lix.systems/manual/lix/nightly/protocols/tarball-fetcher.html",
81+
url = &self.url,
82+
);
83+
return Ok(old.unwrap().clone());
84+
} else {
85+
// This is a no-op since we started with `old.locked_url.is_none()`
86+
None
87+
}
88+
};
89+
Ok(LockedTarball { locked_url })
90+
}
91+
92+
async fn fetch(&self, version: &LockedTarball) -> Result<GenericHash> {
93+
let url = version.locked_url.as_ref().unwrap_or(&self.url);
94+
let hash = nix::nix_prefetch_tarball(&url).await?;
95+
Ok(GenericHash { hash })
96+
}
97+
}

0 commit comments

Comments
 (0)