Skip to content

Commit ff89ae2

Browse files
committed
flake: add narHash and lastModified attributes
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 ff89ae2

File tree

2 files changed

+161
-30
lines changed

2 files changed

+161
-30
lines changed

nix/flake/flakeref.go

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

44
import (
5+
"maps"
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,24 +166,64 @@ func parseURLRef(ref string) (parsed Ref, fragment string, err error) {
156166
} else {
157167
parsed.Path = refURL.Path
158168
}
169+
170+
parsed.NARHash = refURL.Query().Get("narHash")
171+
parsed.LastModified, err = atoiOmitZero(refURL.Query().Get("lastModified"))
172+
if err != nil {
173+
return Ref{}, "", redact.Errorf("parse flake reference URL query parameter: lastModified=%s: %v", redact.Safe(parsed.LastModified), redact.Safe(err))
174+
}
159175
case "http", "https", "file":
160176
if isArchive(refURL.Path) {
161177
parsed.Type = TypeTarball
162178
} else {
163179
parsed.Type = TypeFile
164180
}
165-
parsed.Dir = refURL.Query().Get("dir")
181+
query := refURL.Query()
182+
parsed.Dir = query.Get("dir")
183+
parsed.NARHash = refURL.Query().Get("narHash")
184+
parsed.LastModified, err = atoiOmitZero(refURL.Query().Get("lastModified"))
185+
if err != nil {
186+
return Ref{}, "", redact.Errorf("parse flake reference URL query parameter: lastModified=%s: %v", redact.Safe(parsed.LastModified), redact.Safe(err))
187+
}
188+
189+
// lastModified and narHash get stripped from the query
190+
// parameters, but dir stays.
191+
query.Del("lastModified")
192+
query.Del("narHash")
193+
refURL.RawQuery = query.Encode()
166194
parsed.URL = refURL.String()
167195
case "tarball+http", "tarball+https", "tarball+file":
168196
parsed.Type = TypeTarball
169-
parsed.Dir = refURL.Query().Get("dir")
197+
query := refURL.Query()
198+
parsed.Dir = query.Get("dir")
199+
parsed.NARHash = refURL.Query().Get("narHash")
200+
parsed.LastModified, err = atoiOmitZero(refURL.Query().Get("lastModified"))
201+
if err != nil {
202+
return Ref{}, "", redact.Errorf("parse flake reference URL query parameter: lastModified=%s: %v", redact.Safe(parsed.LastModified), redact.Safe(err))
203+
}
170204

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

222+
// lastModified and narHash get stripped from the query
223+
// parameters, but dir stays.
224+
query.Del("lastModified")
225+
query.Del("narHash")
226+
refURL.RawQuery = query.Encode()
177227
refURL.Scheme = refURL.Scheme[5:] // remove file+
178228
parsed.URL = refURL.String()
179229
case "git", "git+http", "git+https", "git+ssh", "git+git", "git+file":
@@ -245,9 +295,42 @@ func parseGitHubRef(refURL *url.URL, parsed *Ref) error {
245295
parsed.Rev = qRev
246296
}
247297
parsed.Dir = refURL.Query().Get("dir")
298+
parsed.NARHash = refURL.Query().Get("narHash")
248299
return nil
249300
}
250301

