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