Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
2 changes: 1 addition & 1 deletion pkg/nar/hash.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 + `)$`)
)
Expand Down
40 changes: 40 additions & 0 deletions pkg/nar/url.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"net/url"
"path/filepath"
"regexp"
"strings"

Expand Down Expand Up @@ -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,
}
}
87 changes: 87 additions & 0 deletions pkg/nar/url_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down