302+
// Locked reports whether r is locked. Locked flake references always resolve to
303+
// the same content. For some flake types, determining if a Ref is locked
304+
// depends on the local Nix configuration. In these cases, Locked conservatively
305+
// returns false.
306+
func (r Ref) Locked() bool {
307+
// Search for the implementations of InputScheme::isLocked in the nix
308+
// source.
309+
//
310+
// https://github.com/search?q=repo%3ANixOS%2Fnix+language%3AC%2B%2B+symbol%3AisLocked&type=code
311+
312+
switch r.Type {
313+
case TypeFile, TypePath, TypeTarball:
314+
return r.NARHash != ""
315+
case TypeGit:
316+
return r.Rev != ""
317+
case TypeGitHub:
318+
// We technically can't determine if a github flake is locked
319+
// unless we know the trust-tarballs-from-git-forges Nix setting
320+
// (which defaults to true), so we have to be conservative and
321+
// check for rev and narHash.
322+
//
323+
// https://github.com/NixOS/nix/blob/3f3feae33e3381a2ea5928febe03329f0a578b20/src/libfetchers/github.cc#L304-L313
324+
return r.Rev != "" && r.NARHash != ""
325+
case TypeIndirect:
326+
// Never locked because they must be resolved against a flake
327+
// registry.
328+
return false
329+
default:
330+
return false
331+
}
332+
}
333+
251334
// String encodes the flake reference as a URL-like string. It normalizes the
252335
// result such that if two Ref values are equal, then their strings will also be
253336
// equal.
@@ -262,15 +345,26 @@ func parseGitHubRef(refURL *url.URL, parsed *Ref) error {
262345
// put in the path.
263346
// - query parameters are sorted by key.
264347
//
265-
// If f is missing a type or has any invalid fields, String returns an empty
348+
// If r is missing a type or has any invalid fields, String returns an empty
266349
// string.
267350
func (r Ref) String() string {
268351
switch r.Type {
269352
case TypeFile:
270353
if r.URL == "" {
271354
return ""
272355
}
273-
return "file+" + r.URL
356+
357+
url, err := url.Parse("file+" + r.URL)
358+
if err != nil {
359+
// This should be rare and only happen if the caller
360+
// messed with the parsed URL.
361+
return ""
362+
}
363+
url.RawQuery = buildQueryString(url.Query(),
364+
"lastModified", itoaOmitZero(r.LastModified),
365+
"narHash", r.NARHash,
366+
)
367+
return url.String()
274368
case TypeGit:
275369
if r.URL == "" {
276370
return ""
@@ -292,26 +386,35 @@ func (r Ref) String() string {
292386
// messed with the parsed URL.
293387
return ""
294388
}
295-
url.RawQuery = buildQueryString("ref", r.Ref, "rev", r.Rev, "dir", r.Dir)
389+
url.RawQuery = buildQueryString(url.Query(), "ref", r.Ref, "rev", r.Rev, "dir", r.Dir)
296390
return url.String()
297391
case TypeGitHub:
298392
if r.Owner == "" || r.Repo == "" {
299393
return ""
300394
}
301395
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),
396+
Scheme: "github",
397+
Opaque: buildEscapedPath(r.Owner, r.Repo, r.Rev, r.Ref),
398+
RawQuery: buildQueryString(nil,
399+
"host", r.Host,
400+
"dir", r.Dir,
401+
"lastModified", itoaOmitZero(r.LastModified),
402+
"narHash", r.NARHash,
403+
),
305404
}
306405
return url.String()
307406
case TypeIndirect:
308407
if r.ID == "" {
309408
return ""
310409
}
311410
url := &url.URL{
312-
Scheme: "flake",
313-
Opaque: buildEscapedPath(r.ID, r.Ref, r.Rev),
314-
RawQuery: buildQueryString("dir", r.Dir),
411+
Scheme: "flake",
412+
Opaque: buildEscapedPath(r.ID, r.Ref, r.Rev),
413+
RawQuery: buildQueryString(nil,
414+
"dir", r.Dir,
415+
"lastModified", itoaOmitZero(r.LastModified),
416+
"narHash", r.NARHash,
417+
),
315418
}
316419
return url.String()
317420
case TypePath:
@@ -330,6 +433,11 @@ func (r Ref) String() string {
330433
} else if r.Path == "." {
331434
url.Opaque = "."
332435
}
436+
437+
url.RawQuery = buildQueryString(nil,
438+
"lastModified", itoaOmitZero(r.LastModified),
439+
"narHash", r.NARHash,
440+
)
333441
return url.String()
334442
case TypeTarball:
335443
if r.URL == "" {
@@ -338,17 +446,18 @@ func (r Ref) String() string {
338446
if !strings.HasPrefix(r.URL, "tarball") {
339447
r.URL = "tarball+" + r.URL
340448
}
341-
if r.Dir == "" {
342-
return r.URL
343-
}
344449

345450
url, err := url.Parse(r.URL)
346451
if err != nil {
347452
// This should be rare and only happen if the caller
348453
// messed with the parsed URL.
349454
return ""
350455
}
351-
url.RawQuery = buildQueryString("dir", r.Dir)
456+
url.RawQuery = buildQueryString(url.Query(),
457+
"dir", r.Dir,
458+
"lastModified", itoaOmitZero(r.LastModified),
459+
"narHash", r.NARHash,
460+
)
352461
return url.String()
353462
default:
354463
return ""
@@ -425,19 +534,37 @@ func buildEscapedPath(elem ...string) string {
425534

426535
// buildQueryString builds a URL query string from a list of key-value string
427536
// pairs, omitting any keys with empty values.
428-
func buildQueryString(keyval ...string) string {
537+
func buildQueryString(initial url.Values, keyval ...string) string {
429538
if len(keyval)%2 != 0 {
430539
panic("buildQueryString: odd number of key-value pairs")
431540
}
432541

433-
query := make(url.Values, len(keyval)/2)
542+
q := make(url.Values, len(initial)+len(keyval)/2)
543+
maps.Copy(q, initial)
434544
for i := 0; i < len(keyval); i += 2 {
435545
k, v := keyval[i], keyval[i+1]
436546
if v != "" {
437-
query.Set(k, v)
547+
q.Set(k, v)
438548
}
439549
}
440-
return query.Encode()
550+
return q.Encode()
551+
}
552+
553+
// itoaOmitZero returns an empty string if i == 0, otherwise it formats i as a
554+
// string in base-10.
555+
func itoaOmitZero(i int64) string {
556+
if i == 0 {
557+
return ""
558+
}
559+
return strconv.FormatInt(i, 10)
560+
}
561+
562+
// atoiOmitZero returns 0 if s == "", otherwised it parses s as a base-10 int64.
563+
func atoiOmitZero(s string) (int64, error) {
564+
if s == "" {
565+
return 0, nil
566+
}
567+
return strconv.ParseInt(s, 10, 64)
441568
}
442569

443570
// 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+
buildQueryString(nil, elems...)
407411
}

0 commit comments

Comments
 (0)