Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 147 additions & 20 deletions nix/flake/flakeref.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@
package flake

import (
"maps"
"net/url"
"path"
"slices"
"strconv"
"strings"

"go.jetpack.io/devbox/internal/redact"
Expand Down Expand Up @@ -67,6 +69,14 @@ type Ref struct {
// or "git". Note that the URL is not the same as the raw unparsed
// flake ref.
URL string `json:"url,omitempty"`

// NARHash is the SRI hash of the flake's source. Specify a NAR hash to
// lock flakes that don't otherwise have a revision (such as "path" or
// "tarball" flakes).
NARHash string `json:"narHash,omitempty"`

// LastModified is the last modification time of the flake.
LastModified int64 `json:"lastModified,omitempty"`
}

// ParseRef parses a raw flake reference. Nix supports a variety of flake ref
Expand Down Expand Up @@ -156,24 +166,64 @@ func parseURLRef(ref string) (parsed Ref, fragment string, err error) {
} else {
parsed.Path = refURL.Path
}

parsed.NARHash = refURL.Query().Get("narHash")
parsed.LastModified, err = atoiOmitZero(refURL.Query().Get("lastModified"))
if err != nil {
return Ref{}, "", redact.Errorf("parse flake reference URL query parameter: lastModified=%s: %v", redact.Safe(parsed.LastModified), redact.Safe(err))
}
case "http", "https", "file":
if isArchive(refURL.Path) {
parsed.Type = TypeTarball
} else {
parsed.Type = TypeFile
}
parsed.Dir = refURL.Query().Get("dir")
query := refURL.Query()
parsed.Dir = query.Get("dir")
parsed.NARHash = refURL.Query().Get("narHash")
parsed.LastModified, err = atoiOmitZero(refURL.Query().Get("lastModified"))
if err != nil {
return Ref{}, "", redact.Errorf("parse flake reference URL query parameter: lastModified=%s: %v", redact.Safe(parsed.LastModified), redact.Safe(err))
}

// lastModified and narHash get stripped from the query
// parameters, but dir stays.
query.Del("lastModified")
query.Del("narHash")
refURL.RawQuery = query.Encode()
parsed.URL = refURL.String()
case "tarball+http", "tarball+https", "tarball+file":
parsed.Type = TypeTarball
parsed.Dir = refURL.Query().Get("dir")
query := refURL.Query()
parsed.Dir = query.Get("dir")
parsed.NARHash = refURL.Query().Get("narHash")
parsed.LastModified, err = atoiOmitZero(refURL.Query().Get("lastModified"))
if err != nil {
return Ref{}, "", redact.Errorf("parse flake reference URL query parameter: lastModified=%s: %v", redact.Safe(parsed.LastModified), redact.Safe(err))
}

// lastModified and narHash get stripped from the query
// parameters, but dir stays.
query.Del("lastModified")
query.Del("narHash")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seems like these lines are repeated in many case-bodies. Pull into a function and call that function instead?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you referring to the query.Del calls? I dunno, it seems like overkill to make those two lines a separate function.

refURL.RawQuery = query.Encode()
refURL.Scheme = refURL.Scheme[8:] // remove tarball+
parsed.URL = refURL.String()
case "file+http", "file+https", "file+file":
parsed.Type = TypeFile
parsed.Dir = refURL.Query().Get("dir")
query := refURL.Query()
parsed.Dir = query.Get("dir")
parsed.NARHash = refURL.Query().Get("narHash")
parsed.LastModified, err = atoiOmitZero(refURL.Query().Get("lastModified"))
if err != nil {
return Ref{}, "", redact.Errorf("parse flake reference URL query parameter: lastModified=%s: %v", redact.Safe(parsed.LastModified), redact.Safe(err))
}

// lastModified and narHash get stripped from the query
// parameters, but dir stays.
query.Del("lastModified")
query.Del("narHash")
refURL.RawQuery = query.Encode()
refURL.Scheme = refURL.Scheme[5:] // remove file+
parsed.URL = refURL.String()
case "git", "git+http", "git+https", "git+ssh", "git+git", "git+file":
Expand Down Expand Up @@ -245,9 +295,42 @@ func parseGitHubRef(refURL *url.URL, parsed *Ref) error {
parsed.Rev = qRev
}
parsed.Dir = refURL.Query().Get("dir")
parsed.NARHash = refURL.Query().Get("narHash")
return nil
}

