diff --git a/pkg/nar/hash.go b/pkg/nar/hash.go index 4784acfd..38fe8a2e 100644 --- a/pkg/nar/hash.go +++ b/pkg/nar/hash.go @@ -11,7 +11,7 @@ var ( // narHashPattern defines the valid characters for a nar hash. //nolint:gochecknoglobals // This is used in other regexes to ensure they validate the same thing. - narHashPattern = `[a-z0-9]+` + narHashPattern = `[a-z0-9_-]+` narHashRegexp = regexp.MustCompile(`^(` + narHashPattern + `)$`) ) diff --git a/pkg/nar/url.go b/pkg/nar/url.go index 3db3d1e4..57cb2b2a 100644 --- a/pkg/nar/url.go +++ b/pkg/nar/url.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "net/url" + "path/filepath" "regexp" "strings" @@ -100,3 +101,42 @@ func (u URL) pathWithCompression() string { return p } + +// Normalize returns a new URL with the narinfo hash prefix trimmed from the Hash. +// nix-serve serves NAR URLs with the narinfo hash as a prefix (e.g., "narinfo-hash-actual-hash"). +// This method removes that prefix to standardize the hash for storage. +func (u URL) Normalize() URL { + hash := u.Hash + + // Find the first separator ('-' or '_'). + idx := strings.IndexAny(hash, "-_") + + // If a separator is found after the first character. + if idx > 0 { + prefix := hash[:idx] + suffix := hash[idx+1:] + + // A narinfo hash prefix is typically 32 characters long. This is a strong signal. + // We check this and ensure the suffix is not empty. + if len(prefix) == 32 && len(suffix) > 0 { + hash = suffix + } + } + + // Sanitize the hash to prevent path traversal. + // Even though ParseURL validates the hash, URL is a public struct + // and Normalize could be called on a manually constructed URL. + cleanedHash := filepath.Clean(hash) + if strings.Contains(cleanedHash, "..") || strings.HasPrefix(cleanedHash, "/") { + // If the cleaned hash is still invalid, we return the original URL + // to avoid potentially breaking something that might be valid in some context, + // but storage layers will still validate it using ToFilePath(). + return u + } + + return URL{ + Hash: cleanedHash, + Compression: u.Compression, + Query: u.Query, + } +} diff --git a/pkg/nar/url_test.go b/pkg/nar/url_test.go index afda4fb9..bb79ea06 100644 --- a/pkg/nar/url_test.go +++ b/pkg/nar/url_test.go @@ -132,6 +132,93 @@ func TestParseURL(t *testing.T) { } } +func TestNormalize(t *testing.T) { + t.Parallel() + + tests := []struct { + input nar.URL + output nar.URL + }{ + { + input: nar.URL{ + Hash: "09xizkfyvigl5fqs0dhkn46nghfwwijbpdzzl4zg6kx90prjmsg0", + Compression: nar.CompressionTypeNone, + Query: url.Values{}, + }, + output: nar.URL{ + Hash: "09xizkfyvigl5fqs0dhkn46nghfwwijbpdzzl4zg6kx90prjmsg0", + Compression: nar.CompressionTypeNone, + Query: url.Values{}, + }, + }, + { + input: nar.URL{ + Hash: "c12lxpykv6sld7a0sakcnr3y0la70x8w-09xizkfyvigl5fqs0dhkn46nghfwwijbpdzzl4zg6kx90prjmsg0", + Compression: nar.CompressionTypeNone, + Query: url.Values{}, + }, + output: nar.URL{ + Hash: "09xizkfyvigl5fqs0dhkn46nghfwwijbpdzzl4zg6kx90prjmsg0", + Compression: nar.CompressionTypeNone, + Query: url.Values{}, + }, + }, + { + input: nar.URL{ + Hash: "c12lxpykv6sld7a0sakcnr3y0la70x8w_09xizkfyvigl5fqs0dhkn46nghfwwijbpdzzl4zg6kx90prjmsg0", + Compression: nar.CompressionTypeZstd, + Query: url.Values(map[string][]string{"hash": {"123"}}), + }, + output: nar.URL{ + Hash: "09xizkfyvigl5fqs0dhkn46nghfwwijbpdzzl4zg6kx90prjmsg0", + Compression: nar.CompressionTypeZstd, + Query: url.Values(map[string][]string{"hash": {"123"}}), + }, + }, + { + // Valid hash with separator but no prefix + input: nar.URL{ + Hash: "my-hash", + }, + output: nar.URL{ + Hash: "my-hash", + }, + }, + { + // Valid prefix and multiple separators in the suffix + input: nar.URL{ + Hash: "c12lxpykv6sld7a0sakcnr3y0la70x8w-part1-part2", + }, + output: nar.URL{ + Hash: "part1-part2", + }, + }, + { + // Potential path traversal attempt in the hash (should remain unchanged or be sanitized) + input: nar.URL{ + Hash: "c12lxpykv6sld7a0sakcnr3y0la70x8w-../../etc/passwd", + }, + output: nar.URL{ + Hash: "c12lxpykv6sld7a0sakcnr3y0la70x8w-../../etc/passwd", + }, + }, + } + + for _, test := range tests { + tname := fmt.Sprintf( + "Normalize(%q) -> %q", + test.input.Hash, + test.output.Hash, + ) + t.Run(tname, func(t *testing.T) { + t.Parallel() + + result := test.input.Normalize() + assert.Equal(t, test.output, result) + }) + } +} + func TestJoinURL(t *testing.T) { t.Parallel()