Skip to content

Commit 068a486

Browse files
committed
devpkg: better flakeref parsing
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. 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 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 dfd5d53 commit 068a486

File tree

2 files changed

+427
-0
lines changed

2 files changed

+427
-0
lines changed

internal/devpkg/flakeref.go

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
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 as 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 containinig 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+
}

0 commit comments

Comments
 (0)