// Locked reports whether r is locked. Locked flake references always resolve to
// the same content. For some flake types, determining if a Ref is locked
// depends on the local Nix configuration. In these cases, Locked conservatively
// returns false.
func (r Ref) Locked() bool {
// Search for the implementations of InputScheme::isLocked in the nix
// source.
//
// https://github.com/search?q=repo%3ANixOS%2Fnix+language%3AC%2B%2B+symbol%3AisLocked&type=code

switch r.Type {
case TypeFile, TypePath, TypeTarball:
return r.NARHash != ""
case TypeGit:
return r.Rev != ""
case TypeGitHub:
// We technically can't determine if a github flake is locked
// unless we know the trust-tarballs-from-git-forges Nix setting
// (which defaults to true), so we have to be conservative and
// check for rev and narHash.
//
// https://github.com/NixOS/nix/blob/3f3feae33e3381a2ea5928febe03329f0a578b20/src/libfetchers/github.cc#L304-L313
return r.Rev != "" && r.NARHash != ""
case TypeIndirect:
// Never locked because they must be resolved against a flake
// registry.
return false
default:
return false
}
}

// String encodes the flake reference as a URL-like string. It normalizes the
// result such that if two Ref values are equal, then their strings will also be
// equal.
Expand All @@ -262,15 +345,26 @@ func parseGitHubRef(refURL *url.URL, parsed *Ref) error {
// put in the path.
// - query parameters are sorted by key.
//
// If f is missing a type or has any invalid fields, String returns an empty
// If r is missing a type or has any invalid fields, String returns an empty
// string.
func (r Ref) String() string {
switch r.Type {
case TypeFile:
if r.URL == "" {
return ""
}
return "file+" + r.URL

url, err := url.Parse("file+" + r.URL)
if err != nil {
// This should be rare and only happen if the caller
// messed with the parsed URL.
return ""
}
url.RawQuery = buildQueryString(url.Query(),
"lastModified", itoaOmitZero(r.LastModified),
"narHash", r.NARHash,
Comment on lines +365 to +366
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what happens if lastModified or NarHash are zero?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

buildQueryString will omit them.

)
return url.String()
case TypeGit:
if r.URL == "" {
return ""
Expand All @@ -292,26 +386,35 @@ func (r Ref) String() string {
// messed with the parsed URL.
return ""
}
url.RawQuery = buildQueryString("ref", r.Ref, "rev", r.Rev, "dir", r.Dir)
url.RawQuery = buildQueryString(url.Query(), "ref", r.Ref, "rev", r.Rev, "dir", r.Dir)
return url.String()
case TypeGitHub:
if r.Owner == "" || r.Repo == "" {
return ""
}
url := &url.URL{
Scheme: "github",
Opaque: buildEscapedPath(r.Owner, r.Repo, r.Rev, r.Ref),
RawQuery: buildQueryString("host", r.Host, "dir", r.Dir),
Scheme: "github",
Opaque: buildEscapedPath(r.Owner, r.Repo, r.Rev, r.Ref),
RawQuery: buildQueryString(nil,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is nil for?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's the initial url.Values query. In this case it's nil because we're building the query string from scratch.

"host", r.Host,
"dir", r.Dir,
"lastModified", itoaOmitZero(r.LastModified),
"narHash", r.NARHash,
),
}
return url.String()
case TypeIndirect:
if r.ID == "" {
return ""
}
url := &url.URL{
Scheme: "flake",
Opaque: buildEscapedPath(r.ID, r.Ref, r.Rev),
RawQuery: buildQueryString("dir", r.Dir),
Scheme: "flake",
Opaque: buildEscapedPath(r.ID, r.Ref, r.Rev),
RawQuery: buildQueryString(nil,
"dir", r.Dir,
"lastModified", itoaOmitZero(r.LastModified),
"narHash", r.NARHash,
),
}
return url.String()
case TypePath:
Expand All @@ -330,6 +433,11 @@ func (r Ref) String() string {
} else if r.Path == "." {
url.Opaque = "."
}

url.RawQuery = buildQueryString(nil,
"lastModified", itoaOmitZero(r.LastModified),
"narHash", r.NARHash,
)
return url.String()
case TypeTarball:
if r.URL == "" {
Expand All @@ -338,17 +446,18 @@ func (r Ref) String() string {
if !strings.HasPrefix(r.URL, "tarball") {
r.URL = "tarball+" + r.URL
}
if r.Dir == "" {
return r.URL
}

url, err := url.Parse(r.URL)
if err != nil {
// This should be rare and only happen if the caller
// messed with the parsed URL.
return ""
}
url.RawQuery = buildQueryString("dir", r.Dir)
url.RawQuery = buildQueryString(url.Query(),
"dir", r.Dir,
"lastModified", itoaOmitZero(r.LastModified),
"narHash", r.NARHash,
)
return url.String()
default:
return ""
Expand Down Expand Up @@ -425,19 +534,37 @@ func buildEscapedPath(elem ...string) string {

// buildQueryString builds a URL query string from a list of key-value string
// pairs, omitting any keys with empty values.
func buildQueryString(keyval ...string) string {
func buildQueryString(initial url.Values, keyval ...string) string {
if len(keyval)%2 != 0 {
panic("buildQueryString: odd number of key-value pairs")
}

query := make(url.Values, len(keyval)/2)
q := make(url.Values, len(initial)+len(keyval)/2)
maps.Copy(q, initial)
for i := 0; i < len(keyval); i += 2 {
k, v := keyval[i], keyval[i+1]
if v != "" {
query.Set(k, v)
q.Set(k, v)
}
}
return query.Encode()
return q.Encode()
}

// itoaOmitZero returns an empty string if i == 0, otherwise it formats i as a
// string in base-10.
func itoaOmitZero(i int64) string {
if i == 0 {
return ""
}
return strconv.FormatInt(i, 10)
}

// atoiOmitZero returns 0 if s == "", otherwised it parses s as a base-10 int64.
func atoiOmitZero(s string) (int64, error) {
if s == "" {
return 0, nil
}
return strconv.ParseInt(s, 10, 64)
}

// Special values for [Installable].Outputs.
Expand Down
24 changes: 14 additions & 10 deletions nix/flake/flakeref_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ func TestParseFlakeRef(t *testing.T) {
"path:/": {Type: TypePath, Path: "/"},
"path:/flake": {Type: TypePath, Path: "/flake"},
"path:/absolute/flake": {Type: TypePath, Path: "/absolute/flake"},
"path:/absolute/flake?lastModified=1734435836&narHash=sha256-kMBQ5PRiFLagltK0sH%2B08aiNt3zGERC2297iB6vrvlU%3D": {Type: TypePath, Path: "/absolute/flake", LastModified: 1734435836, NARHash: "sha256-kMBQ5PRiFLagltK0sH+08aiNt3zGERC2297iB6vrvlU="},

// URL-like paths can omit the "./" prefix for relative
// directories.
Expand Down Expand Up @@ -65,6 +66,7 @@ func TestParseFlakeRef(t *testing.T) {
"github:NixOS/nix?rev=5233fd2ba76a3accb5aaa999c00509a11fd0793c": {Type: TypeGitHub, Owner: "NixOS", Repo: "nix", Rev: "5233fd2ba76a3accb5aaa999c00509a11fd0793c"},
"github:NixOS/nix?host=example.com": {Type: TypeGitHub, Owner: "NixOS", Repo: "nix", Host: "example.com"},
"github:NixOS/nix?host=example.com&dir=subdir": {Type: TypeGitHub, Owner: "NixOS", Repo: "nix", Host: "example.com", Dir: "subdir"},
"github:NixOS/nix?host=example.com&dir=subdir&lastModified=1734435836&narHash=sha256-kMBQ5PRiFLagltK0sH%2B08aiNt3zGERC2297iB6vrvlU%3D": {Type: TypeGitHub, Owner: "NixOS", Repo: "nix", Host: "example.com", Dir: "subdir", NARHash: "sha256-kMBQ5PRiFLagltK0sH+08aiNt3zGERC2297iB6vrvlU="},

// The github type allows clone-style URLs. The username and
// host are ignored.
Expand All @@ -81,7 +83,8 @@ func TestParseFlakeRef(t *testing.T) {
"git+ssh://[email protected]/repo/flake": {Type: TypeGit, URL: "ssh://[email protected]/repo/flake"},
"git:/repo/flake": {Type: TypeGit, URL: "git:/repo/flake"},
"git+file:///repo/flake": {Type: TypeGit, URL: "file:///repo/flake"},
"git://example.com/repo/flake?ref=unstable&rev=e486d8d40e626a20e06d792db8cc5ac5aba9a5b4&dir=subdir": {Type: TypeGit, URL: "git://example.com/repo/flake?dir=subdir", Ref: "unstable", Rev: "e486d8d40e626a20e06d792db8cc5ac5aba9a5b4", Dir: "subdir"},
"git://example.com/repo/flake?ref=unstable&rev=e486d8d40e626a20e06d792db8cc5ac5aba9a5b4&dir=subdir": {Type: TypeGit, URL: "git://example.com/repo/flake?dir=subdir", Ref: "unstable", Rev: "e486d8d40e626a20e06d792db8cc5ac5aba9a5b4", Dir: "subdir"},
"git://example.com/repo/flake?ref=unstable&rev=e486d8d40e626a20e06d792db8cc5ac5aba9a5b4&dir=subdir&lastModified=1734435836&narHash=sha256-kMBQ5PRiFLagltK0sH%2B08aiNt3zGERC2297iB6vrvlU%3D": {Type: TypeGit, URL: "git://example.com/repo/flake?dir=subdir&lastModified=1734435836&narHash=sha256-kMBQ5PRiFLagltK0sH%2B08aiNt3zGERC2297iB6vrvlU%3D", Ref: "unstable", Rev: "e486d8d40e626a20e06d792db8cc5ac5aba9a5b4", Dir: "subdir"},

// Tarball references.
"tarball+http://example.com/flake": {Type: TypeTarball, URL: "http://example.com/flake"},
Expand All @@ -99,14 +102,15 @@ func TestParseFlakeRef(t *testing.T) {
"http://example.com/flake.tar.bz2": {Type: TypeTarball, URL: "http://example.com/flake.tar.bz2"},
"http://example.com/flake.tar.zst": {Type: TypeTarball, URL: "http://example.com/flake.tar.zst"},
"http://example.com/flake.tar?dir=subdir": {Type: TypeTarball, URL: "http://example.com/flake.tar?dir=subdir", Dir: "subdir"},
"file:///flake.zip": {Type: TypeTarball, URL: "file:///flake.zip"},
"file:///flake.tar": {Type: TypeTarball, URL: "file:///flake.tar"},
"file:///flake.tgz": {Type: TypeTarball, URL: "file:///flake.tgz"},
"file:///flake.tar.gz": {Type: TypeTarball, URL: "file:///flake.tar.gz"},
"file:///flake.tar.xz": {Type: TypeTarball, URL: "file:///flake.tar.xz"},
"file:///flake.tar.bz2": {Type: TypeTarball, URL: "file:///flake.tar.bz2"},
"file:///flake.tar.zst": {Type: TypeTarball, URL: "file:///flake.tar.zst"},
"file:///flake.tar?dir=subdir": {Type: TypeTarball, URL: "file:///flake.tar?dir=subdir", Dir: "subdir"},
"http://example.com/flake.tar?dir=subdir&lastModified=1734435836&narHash=sha256-kMBQ5PRiFLagltK0sH%2B08aiNt3zGERC2297iB6vrvlU%3D": {Type: TypeTarball, URL: "http://example.com/flake.tar?dir=subdir", Dir: "subdir", LastModified: 1734435836, NARHash: "sha256-kMBQ5PRiFLagltK0sH+08aiNt3zGERC2297iB6vrvlU="},
"file:///flake.zip": {Type: TypeTarball, URL: "file:///flake.zip"},
"file:///flake.tar": {Type: TypeTarball, URL: "file:///flake.tar"},
"file:///flake.tgz": {Type: TypeTarball, URL: "file:///flake.tgz"},
"file:///flake.tar.gz": {Type: TypeTarball, URL: "file:///flake.tar.gz"},
"file:///flake.tar.xz": {Type: TypeTarball, URL: "file:///flake.tar.xz"},
"file:///flake.tar.bz2": {Type: TypeTarball, URL: "file:///flake.tar.bz2"},
"file:///flake.tar.zst": {Type: TypeTarball, URL: "file:///flake.tar.zst"},
"file:///flake.tar?dir=subdir": {Type: TypeTarball, URL: "file:///flake.tar?dir=subdir", Dir: "subdir"},

// File URL references.
"file+file:///flake": {Type: TypeFile, URL: "file:///flake"},
Expand Down Expand Up @@ -403,5 +407,5 @@ func TestBuildQueryString(t *testing.T) {
// directives).
var elems []string
elems = append(elems, "1")
buildQueryString(elems...)
buildQueryString(nil, elems...)
}
Loading