Skip to content

Commit 5a94b6e

Browse files
authored
devpkg: better flake references and installable parsing (#1581)
In anticipation of adding support for non-default package outputs, I wanted to clean up some of the flakeref parsing in `devpkg`. This will make it easier to handle Nix's `flakeref#attrpath^outputs` installable syntax. This PR is broken up into two commits: one that handles flakeref parsing and one that handles installable parsing. The `ParseFlakeRef` function is able to parse the more common flakerefs that devbox currently supports ("path", "flake", "indirect", etc.). It doesn't support mercurial, gitlab, and sourcehuthut for now. The `ParseFlakeInstallable` function parses Nix flake installables, which are a superset of the flake reference syntax. It allows an attribute path and/or outputs to be specified using the `flake#attrpath^outputs` syntax. The tests were validated using the nix expression `builtins.parseFlakeRef "ref"` and the `nix flake metadata <ref>` CLI. The goal isn't to perfectly replicate Nix's parsing of flake references (which itself is sometimes inconsistent), but to make it good enough to handle flakerefs in devbox.json.
1 parent 4394d1a commit 5a94b6e

File tree

2 files changed

+611
-0
lines changed

2 files changed

+611
-0
lines changed

internal/devpkg/flakeref.go

Lines changed: 356 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,356 @@
1+
package devpkg
2+
3+
import (
4+
"net/url"
5+
"strings"
6+
7+
"go.jetpack.io/devbox/internal/redact"
8+
)
9+
10+
// FlakeRef is a parsed Nix flake reference. A flake reference is a subset of
11+
// the Nix CLI "installable" syntax. Installables may specify an attribute path
12+
// and derivation outputs with a flake reference using the '#' and '^' characters.
13+
// For example, the string "nixpkgs" and "./flake" are valid flake references,
14+
// but "nixpkgs#hello" and "./flake#app^bin,dev" are not.
15+
//
16+
// See the [Nix manual] for details on flake references.
17+
//
18+
// [Nix manual]: https://nixos.org/manual/nix/unstable/command-ref/new-cli/nix3-flake
19+
type FlakeRef struct {
20+
// Type is the type of flake reference. Some valid types are "indirect",
21+
// "path", "file", "git", "tarball", and "github".
22+
Type string `json:"type,omitempty"`
23+
24+
// ID is the flake's identifier when Type is "indirect". A common
25+
// example is nixpkgs.
26+
ID string `json:"id,omitempty"`
27+
28+
// Path is the path to the flake directory when Type is "path".
29+
Path string `json:"path,omitempty"`
30+
31+
// Owner and repo are the flake repository owner and name when Type is
32+
// "github".
33+
Owner string `json:"owner,omitempty"`
34+
Repo string `json:"repo,omitempty"`
35+
36+
// Rev and ref are the git revision (commit hash) and ref
37+
// (branch or tag) when Type is "github" or "git".
38+
Rev string `json:"rev,omitempty"`
39+
Ref string `json:"ref,omitempty"`
40+
41+
// Dir is non-empty when the directory containing the flake.nix file is
42+
// not at the flake root. It corresponds to the optional "dir" query
43+
// parameter when Type is "github", "git", "tarball", or "file".
44+
Dir string `json:"dir,omitempty"`
45+
46+
// Host overrides the default VCS host when Type is "github", such as
47+
// when referring to a GitHub Enterprise instance. It corresponds to the
48+
// optional "host" query parameter when Type is "github".
49+
Host string `json:"host,omitempty"`
50+
51+
// URL is the URL pointing to the flake when type is "tarball", "file",
52+
// or "git". Note that the URL is not the same as the raw unparsed
53+
// flakeref.
54+
URL string `json:"url,omitempty"`
55+
56+
// raw stores the original unparsed flakeref string.
57+
raw string
58+
}
59+
60+
// ParseFlakeRef parses a raw flake reference. Nix supports a variety of
61+
// flakeref formats, and isn't entirely consistent about how it parses them.
62+
// ParseFlakeRef attempts to mimic how Nix parses flakerefs on the command line.
63+
// The raw ref can be one of the following:
64+
//
65+
// - Indirect reference such as "nixpkgs" or "nixpkgs/unstable".
66+
// - Path-like reference such as "./flake" or "/path/to/flake". They must
67+
// start with a '.' or '/' and not contain a '#' or '?'.
68+
// - URL-like reference which must be a valid URL with any special characters
69+
// encoded. The scheme can be any valid flakeref type except for mercurial,
70+
// gitlab, and sourcehut.
71+
//
72+
// ParseFlakeRef does not guarantee that a parsed flakeref is valid or that an
73+
// error indicates an invalid flakeref. Use the "nix flake metadata" command or
74+
// the parseFlakeRef builtin function to validate a flakeref.
75+
func ParseFlakeRef(ref string) (FlakeRef, error) {
76+
if ref == "" {
77+
return FlakeRef{}, redact.Errorf("empty flake reference")
78+
}
79+
80+
// Handle path-style references first.
81+
parsed := FlakeRef{raw: ref}
82+
if ref[0] == '.' || ref[0] == '/' {
83+
if strings.ContainsAny(ref, "?#") {
84+
// The Nix CLI does seem to allow paths with a '?'
85+
// (contrary to the manual) but ignores everything that
86+
// comes after it. This is a bit surprising, so we just
87+
// don't allow it at all.
88+
return FlakeRef{}, redact.Errorf("path-style flake reference %q contains a '?' or '#'", ref)
89+
}
90+
parsed.Type = "path"
91+
parsed.Path = ref
92+
return parsed, nil
93+
}
94+
parsed, _, err := parseFlakeURLRef(ref)
95+
return parsed, err
96+
}
97+
98+
func parseFlakeURLRef(ref string) (parsed FlakeRef, fragment string, err error) {
99+
// A good way to test how Nix parses a flake reference is to run:
100+
//
101+
// nix eval --json --expr 'builtins.parseFlakeRef "ref"' | jq
102+
parsed.raw = ref
103+
refURL, err := url.Parse(ref)
104+
if err != nil {
105+
return FlakeRef{}, "", redact.Errorf("parse flake reference as URL: %v", err)
106+
}
107+
108+
switch refURL.Scheme {
109+
case "", "flake":
110+
// [flake:]<flake-id>(/<rev-or-ref>(/rev)?)?
111+
112+
parsed.Type = "indirect"
113+
114+
// "indirect" is parsed as a path, "flake:indirect" is parsed as
115+
// opaque because it has a scheme.
116+
path := refURL.Path
117+
if path == "" {
118+
path, err = url.PathUnescape(refURL.Opaque)
119+
if err != nil {
120+
path = refURL.Opaque
121+
}
122+
}
123+
split := strings.SplitN(path, "/", 3)
124+
parsed.ID = split[0]
125+
if len(split) > 1 {
126+
if isGitHash(split[1]) {
127+
parsed.Rev = split[1]
128+
} else {
129+
parsed.Ref = split[1]
130+
}
131+
}
132+
if len(split) > 2 && parsed.Rev == "" {
133+
parsed.Rev = split[2]
134+
}
135+
case "path":
136+
// [path:]<path>(\?<params)?
137+
138+
parsed.Type = "path"
139+
if refURL.Path == "" {
140+
parsed.Path, err = url.PathUnescape(refURL.Opaque)
141+
if err != nil {
142+
parsed.Path = refURL.Opaque
143+
}
144+
} else {
145+
parsed.Path = refURL.Path
146+
}
147+
case "http", "https", "file":
148+
if isArchive(refURL.Path) {
149+
parsed.Type = "tarball"
150+
} else {
151+
parsed.Type = "file"
152+
}
153+
parsed.URL = ref
154+
parsed.Dir = refURL.Query().Get("dir")
155+
case "tarball+http", "tarball+https", "tarball+file":
156+
parsed.Type = "tarball"
157+
parsed.Dir = refURL.Query().Get("dir")
158+
parsed.URL = ref[8:] // remove tarball+
159+
case "file+http", "file+https", "file+file":
160+
parsed.Type = "file"
161+
parsed.Dir = refURL.Query().Get("dir")
162+
parsed.URL = ref[5:] // remove file+
163+
case "git", "git+http", "git+https", "git+ssh", "git+git", "git+file":
164+
parsed.Type = "git"
165+
q := refURL.Query()
166+
parsed.Dir = q.Get("dir")
167+
parsed.Ref = q.Get("ref")
168+
parsed.Rev = q.Get("rev")
169+
170+
// ref and rev get stripped from the query parameters, but dir
171+
// stays.
172+
q.Del("ref")
173+
q.Del("rev")
174+
refURL.RawQuery = q.Encode()
175+
if len(refURL.Scheme) > 3 {
176+
refURL.Scheme = refURL.Scheme[4:] // remove git+
177+
}
178+
parsed.URL = refURL.String()
179+
case "github":
180+
if err := parseGitHubFlakeRef(refURL, &parsed); err != nil {
181+
return FlakeRef{}, "", err
182+
}
183+
}
184+
return parsed, refURL.Fragment, nil
185+
}
186+
187+
func parseGitHubFlakeRef(refURL *url.URL, parsed *FlakeRef) error {
188+
// github:<owner>/<repo>(/<rev-or-ref>)?(\?<params>)?
189+
190+
parsed.Type = "github"
191+
path := refURL.Path
192+
if path == "" {
193+
var err error
194+
path, err = url.PathUnescape(refURL.Opaque)
195+
if err != nil {
196+
path = refURL.Opaque
197+
}
198+
}
199+
path = strings.TrimPrefix(path, "/")
200+
201+
split := strings.SplitN(path, "/", 3)
202+
parsed.Owner = split[0]
203+
parsed.Repo = split[1]
204+
if len(split) > 2 {
205+
if revOrRef := split[2]; isGitHash(revOrRef) {
206+
parsed.Rev = revOrRef
207+
} else {
208+
parsed.Ref = revOrRef
209+
}
210+
}
211+
212+
parsed.Host = refURL.Query().Get("host")
213+
if qRef := refURL.Query().Get("ref"); qRef != "" {
214+
if parsed.Rev != "" {
215+
return redact.Errorf("github flake reference has a ref and a rev")
216+
}
217+
if parsed.Ref != "" && qRef != parsed.Ref {
218+
return redact.Errorf("github flake reference has a ref in the path (%q) and a ref query parameter (%q)", parsed.Ref, qRef)
219+
}
220+
parsed.Ref = qRef
221+
}
222+
if qRev := refURL.Query().Get("rev"); qRev != "" {
223+
if parsed.Ref != "" {
224+
return redact.Errorf("github flake reference has a ref and a rev")
225+
}
226+
if parsed.Rev != "" && qRev != parsed.Rev {
227+
return redact.Errorf("github flake reference has a rev in the path (%q) and a rev query parameter (%q)", parsed.Rev, qRev)
228+
}
229+
parsed.Rev = qRev
230+
}
231+
return nil
232+
}
233+
234+
// String returns the raw flakeref string as given to ParseFlakeRef.
235+
func (f FlakeRef) String() string {
236+
return f.raw
237+
}
238+
239+
func isGitHash(s string) bool {
240+
if len(s) != 40 {
241+
return false
242+
}
243+
for i := range s {
244+
isDigit := s[i] >= '0' && s[i] <= '9'
245+
isHexLetter := s[i] >= 'a' && s[i] <= 'f'
246+
if !isDigit && !isHexLetter {
247+
return false
248+
}
249+
}
250+
return true
251+
}
252+
253+
func isArchive(path string) bool {
254+
return strings.HasSuffix(path, ".tar") ||
255+
strings.HasSuffix(path, ".tar.gz") ||
256+
strings.HasSuffix(path, ".tgz") ||
257+
strings.HasSuffix(path, ".tar.xz") ||
258+
strings.HasSuffix(path, ".tar.zst") ||
259+
strings.HasSuffix(path, ".tar.bz2") ||
260+
strings.HasSuffix(path, ".zip")
261+
}
262+
263+
// FlakeInstallable is a Nix command line argument that specifies how to install
264+
// a flake. It can be a plain flake reference, or a flake reference with an
265+
// attribute path and/or output specification.
266+
//
267+
// Some examples are:
268+
//
269+
// - "." installs the default attribute from the flake in the current
270+
// directory.
271+
// - ".#hello" installs the hello attribute from the flake in the current
272+
// directory.
273+
// - "nixpkgs#hello" installs the hello attribute from the nixpkgs flake.
274+
// - "github:NixOS/nixpkgs/unstable#curl^lib" installs the the lib output of
275+
// curl attribute from the flake on the nixpkgs unstable branch.
276+
//
277+
// The flake installable syntax is only valid in Nix command line arguments, not
278+
// in Nix expressions. See FlakeRef and the [Nix manual for details on the
279+
// differences between flake references and installables.
280+
//
281+
// [Nix manual]: https://nixos.org/manual/nix/unstable/command-ref/new-cli/nix#installables
282+
type FlakeInstallable struct {
283+
Ref FlakeRef
284+
AttrPath string
285+
Outputs []string
286+
287+
raw string
288+
}
289+
290+
// ParseFlakeInstallable parses a flake installable. The string s must contain a
291+
// valid flake reference parsable by ParseFlakeRef, optionally followed by an
292+
// #attrpath and/or an ^output.
293+
func ParseFlakeInstallable(raw string) (FlakeInstallable, error) {
294+
if raw == "" {
295+
return FlakeInstallable{}, redact.Errorf("empty flake installable")
296+
}
297+
298+
// The output spec must be parsed and removed first, otherwise it will
299+
// be parsed as part of the flakeref's URL fragment.
300+
install := FlakeInstallable{raw: raw}
301+
before, after := splitOutputSpec(raw)
302+
if after != "" {
303+
install.Outputs = strings.Split(after, ",")
304+
}
305+
raw = before
306+
307+
// Interpret installables with path-style flakerefs as URLs to extract
308+
// the attribute path (fragment). This means that path-style flakerefs
309+
// cannot point to files with a '#' or '?' in their name, since those
310+
// would be parsed as the URL fragment or query string. This mimic's
311+
// Nix's CLI behavior.
312+
prefix := ""
313+
if raw[0] == '.' || raw[0] == '/' {
314+
prefix = "path:"
315+
raw = prefix + raw
316+
}
317+
318+
var err error
319+
install.Ref, install.AttrPath, err = parseFlakeURLRef(raw)
320+
if err != nil {
321+
return FlakeInstallable{}, err
322+
}
323+
// Make sure to reset the raw flakeref to the original string
324+
// after parsing.
325+
install.Ref.raw = raw[len(prefix):]
326+
return install, nil
327+
}
328+
329+
// AllOutputs returns true if the installable specifies all outputs with the
330+
// "^*" syntax.
331+
func (f FlakeInstallable) AllOutputs() bool {
332+
for _, out := range f.Outputs {
333+
if out == "*" {
334+
return true
335+
}
336+
}
337+
return false
338+
}
339+
340+
// DefaultOutputs returns true if the installable does not specify any outputs.
341+
func (f FlakeInstallable) DefaultOutputs() bool {
342+
return len(f.Outputs) == 0
343+
}
344+
345+
// String returns the raw installable string as given to ParseFlakeInstallable.
346+
func (f FlakeInstallable) String() string {
347+
return f.raw
348+
}
349+
350+
// splitOutputSpec cuts a flake installable around the last instance of ^.
351+
func splitOutputSpec(s string) (before, after string) {
352+
if i := strings.LastIndexByte(s, '^'); i >= 0 {
353+
return s[:i], s[i+1:]
354+
}
355+
return s, ""
356+
}

0 commit comments

Comments
 (0)