2
2
package flake
3
3
4
4
import (
5
+ "cmp"
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,38 +166,79 @@ func parseURLRef(ref string) (parsed Ref, fragment string, err error) {
156
166
} else {
157
167
parsed .Path = refURL .Path
158
168
}
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
+ }
159
176
case "http" , "https" , "file" :
160
177
if isArchive (refURL .Path ) {
161
178
parsed .Type = TypeTarball
162
179
} else {
163
180
parsed .Type = TypeFile
164
181
}
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 ()
166
195
parsed .URL = refURL .String ()
167
196
case "tarball+http" , "tarball+https" , "tarball+file" :
168
197
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
+ }
170
205
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 ()
171
211
refURL .Scheme = refURL .Scheme [8 :] // remove tarball+
172
212
parsed .URL = refURL .String ()
173
213
case "file+http" , "file+https" , "file+file" :
174
214
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
+ }
176
222
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 ()
177
228
refURL .Scheme = refURL .Scheme [5 :] // remove file+
178
229
parsed .URL = refURL .String ()
179
230
case "git" , "git+http" , "git+https" , "git+ssh" , "git+git" , "git+file" :
180
231
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" )
185
236
186
237
// ref and rev get stripped from the query parameters, but dir
187
238
// 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 ()
191
242
if len (refURL .Scheme ) > 3 {
192
243
refURL .Scheme = refURL .Scheme [4 :] // remove git+
193
244
}
@@ -245,9 +296,42 @@ func parseGitHubRef(refURL *url.URL, parsed *Ref) error {
245
296
parsed .Rev = qRev
246
297
}
247
298
parsed .Dir = refURL .Query ().Get ("dir" )
299
+ parsed .NARHash = refURL .Query ().Get ("narHash" )
248
300
return nil
249
301
}
250
302
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
+
251
335
// String encodes the flake reference as a URL-like string. It normalizes the
252
336
// result such that if two Ref values are equal, then their strings will also be
253
337
// equal.
@@ -262,15 +346,26 @@ func parseGitHubRef(refURL *url.URL, parsed *Ref) error {
262
346
// put in the path.
263
347
// - query parameters are sorted by key.
264
348
//
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
266
350
// string.
267
351
func (r Ref ) String () string {
268
352
switch r .Type {
269
353
case TypeFile :
270
354
if r .URL == "" {
271
355
return ""
272
356
}
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 ()
274
369
case TypeGit :
275
370
if r .URL == "" {
276
371
return ""
@@ -292,26 +387,35 @@ func (r Ref) String() string {
292
387
// messed with the parsed URL.
293
388
return ""
294
389
}
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 )
296
391
return url .String ()
297
392
case TypeGitHub :
298
393
if r .Owner == "" || r .Repo == "" {
299
394
return ""
300
395
}
301
396
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
+ ),
305
405
}
306
406
return url .String ()
307
407
case TypeIndirect :
308
408
if r .ID == "" {
309
409
return ""
310
410
}
311
411
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
+ ),
315
419
}
316
420
return url .String ()
317
421
case TypePath :
@@ -330,6 +434,11 @@ func (r Ref) String() string {
330
434
} else if r .Path == "." {
331
435
url .Opaque = "."
332
436
}
437
+
438
+ url .RawQuery = appendQueryString (nil ,
439
+ "lastModified" , itoaOmitZero (r .LastModified ),
440
+ "narHash" , r .NARHash ,
441
+ )
333
442
return url .String ()
334
443
case TypeTarball :
335
444
if r .URL == "" {
@@ -338,17 +447,18 @@ func (r Ref) String() string {
338
447
if ! strings .HasPrefix (r .URL , "tarball" ) {
339
448
r .URL = "tarball+" + r .URL
340
449
}
341
- if r .Dir == "" {
342
- return r .URL
343
- }
344
450
345
451
url , err := url .Parse (r .URL )
346
452
if err != nil {
347
453
// This should be rare and only happen if the caller
348
454
// messed with the parsed URL.
349
455
return ""
350
456
}
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
+ )
352
462
return url .String ()
353
463
default :
354
464
return ""
@@ -423,21 +533,44 @@ func buildEscapedPath(elem ...string) string {
423
533
return u .JoinPath (elem ... ).String ()
424
534
}
425
535
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
427
537
// pairs, omitting any keys with empty values.
428
- func buildQueryString ( keyval ... string ) string {
538
+ func appendQueryString ( query url. Values , keyval ... string ) string {
429
539
if len (keyval )% 2 != 0 {
430
- panic ("buildQueryString : odd number of key-value pairs" )
540
+ panic ("appendQueryString : odd number of key-value pairs" )
431
541
}
432
542
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
+ }
434
550
for i := 0 ; i < len (keyval ); i += 2 {
435
551
k , v := keyval [i ], keyval [i + 1 ]
436
552
if v != "" {
437
- query .Set (k , v )
553
+ appended .Set (k , v )
438
554
}
439
555
}
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 )
441
574
}
442
575
443
576
// Special values for [Installable].Outputs.
0 commit comments