2
2
package flake
3
3
4
4
import (
5
+ "maps"
5
6
"net/url"
6
7
"path"
7
8
"slices"
9
+ "strconv"
8
10
"strings"
9
11
10
12
"go.jetpack.io/devbox/internal/redact"
@@ -67,6 +69,14 @@ type Ref struct {
67
69
// or "git". Note that the URL is not the same as the raw unparsed
68
70
// flake ref.
69
71
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"`
70
80
}
71
81
72
82
// 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) {
156
166
} else {
157
167
parsed .Path = refURL .Path
158
168
}
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
+ }
159
175
case "http" , "https" , "file" :
160
176
if isArchive (refURL .Path ) {
161
177
parsed .Type = TypeTarball
162
178
} else {
163
179
parsed .Type = TypeFile
164
180
}
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 ()
166
194
parsed .URL = refURL .String ()
167
195
case "tarball+http" , "tarball+https" , "tarball+file" :
168
196
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
+ }
170
204
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 ()
171
210
refURL .Scheme = refURL .Scheme [8 :] // remove tarball+
172
211
parsed .URL = refURL .String ()
173
212
case "file+http" , "file+https" , "file+file" :
174
213
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
+ }
176
221
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 ()
177
227
refURL .Scheme = refURL .Scheme [5 :] // remove file+
178
228
parsed .URL = refURL .String ()
179
229
case "git" , "git+http" , "git+https" , "git+ssh" , "git+git" , "git+file" :
@@ -245,9 +295,42 @@ func parseGitHubRef(refURL *url.URL, parsed *Ref) error {
245
295
parsed .Rev = qRev
246
296
}
247
297
parsed .Dir = refURL .Query ().Get ("dir" )
298
+ parsed .NARHash = refURL .Query ().Get ("narHash" )
248
299
return nil
249
300
}
250
301
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
+
251
334
// String encodes the flake reference as a URL-like string. It normalizes the
252
335
// result such that if two Ref values are equal, then their strings will also be
253
336
// equal.
@@ -262,15 +345,26 @@ func parseGitHubRef(refURL *url.URL, parsed *Ref) error {
262
345
// put in the path.
263
346
// - query parameters are sorted by key.
264
347
//
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
266
349
// string.
267
350
func (r Ref ) String () string {
268
351
switch r .Type {
269
352
case TypeFile :
270
353
if r .URL == "" {
271
354
return ""
272
355
}
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 ()
274
368
case TypeGit :
275
369
if r .URL == "" {
276
370
return ""
@@ -292,26 +386,35 @@ func (r Ref) String() string {
292
386
// messed with the parsed URL.
293
387
return ""
294
388
}
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 )
296
390
return url .String ()
297
391
case TypeGitHub :
298
392
if r .Owner == "" || r .Repo == "" {
299
393
return ""
300
394
}
301
395
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
+ ),
305
404
}
306
405
return url .String ()
307
406
case TypeIndirect :
308
407
if r .ID == "" {
309
408
return ""
310
409
}
311
410
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
+ ),
315
418
}
316
419
return url .String ()
317
420
case TypePath :
@@ -330,6 +433,11 @@ func (r Ref) String() string {
330
433
} else if r .Path == "." {
331
434
url .Opaque = "."
332
435
}
436
+
437
+ url .RawQuery = buildQueryString (nil ,
438
+ "lastModified" , itoaOmitZero (r .LastModified ),
439
+ "narHash" , r .NARHash ,
440
+ )
333
441
return url .String ()
334
442
case TypeTarball :
335
443
if r .URL == "" {
@@ -338,17 +446,18 @@ func (r Ref) String() string {
338
446
if ! strings .HasPrefix (r .URL , "tarball" ) {
339
447
r .URL = "tarball+" + r .URL
340
448
}
341
- if r .Dir == "" {
342
- return r .URL
343
- }
344
449
345
450
url , err := url .Parse (r .URL )
346
451
if err != nil {
347
452
// This should be rare and only happen if the caller
348
453
// messed with the parsed URL.
349
454
return ""
350
455
}
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
+ )
352
461
return url .String ()
353
462
default :
354
463
return ""
@@ -425,19 +534,37 @@ func buildEscapedPath(elem ...string) string {
425
534
426
535
// buildQueryString builds a URL query string from a list of key-value string
427
536
// pairs, omitting any keys with empty values.
428
- func buildQueryString (keyval ... string ) string {
537
+ func buildQueryString (initial url. Values , keyval ... string ) string {
429
538
if len (keyval )% 2 != 0 {
430
539
panic ("buildQueryString: odd number of key-value pairs" )
431
540
}
432
541
433
- query := make (url.Values , len (keyval )/ 2 )
542
+ q := make (url.Values , len (initial )+ len (keyval )/ 2 )
543
+ maps .Copy (q , initial )
434
544
for i := 0 ; i < len (keyval ); i += 2 {
435
545
k , v := keyval [i ], keyval [i + 1 ]
436
546
if v != "" {
437
- query .Set (k , v )
547
+ q .Set (k , v )
438
548
}
439
549
}
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 )
441
568
}
442
569
443
570
// Special values for [Installable].Outputs.
0 commit comments