22package flake
33
44import (
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.
267350func (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.
0 commit comments