-
Notifications
You must be signed in to change notification settings - Fork 270
flake: add narHash and lastModified attributes #2464
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,9 +2,11 @@ | |
package flake | ||
|
||
import ( | ||
"maps" | ||
"net/url" | ||
"path" | ||
"slices" | ||
"strconv" | ||
"strings" | ||
|
||
"go.jetpack.io/devbox/internal/redact" | ||
|
@@ -67,6 +69,14 @@ type Ref struct { | |
// or "git". Note that the URL is not the same as the raw unparsed | ||
// flake ref. | ||
URL string `json:"url,omitempty"` | ||
|
||
// NARHash is the SRI hash of the flake's source. Specify a NAR hash to | ||
// lock flakes that don't otherwise have a revision (such as "path" or | ||
// "tarball" flakes). | ||
NARHash string `json:"narHash,omitempty"` | ||
|
||
// LastModified is the last modification time of the flake. | ||
LastModified int64 `json:"lastModified,omitempty"` | ||
} | ||
|
||
// ParseRef parses a raw flake reference. Nix supports a variety of flake ref | ||
|
@@ -156,24 +166,64 @@ func parseURLRef(ref string) (parsed Ref, fragment string, err error) { | |
} else { | ||
parsed.Path = refURL.Path | ||
} | ||
|
||
parsed.NARHash = refURL.Query().Get("narHash") | ||
parsed.LastModified, err = atoiOmitZero(refURL.Query().Get("lastModified")) | ||
if err != nil { | ||
return Ref{}, "", redact.Errorf("parse flake reference URL query parameter: lastModified=%s: %v", redact.Safe(parsed.LastModified), redact.Safe(err)) | ||
} | ||
case "http", "https", "file": | ||
if isArchive(refURL.Path) { | ||
parsed.Type = TypeTarball | ||
} else { | ||
parsed.Type = TypeFile | ||
} | ||
parsed.Dir = refURL.Query().Get("dir") | ||
query := refURL.Query() | ||
parsed.Dir = query.Get("dir") | ||
parsed.NARHash = refURL.Query().Get("narHash") | ||
parsed.LastModified, err = atoiOmitZero(refURL.Query().Get("lastModified")) | ||
if err != nil { | ||
return Ref{}, "", redact.Errorf("parse flake reference URL query parameter: lastModified=%s: %v", redact.Safe(parsed.LastModified), redact.Safe(err)) | ||
} | ||
|
||
// lastModified and narHash get stripped from the query | ||
// parameters, but dir stays. | ||
query.Del("lastModified") | ||
query.Del("narHash") | ||
refURL.RawQuery = query.Encode() | ||
parsed.URL = refURL.String() | ||
case "tarball+http", "tarball+https", "tarball+file": | ||
parsed.Type = TypeTarball | ||
parsed.Dir = refURL.Query().Get("dir") | ||
query := refURL.Query() | ||
parsed.Dir = query.Get("dir") | ||
parsed.NARHash = refURL.Query().Get("narHash") | ||
parsed.LastModified, err = atoiOmitZero(refURL.Query().Get("lastModified")) | ||
if err != nil { | ||
return Ref{}, "", redact.Errorf("parse flake reference URL query parameter: lastModified=%s: %v", redact.Safe(parsed.LastModified), redact.Safe(err)) | ||
} | ||
|
||
// lastModified and narHash get stripped from the query | ||
// parameters, but dir stays. | ||
query.Del("lastModified") | ||
query.Del("narHash") | ||
refURL.RawQuery = query.Encode() | ||
refURL.Scheme = refURL.Scheme[8:] // remove tarball+ | ||
parsed.URL = refURL.String() | ||
case "file+http", "file+https", "file+file": | ||
parsed.Type = TypeFile | ||
parsed.Dir = refURL.Query().Get("dir") | ||
query := refURL.Query() | ||
parsed.Dir = query.Get("dir") | ||
parsed.NARHash = refURL.Query().Get("narHash") | ||
parsed.LastModified, err = atoiOmitZero(refURL.Query().Get("lastModified")) | ||
if err != nil { | ||
return Ref{}, "", redact.Errorf("parse flake reference URL query parameter: lastModified=%s: %v", redact.Safe(parsed.LastModified), redact.Safe(err)) | ||
} | ||
|
||
// lastModified and narHash get stripped from the query | ||
// parameters, but dir stays. | ||
query.Del("lastModified") | ||
query.Del("narHash") | ||
refURL.RawQuery = query.Encode() | ||
refURL.Scheme = refURL.Scheme[5:] // remove file+ | ||
parsed.URL = refURL.String() | ||
case "git", "git+http", "git+https", "git+ssh", "git+git", "git+file": | ||
|
@@ -245,9 +295,42 @@ func parseGitHubRef(refURL *url.URL, parsed *Ref) error { | |
parsed.Rev = qRev | ||
} | ||
parsed.Dir = refURL.Query().Get("dir") | ||
parsed.NARHash = refURL.Query().Get("narHash") | ||
return nil | ||
} | ||
|
||
// Locked reports whether r is locked. Locked flake references always resolve to | ||
// the same content. For some flake types, determining if a Ref is locked | ||
// depends on the local Nix configuration. In these cases, Locked conservatively | ||
// returns false. | ||
func (r Ref) Locked() bool { | ||
// Search for the implementations of InputScheme::isLocked in the nix | ||
// source. | ||
// | ||
// https://github.com/search?q=repo%3ANixOS%2Fnix+language%3AC%2B%2B+symbol%3AisLocked&type=code | ||
|
||
switch r.Type { | ||
case TypeFile, TypePath, TypeTarball: | ||
return r.NARHash != "" | ||
case TypeGit: | ||
return r.Rev != "" | ||
case TypeGitHub: | ||
// We technically can't determine if a github flake is locked | ||
// unless we know the trust-tarballs-from-git-forges Nix setting | ||
// (which defaults to true), so we have to be conservative and | ||
// check for rev and narHash. | ||
// | ||
// https://github.com/NixOS/nix/blob/3f3feae33e3381a2ea5928febe03329f0a578b20/src/libfetchers/github.cc#L304-L313 | ||
return r.Rev != "" && r.NARHash != "" | ||
case TypeIndirect: | ||
// Never locked because they must be resolved against a flake | ||
// registry. | ||
return false | ||
default: | ||
return false | ||
} | ||
} | ||
|
||
// String encodes the flake reference as a URL-like string. It normalizes the | ||
// result such that if two Ref values are equal, then their strings will also be | ||
// equal. | ||
|
@@ -262,15 +345,26 @@ func parseGitHubRef(refURL *url.URL, parsed *Ref) error { | |
// put in the path. | ||
// - query parameters are sorted by key. | ||
// | ||
// If f is missing a type or has any invalid fields, String returns an empty | ||
// If r is missing a type or has any invalid fields, String returns an empty | ||
// string. | ||
func (r Ref) String() string { | ||
switch r.Type { | ||
case TypeFile: | ||
if r.URL == "" { | ||
return "" | ||
} | ||
return "file+" + r.URL | ||
|
||
url, err := url.Parse("file+" + r.URL) | ||
if err != nil { | ||
// This should be rare and only happen if the caller | ||
// messed with the parsed URL. | ||
return "" | ||
} | ||
url.RawQuery = buildQueryString(url.Query(), | ||
"lastModified", itoaOmitZero(r.LastModified), | ||
"narHash", r.NARHash, | ||
Comment on lines
+365
to
+366
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. what happens if lastModified or NarHash are zero? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
) | ||
return url.String() | ||
case TypeGit: | ||
if r.URL == "" { | ||
return "" | ||
|
@@ -292,26 +386,35 @@ func (r Ref) String() string { | |
// messed with the parsed URL. | ||
return "" | ||
} | ||
url.RawQuery = buildQueryString("ref", r.Ref, "rev", r.Rev, "dir", r.Dir) | ||
url.RawQuery = buildQueryString(url.Query(), "ref", r.Ref, "rev", r.Rev, "dir", r.Dir) | ||
return url.String() | ||
case TypeGitHub: | ||
if r.Owner == "" || r.Repo == "" { | ||
return "" | ||
} | ||
url := &url.URL{ | ||
Scheme: "github", | ||
Opaque: buildEscapedPath(r.Owner, r.Repo, r.Rev, r.Ref), | ||
RawQuery: buildQueryString("host", r.Host, "dir", r.Dir), | ||
Scheme: "github", | ||
Opaque: buildEscapedPath(r.Owner, r.Repo, r.Rev, r.Ref), | ||
RawQuery: buildQueryString(nil, | ||
|
||
"host", r.Host, | ||
"dir", r.Dir, | ||
"lastModified", itoaOmitZero(r.LastModified), | ||
"narHash", r.NARHash, | ||
), | ||
} | ||
return url.String() | ||
case TypeIndirect: | ||
if r.ID == "" { | ||
return "" | ||
} | ||
url := &url.URL{ | ||
Scheme: "flake", | ||
Opaque: buildEscapedPath(r.ID, r.Ref, r.Rev), | ||
RawQuery: buildQueryString("dir", r.Dir), | ||
Scheme: "flake", | ||
Opaque: buildEscapedPath(r.ID, r.Ref, r.Rev), | ||
RawQuery: buildQueryString(nil, | ||
"dir", r.Dir, | ||
"lastModified", itoaOmitZero(r.LastModified), | ||
"narHash", r.NARHash, | ||
), | ||
} | ||
return url.String() | ||
case TypePath: | ||
|
@@ -330,6 +433,11 @@ func (r Ref) String() string { | |
} else if r.Path == "." { | ||
url.Opaque = "." | ||
} | ||
|
||
url.RawQuery = buildQueryString(nil, | ||
"lastModified", itoaOmitZero(r.LastModified), | ||
"narHash", r.NARHash, | ||
) | ||
return url.String() | ||
case TypeTarball: | ||
if r.URL == "" { | ||
|
@@ -338,17 +446,18 @@ func (r Ref) String() string { | |
if !strings.HasPrefix(r.URL, "tarball") { | ||
r.URL = "tarball+" + r.URL | ||
} | ||
if r.Dir == "" { | ||
return r.URL | ||
} | ||
|
||
url, err := url.Parse(r.URL) | ||
if err != nil { | ||
// This should be rare and only happen if the caller | ||
// messed with the parsed URL. | ||
return "" | ||
} | ||
url.RawQuery = buildQueryString("dir", r.Dir) | ||
url.RawQuery = buildQueryString(url.Query(), | ||
"dir", r.Dir, | ||
"lastModified", itoaOmitZero(r.LastModified), | ||
"narHash", r.NARHash, | ||
) | ||
return url.String() | ||
default: | ||
return "" | ||
|
@@ -425,19 +534,37 @@ func buildEscapedPath(elem ...string) string { | |
|
||
// buildQueryString builds a URL query string from a list of key-value string | ||
// pairs, omitting any keys with empty values. | ||
func buildQueryString(keyval ...string) string { | ||
func buildQueryString(initial url.Values, keyval ...string) string { | ||
if len(keyval)%2 != 0 { | ||
panic("buildQueryString: odd number of key-value pairs") | ||
} | ||
|
||
query := make(url.Values, len(keyval)/2) | ||
q := make(url.Values, len(initial)+len(keyval)/2) | ||
maps.Copy(q, initial) | ||
for i := 0; i < len(keyval); i += 2 { | ||
k, v := keyval[i], keyval[i+1] | ||
if v != "" { | ||
query.Set(k, v) | ||
q.Set(k, v) | ||
} | ||
} | ||
return query.Encode() | ||
return q.Encode() | ||
} | ||
|
||
// itoaOmitZero returns an empty string if i == 0, otherwise it formats i as a | ||
// string in base-10. | ||
func itoaOmitZero(i int64) string { | ||
if i == 0 { | ||
return "" | ||
} | ||
return strconv.FormatInt(i, 10) | ||
} | ||
|
||
// atoiOmitZero returns 0 if s == "", otherwised it parses s as a base-10 int64. | ||
func atoiOmitZero(s string) (int64, error) { | ||
if s == "" { | ||
return 0, nil | ||
} | ||
return strconv.ParseInt(s, 10, 64) | ||
} | ||
|
||
// Special values for [Installable].Outputs. | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -34,6 +34,7 @@ func TestParseFlakeRef(t *testing.T) { | |
"path:/": {Type: TypePath, Path: "/"}, | ||
"path:/flake": {Type: TypePath, Path: "/flake"}, | ||
"path:/absolute/flake": {Type: TypePath, Path: "/absolute/flake"}, | ||
"path:/absolute/flake?lastModified=1734435836&narHash=sha256-kMBQ5PRiFLagltK0sH%2B08aiNt3zGERC2297iB6vrvlU%3D": {Type: TypePath, Path: "/absolute/flake", LastModified: 1734435836, NARHash: "sha256-kMBQ5PRiFLagltK0sH+08aiNt3zGERC2297iB6vrvlU="}, | ||
|
||
// URL-like paths can omit the "./" prefix for relative | ||
// directories. | ||
|
@@ -65,6 +66,7 @@ func TestParseFlakeRef(t *testing.T) { | |
"github:NixOS/nix?rev=5233fd2ba76a3accb5aaa999c00509a11fd0793c": {Type: TypeGitHub, Owner: "NixOS", Repo: "nix", Rev: "5233fd2ba76a3accb5aaa999c00509a11fd0793c"}, | ||
"github:NixOS/nix?host=example.com": {Type: TypeGitHub, Owner: "NixOS", Repo: "nix", Host: "example.com"}, | ||
"github:NixOS/nix?host=example.com&dir=subdir": {Type: TypeGitHub, Owner: "NixOS", Repo: "nix", Host: "example.com", Dir: "subdir"}, | ||
"github:NixOS/nix?host=example.com&dir=subdir&lastModified=1734435836&narHash=sha256-kMBQ5PRiFLagltK0sH%2B08aiNt3zGERC2297iB6vrvlU%3D": {Type: TypeGitHub, Owner: "NixOS", Repo: "nix", Host: "example.com", Dir: "subdir", NARHash: "sha256-kMBQ5PRiFLagltK0sH+08aiNt3zGERC2297iB6vrvlU="}, | ||
|
||
// The github type allows clone-style URLs. The username and | ||
// host are ignored. | ||
|
@@ -81,7 +83,8 @@ func TestParseFlakeRef(t *testing.T) { | |
"git+ssh://[email protected]/repo/flake": {Type: TypeGit, URL: "ssh://[email protected]/repo/flake"}, | ||
"git:/repo/flake": {Type: TypeGit, URL: "git:/repo/flake"}, | ||
"git+file:///repo/flake": {Type: TypeGit, URL: "file:///repo/flake"}, | ||
"git://example.com/repo/flake?ref=unstable&rev=e486d8d40e626a20e06d792db8cc5ac5aba9a5b4&dir=subdir": {Type: TypeGit, URL: "git://example.com/repo/flake?dir=subdir", Ref: "unstable", Rev: "e486d8d40e626a20e06d792db8cc5ac5aba9a5b4", Dir: "subdir"}, | ||
"git://example.com/repo/flake?ref=unstable&rev=e486d8d40e626a20e06d792db8cc5ac5aba9a5b4&dir=subdir": {Type: TypeGit, URL: "git://example.com/repo/flake?dir=subdir", Ref: "unstable", Rev: "e486d8d40e626a20e06d792db8cc5ac5aba9a5b4", Dir: "subdir"}, | ||
"git://example.com/repo/flake?ref=unstable&rev=e486d8d40e626a20e06d792db8cc5ac5aba9a5b4&dir=subdir&lastModified=1734435836&narHash=sha256-kMBQ5PRiFLagltK0sH%2B08aiNt3zGERC2297iB6vrvlU%3D": {Type: TypeGit, URL: "git://example.com/repo/flake?dir=subdir&lastModified=1734435836&narHash=sha256-kMBQ5PRiFLagltK0sH%2B08aiNt3zGERC2297iB6vrvlU%3D", Ref: "unstable", Rev: "e486d8d40e626a20e06d792db8cc5ac5aba9a5b4", Dir: "subdir"}, | ||
|
||
// Tarball references. | ||
"tarball+http://example.com/flake": {Type: TypeTarball, URL: "http://example.com/flake"}, | ||
|
@@ -99,14 +102,15 @@ func TestParseFlakeRef(t *testing.T) { | |
"http://example.com/flake.tar.bz2": {Type: TypeTarball, URL: "http://example.com/flake.tar.bz2"}, | ||
"http://example.com/flake.tar.zst": {Type: TypeTarball, URL: "http://example.com/flake.tar.zst"}, | ||
"http://example.com/flake.tar?dir=subdir": {Type: TypeTarball, URL: "http://example.com/flake.tar?dir=subdir", Dir: "subdir"}, | ||
"file:///flake.zip": {Type: TypeTarball, URL: "file:///flake.zip"}, | ||
"file:///flake.tar": {Type: TypeTarball, URL: "file:///flake.tar"}, | ||
"file:///flake.tgz": {Type: TypeTarball, URL: "file:///flake.tgz"}, | ||
"file:///flake.tar.gz": {Type: TypeTarball, URL: "file:///flake.tar.gz"}, | ||
"file:///flake.tar.xz": {Type: TypeTarball, URL: "file:///flake.tar.xz"}, | ||
"file:///flake.tar.bz2": {Type: TypeTarball, URL: "file:///flake.tar.bz2"}, | ||
"file:///flake.tar.zst": {Type: TypeTarball, URL: "file:///flake.tar.zst"}, | ||
"file:///flake.tar?dir=subdir": {Type: TypeTarball, URL: "file:///flake.tar?dir=subdir", Dir: "subdir"}, | ||
"http://example.com/flake.tar?dir=subdir&lastModified=1734435836&narHash=sha256-kMBQ5PRiFLagltK0sH%2B08aiNt3zGERC2297iB6vrvlU%3D": {Type: TypeTarball, URL: "http://example.com/flake.tar?dir=subdir", Dir: "subdir", LastModified: 1734435836, NARHash: "sha256-kMBQ5PRiFLagltK0sH+08aiNt3zGERC2297iB6vrvlU="}, | ||
"file:///flake.zip": {Type: TypeTarball, URL: "file:///flake.zip"}, | ||
"file:///flake.tar": {Type: TypeTarball, URL: "file:///flake.tar"}, | ||
"file:///flake.tgz": {Type: TypeTarball, URL: "file:///flake.tgz"}, | ||
"file:///flake.tar.gz": {Type: TypeTarball, URL: "file:///flake.tar.gz"}, | ||
"file:///flake.tar.xz": {Type: TypeTarball, URL: "file:///flake.tar.xz"}, | ||
"file:///flake.tar.bz2": {Type: TypeTarball, URL: "file:///flake.tar.bz2"}, | ||
"file:///flake.tar.zst": {Type: TypeTarball, URL: "file:///flake.tar.zst"}, | ||
"file:///flake.tar?dir=subdir": {Type: TypeTarball, URL: "file:///flake.tar?dir=subdir", Dir: "subdir"}, | ||
|
||
// File URL references. | ||
"file+file:///flake": {Type: TypeFile, URL: "file:///flake"}, | ||
|
@@ -403,5 +407,5 @@ func TestBuildQueryString(t *testing.T) { | |
// directives). | ||
var elems []string | ||
elems = append(elems, "1") | ||
buildQueryString(elems...) | ||
buildQueryString(nil, elems...) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
seems like these lines are repeated in many case-bodies. Pull into a function and call that function instead?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are you referring to the
query.Del
calls? I dunno, it seems like overkill to make those two lines a separate function.