Skip to content

Commit b02f1c6

Browse files
authored
flake: add narHash and lastModified attributes (#2464)
Add support for the `narHash` and `lastModified` attributes of flakerefs. The new `Ref.Locked` method uses these attributes to report whether a flake is locked.
1 parent 9f43428 commit b02f1c6

File tree

2 files changed

+176
-39
lines changed

2 files changed

+176
-39
lines changed

nix/flake/flakeref.go

Lines changed: 162 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22
package flake
33

44
import (
5+
"cmp"
56
"net/url"
67
"path"
78
"slices"
9+
"strconv"
810
"strings"
911

1012
"go.jetpack.io/devbox/internal/redact"
@@ -67,6 +69,14 @@ type Ref struct {
6769
// or "git". Note that the URL is not the same as the raw unparsed
6870
// flake ref.
6971
URL string `json:"url,omitempty"`
72+
73+
// NARHash is the SRI hash of the flake's source. Specify a NAR hash to
74+
// lock flakes that don't otherwise have a revision (such as "path" or
75+
// "tarball" flakes).
76+
NARHash string `json:"narHash,omitempty"`
77+
78+
// LastModified is the last modification time of the flake.
79+
LastModified int64 `json:"lastModified,omitempty"`
7080
}
7181

7282
// ParseRef parses a raw flake reference. Nix supports a variety of flake ref
@@ -156,38 +166,79 @@ func parseURLRef(ref string) (parsed Ref, fragment string, err error) {
156166
} else {
157167
parsed.Path = refURL.Path
158168
}
169+
170+
query := refURL.Query()
171+
parsed.NARHash = query.Get("narHash")
172+
parsed.LastModified, err = atoiOmitZero(query.Get("lastModified"))
173+
if err != nil {
174+
return Ref{}, "", redact.Errorf("parse flake reference URL query parameter: lastModified=%s: %v", redact.Safe(parsed.LastModified), redact.Safe(err))
175+
}
159176
case "http", "https", "file":
160177
if isArchive(refURL.Path) {
161178
parsed.Type = TypeTarball
162179
} else {
163180
parsed.Type = TypeFile
164181
}
165-
parsed.Dir = refURL.Query().Get("dir")
182+
query := refURL.Query()
183+
parsed.Dir = query.Get("dir")
184+
parsed.NARHash = query.Get("narHash")
185+
parsed.LastModified, err = atoiOmitZero(query.Get("lastModified"))
186+
if err != nil {
187+
return Ref{}, "", redact.Errorf("parse flake reference URL query parameter: lastModified=%s: %v", redact.Safe(parsed.LastModified), redact.Safe(err))
188+
}
189+
190+
// lastModified and narHash get stripped from the query
191+
// parameters, but dir stays.
192+
query.Del("lastModified")
193+
query.Del("narHash")
194+
refURL.RawQuery = query.Encode()
166195
parsed.URL = refURL.String()
167196
case "tarball+http", "tarball+https", "tarball+file":
168197
parsed.Type = TypeTarball
169-
parsed.Dir = refURL.Query().Get("dir")
198+
query := refURL.Query()
199+
parsed.Dir = query.Get("dir")
200+
parsed.NARHash = query.Get("narHash")
201+
parsed.LastModified, err = atoiOmitZero(query.Get("lastModified"))
202+
if err != nil {
203+
return Ref{}, "", redact.Errorf("parse flake reference URL query parameter: lastModified=%s: %v", redact.Safe(parsed.LastModified), redact.Safe(err))
204+
}
170205

206+
// lastModified and narHash get stripped from the query
207+
// parameters, but dir stays.
208+
query.Del("lastModified")
209+
query.Del("narHash")
210+
refURL.RawQuery = query.Encode()
171211
refURL.Scheme = refURL.Scheme[8:] // remove tarball+
172212
parsed.URL = refURL.String()
173213
case "file+http", "file+https", "file+file":
174214
parsed.Type = TypeFile
175-
parsed.Dir = refURL.Query().Get("dir")
215+
query := refURL.Query()
216+
parsed.Dir = query.Get("dir")
217+
parsed.NARHash = query.Get("narHash")
218+
parsed.LastModified, err = atoiOmitZero(query.Get("lastModified"))
219+
if err != nil {
220+
return Ref{}, "", redact.Errorf("parse flake reference URL query parameter: lastModified=%s: %v", redact.Safe(parsed.LastModified), redact.Safe(err))
221+
}
176222

223+
// lastModified and narHash get stripped from the query
224+
// parameters, but dir stays.
225+
query.Del("lastModified")
226+
query.Del("narHash")
227+
refURL.RawQuery = query.Encode()
177228
refURL.Scheme = refURL.Scheme[5:] // remove file+
178229
parsed.URL = refURL.String()
179230
case "git", "git+http", "git+https", "git+ssh", "git+git", "git+file":
180231
parsed.Type = TypeGit
181-
q := refURL.Query()
182-
parsed.Dir = q.Get("dir")
183-
parsed.Ref = q.Get("ref")
184-
parsed.Rev = q.Get("rev")
232+
query := refURL.Query()
233+
parsed.Dir = query.Get("dir")
234+
parsed.Ref = query.Get("ref")
235+
parsed.Rev = query.Get("rev")
185236

186237
// ref and rev get stripped from the query parameters, but dir
187238
// stays.
188-
q.Del("ref")
189-
q.Del("rev")
190-
refURL.RawQuery = q.Encode()
239+
query.Del("ref")
240+
query.Del("rev")
241+
refURL.RawQuery = query.Encode()
191242
if len(refURL.Scheme) > 3 {
192243
refURL.Scheme = refURL.Scheme[4:] // remove git+
193244
}
@@ -245,9 +296,42 @@ func parseGitHubRef(refURL *url.URL, parsed *Ref) error {
245296
parsed.Rev = qRev
246297
}
247298
parsed.Dir = refURL.Query().Get("dir")
299+
parsed.NARHash = refURL.Query().Get("narHash")
248300
return nil
249301
}
250302

303+
// Locked reports whether r is locked. Locked flake references always resolve to
304+
// the same content. For some flake types, determining if a Ref is locked
305+
// depends on the local Nix configuration. In these cases, Locked conservatively
306+
// returns false.
307+
func (r Ref) Locked() bool {
308+
// Search for the implementations of InputScheme::isLocked in the nix
309+
// source.
310+
//
311+
// https://github.com/search?q=repo%3ANixOS%2Fnix+language%3AC%2B%2B+symbol%3AisLocked&type=code
312+
313+
switch r.Type {
314+
case TypeFile, TypePath, TypeTarball:
315+
return r.NARHash != ""
316+
case TypeGit:
317+
return r.Rev != ""
318+
case TypeGitHub:
319+
// We technically can't determine if a github flake is locked
320+
// unless we know the trust-tarballs-from-git-forges Nix setting
321+
// (which defaults to true), so we have to be conservative and
322+
// check for rev and narHash.
323+
//
324+
// https://github.com/NixOS/nix/blob/3f3feae33e3381a2ea5928febe03329f0a578b20/src/libfetchers/github.cc#L304-L313
325+
return r.Rev != "" && r.NARHash != ""
326+
case TypeIndirect:
327+
// Never locked because they must be resolved against a flake
328+
// registry.
329+
return false
330+
default:
331+
return false
332+
}
333+
}
334+
251335
// String encodes the flake reference as a URL-like string. It normalizes the
252336
// result such that if two Ref values are equal, then their strings will also be
253337
// equal.
@@ -262,15 +346,26 @@ func parseGitHubRef(refURL *url.URL, parsed *Ref) error {
262346
// put in the path.
263347
// - query parameters are sorted by key.
264348
//
265-
// If f is missing a type or has any invalid fields, String returns an empty
349+
// If r is missing a type or has any invalid fields, String returns an empty
266350
// string.
267351
func (r Ref) String() string {
268352
switch r.Type {
269353
case TypeFile:
270354
if r.URL == "" {
271355
return ""
272356
}
273-
return "file+" + r.URL
357+
358+
url, err := url.Parse("file+" + r.URL)
359+
if err != nil {
360+
// This should be rare and only happen if the caller
361+
// messed with the parsed URL.
362+
return ""
363+
}
364+
url.RawQuery = appendQueryString(url.Query(),
365+
"lastModified", itoaOmitZero(r.LastModified),
366+
"narHash", r.NARHash,
367+
)
368+
return url.String()
274369
case TypeGit:
275370
if r.URL == "" {
276371
return ""
@@ -292,26 +387,35 @@ func (r Ref) String() string {
292387
// messed with the parsed URL.
293388
return ""
294389
}
295-
url.RawQuery = buildQueryString("ref", r.Ref, "rev", r.Rev, "dir", r.Dir)
390+
url.RawQuery = appendQueryString(url.Query(), "ref", r.Ref, "rev", r.Rev, "dir", r.Dir)
296391
return url.String()
297392
case TypeGitHub:
298393
if r.Owner == "" || r.Repo == "" {
299394
return ""
300395
}
301396
url := &url.URL{
302-
Scheme: "github",
303-
Opaque: buildEscapedPath(r.Owner, r.Repo, r.Rev, r.Ref),
304-
RawQuery: buildQueryString("host", r.Host, "dir", r.Dir),
397+
Scheme: "github",
398+
Opaque: buildEscapedPath(r.Owner, r.Repo, r.Rev, r.Ref),
399+
RawQuery: appendQueryString(nil,
400+
"host", r.Host,
401+
"dir", r.Dir,
402+
"lastModified", itoaOmitZero(r.LastModified),
403+
"narHash", r.NARHash,
404+
),
305405
}
306406
return url.String()
307407
case TypeIndirect:
308408
if r.ID == "" {
309409
return ""
310410
}
311411
url := &url.URL{
312-
Scheme: "flake",
313-
Opaque: buildEscapedPath(r.ID, r.Ref, r.Rev),
314-
RawQuery: buildQueryString("dir", r.Dir),
412+
Scheme: "flake",
413+
Opaque: buildEscapedPath(r.ID, r.Ref, r.Rev),
414+
RawQuery: appendQueryString(nil,
415+
"dir", r.Dir,
416+
"lastModified", itoaOmitZero(r.LastModified),
417+
"narHash", r.NARHash,
418+
),
315419
}
316420
return url.String()
317421
case TypePath:
@@ -330,6 +434,11 @@ func (r Ref) String() string {
330434
} else if r.Path == "." {
331435
url.Opaque = "."
332436
}
437+
438+
url.RawQuery = appendQueryString(nil,
439+
"lastModified", itoaOmitZero(r.LastModified),
440+
"narHash", r.NARHash,
441+
)
333442
return url.String()
334443
case TypeTarball:
335444
if r.URL == "" {
@@ -338,17 +447,18 @@ func (r Ref) String() string {
338447
if !strings.HasPrefix(r.URL, "tarball") {
339448
r.URL = "tarball+" + r.URL
340449
}
341-
if r.Dir == "" {
342-
return r.URL
343-
}
344450

345451
url, err := url.Parse(r.URL)
346452
if err != nil {
347453
// This should be rare and only happen if the caller
348454
// messed with the parsed URL.
349455
return ""
350456
}
351-
url.RawQuery = buildQueryString("dir", r.Dir)
457+
url.RawQuery = appendQueryString(url.Query(),
458+
"dir", r.Dir,
459+
"lastModified", itoaOmitZero(r.LastModified),
460+
"narHash", r.NARHash,
461+
)
352462
return url.String()
353463
default:
354464
return ""
@@ -423,21 +533,44 @@ func buildEscapedPath(elem ...string) string {
423533
return u.JoinPath(elem...).String()
424534
}
425535

426-
// buildQueryString builds a URL query string from a list of key-value string
536+
// appendQueryString builds a URL query string from a list of key-value string
427537
// pairs, omitting any keys with empty values.
428-
func buildQueryString(keyval ...string) string {
538+
func appendQueryString(query url.Values, keyval ...string) string {
429539
if len(keyval)%2 != 0 {
430-
panic("buildQueryString: odd number of key-value pairs")
540+
panic("appendQueryString: odd number of key-value pairs")
431541
}
432542

433-
query := make(url.Values, len(keyval)/2)
543+
appended := make(url.Values, len(query)+len(keyval)/2)
544+
for k, vals := range query {
545+
v := cmp.Or(vals...)
546+
if v != "" {
547+
appended.Set(k, v)
548+
}
549+
}
434550
for i := 0; i < len(keyval); i += 2 {
435551
k, v := keyval[i], keyval[i+1]
436552
if v != "" {
437-
query.Set(k, v)
553+
appended.Set(k, v)
438554
}
439555
}
440-
return query.Encode()
556+
return appended.Encode()
557+
}
558+
559+
// itoaOmitZero returns an empty string if i == 0, otherwise it formats i as a
560+
// string in base-10.
561+
func itoaOmitZero(i int64) string {
562+
if i == 0 {
563+
return ""
564+
}
565+
return strconv.FormatInt(i, 10)
566+
}
567+
568+
// atoiOmitZero returns 0 if s == "", otherwised it parses s as a base-10 int64.
569+
func atoiOmitZero(s string) (int64, error) {
570+
if s == "" {
571+
return 0, nil
572+
}
573+
return strconv.ParseInt(s, 10, 64)
441574
}
442575

443576
// Special values for [Installable].Outputs.

nix/flake/flakeref_test.go

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ func TestParseFlakeRef(t *testing.T) {
3434
"path:/": {Type: TypePath, Path: "/"},
3535
"path:/flake": {Type: TypePath, Path: "/flake"},
3636
"path:/absolute/flake": {Type: TypePath, Path: "/absolute/flake"},
37+
"path:/absolute/flake?lastModified=1734435836&narHash=sha256-kMBQ5PRiFLagltK0sH%2B08aiNt3zGERC2297iB6vrvlU%3D": {Type: TypePath, Path: "/absolute/flake", LastModified: 1734435836, NARHash: "sha256-kMBQ5PRiFLagltK0sH+08aiNt3zGERC2297iB6vrvlU="},
3738

3839
// URL-like paths can omit the "./" prefix for relative
3940
// directories.
@@ -65,6 +66,7 @@ func TestParseFlakeRef(t *testing.T) {
6566
"github:NixOS/nix?rev=5233fd2ba76a3accb5aaa999c00509a11fd0793c": {Type: TypeGitHub, Owner: "NixOS", Repo: "nix", Rev: "5233fd2ba76a3accb5aaa999c00509a11fd0793c"},
6667
"github:NixOS/nix?host=example.com": {Type: TypeGitHub, Owner: "NixOS", Repo: "nix", Host: "example.com"},
6768
"github:NixOS/nix?host=example.com&dir=subdir": {Type: TypeGitHub, Owner: "NixOS", Repo: "nix", Host: "example.com", Dir: "subdir"},
69+
"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="},
6870

6971
// The github type allows clone-style URLs. The username and
7072
// host are ignored.
@@ -81,7 +83,8 @@ func TestParseFlakeRef(t *testing.T) {
8183
"git+ssh://[email protected]/repo/flake": {Type: TypeGit, URL: "ssh://[email protected]/repo/flake"},
8284
"git:/repo/flake": {Type: TypeGit, URL: "git:/repo/flake"},
8385
"git+file:///repo/flake": {Type: TypeGit, URL: "file:///repo/flake"},
84-
"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"},
86+
"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"},
87+
"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"},
8588

8689
// Tarball references.
8790
"tarball+http://example.com/flake": {Type: TypeTarball, URL: "http://example.com/flake"},
@@ -99,14 +102,15 @@ func TestParseFlakeRef(t *testing.T) {
99102
"http://example.com/flake.tar.bz2": {Type: TypeTarball, URL: "http://example.com/flake.tar.bz2"},
100103
"http://example.com/flake.tar.zst": {Type: TypeTarball, URL: "http://example.com/flake.tar.zst"},
101104
"http://example.com/flake.tar?dir=subdir": {Type: TypeTarball, URL: "http://example.com/flake.tar?dir=subdir", Dir: "subdir"},
102-
"file:///flake.zip": {Type: TypeTarball, URL: "file:///flake.zip"},
103-
"file:///flake.tar": {Type: TypeTarball, URL: "file:///flake.tar"},
104-
"file:///flake.tgz": {Type: TypeTarball, URL: "file:///flake.tgz"},
105-
"file:///flake.tar.gz": {Type: TypeTarball, URL: "file:///flake.tar.gz"},
106-
"file:///flake.tar.xz": {Type: TypeTarball, URL: "file:///flake.tar.xz"},
107-
"file:///flake.tar.bz2": {Type: TypeTarball, URL: "file:///flake.tar.bz2"},
108-
"file:///flake.tar.zst": {Type: TypeTarball, URL: "file:///flake.tar.zst"},
109-
"file:///flake.tar?dir=subdir": {Type: TypeTarball, URL: "file:///flake.tar?dir=subdir", Dir: "subdir"},
105+
"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="},
106+
"file:///flake.zip": {Type: TypeTarball, URL: "file:///flake.zip"},
107+
"file:///flake.tar": {Type: TypeTarball, URL: "file:///flake.tar"},
108+
"file:///flake.tgz": {Type: TypeTarball, URL: "file:///flake.tgz"},
109+
"file:///flake.tar.gz": {Type: TypeTarball, URL: "file:///flake.tar.gz"},
110+
"file:///flake.tar.xz": {Type: TypeTarball, URL: "file:///flake.tar.xz"},
111+
"file:///flake.tar.bz2": {Type: TypeTarball, URL: "file:///flake.tar.bz2"},
112+
"file:///flake.tar.zst": {Type: TypeTarball, URL: "file:///flake.tar.zst"},
113+
"file:///flake.tar?dir=subdir": {Type: TypeTarball, URL: "file:///flake.tar?dir=subdir", Dir: "subdir"},
110114

111115
// File URL references.
112116
"file+file:///flake": {Type: TypeFile, URL: "file:///flake"},
@@ -403,5 +407,5 @@ func TestBuildQueryString(t *testing.T) {
403407
// directives).
404408
var elems []string
405409
elems = append(elems, "1")
406-
buildQueryString(elems...)
410+
appendQueryString(nil, elems...)
407411
}

0 commit comments

Comments
 (0)