|
| 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