diff --git a/pkg/narinfo/hash.go b/pkg/narinfo/hash.go index 401f0882..858f59ce 100644 --- a/pkg/narinfo/hash.go +++ b/pkg/narinfo/hash.go @@ -5,19 +5,24 @@ import ( "regexp" ) +// narInfoHashPattern defines the valid characters for a Nix32 encoded hash. +// Nix32 uses a 32-character alphabet excluding 'e', 'o', 'u', and 't'. +// Valid characters: 0-9, a-d, f-n, p-s, v-z +// Hashes must be exactly 32 characters long. +const HashPattern = `[0-9a-df-np-sv-z]{32}` + var ( // ErrInvalidHash is returned if the hash is invalid. ErrInvalidHash = errors.New("invalid narinfo hash") - // narInfoHashPattern defines the valid characters for a narinfo hash. - //nolint:gochecknoglobals // This is used in other regexes to ensure they validate the same thing. - narInfoHashPattern = `[a-z0-9]+` - // hashRegexp is used to validate hashes. - hashRegexp = regexp.MustCompile(`^` + narInfoHashPattern + `$`) + hashRegexp = regexp.MustCompile(`^` + HashPattern + `$`) ) -// ValidateHash validates the given hash. +// ValidateHash validates the given hash according to Nix32 encoding requirements. +// A valid hash must: +// - Be exactly 32 characters long +// - Contain only characters from the Nix32 alphabet ('0'-'9', 'a'-'z' excluding 'e', 'o', 'u', 't'). func ValidateHash(hash string) error { if !hashRegexp.MatchString(hash) { return ErrInvalidHash diff --git a/pkg/narinfo/hash_test.go b/pkg/narinfo/hash_test.go new file mode 100644 index 00000000..c3208490 --- /dev/null +++ b/pkg/narinfo/hash_test.go @@ -0,0 +1,131 @@ +package narinfo_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/kalbasit/ncps/pkg/narinfo" +) + +func TestValidateHash(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + hash string + shouldErr bool + }{ + // Valid Nix32 hashes (32 characters from the allowed alphabet) + { + name: "valid hash with all allowed characters", + hash: "n5glp21rsz314qssw9fbvfswgy3kc68f", + shouldErr: false, + }, + { + name: "valid hash with numbers", + hash: "01234567890123456789012345678901", + shouldErr: false, + }, + { + name: "valid hash with mixed characters", + hash: "abcdfghijklmnpqrsvwxyzabcdfghijk", + shouldErr: false, + }, + + // Invalid: contains forbidden letters (e, o, u, t) + { + name: "invalid hash contains 'e'", + hash: "n5glp21rsz314qssw9fbvfswgy3kc68e", + shouldErr: true, + }, + { + name: "invalid hash contains 'o'", + hash: "n5glp21rsz314qssw9fbvfswgy3kc68o", + shouldErr: true, + }, + { + name: "invalid hash contains 'u'", + hash: "n5glp21rsz314qssw9fbvfswgy3kc68u", + shouldErr: true, + }, + { + name: "invalid hash contains 't'", + hash: "n5glp21rsz314qssw9fbvfswgy3kc68t", + shouldErr: true, + }, + + // Invalid: contains uppercase letters + { + name: "invalid hash contains uppercase", + hash: "N5glp21rsz314qssw9fbvfswgy3kc68f", + shouldErr: true, + }, + { + name: "invalid hash all uppercase", + hash: "N5GLP21RSZ314QSSW9FBVFSWGY3KC68F", + shouldErr: true, + }, + + // Invalid: contains special characters + { + name: "invalid hash with exclamation mark", + hash: "n5glp21rsz314qssw9fbvfswgy3kc68!", + shouldErr: true, + }, + { + name: "invalid hash with hyphen", + hash: "n5glp21rsz314qssw9fbvfswgy3kc-8f", + shouldErr: true, + }, + { + name: "invalid hash with underscore", + hash: "n5glp21rsz314qssw9fbvfswgy3kc_8f", + shouldErr: true, + }, + { + name: "invalid hash with space", + hash: "n5glp21rsz314qssw9fbvfswgy3kc 8f", + shouldErr: true, + }, + + // Invalid: wrong length + { + name: "invalid hash too short", + hash: "n5glp21rsz314qssw9fbvfswgy3kc68", + shouldErr: true, + }, + { + name: "invalid hash too long", + hash: "n5glp21rsz314qssw9fbvfswgy3kc68ff", + shouldErr: true, + }, + + // Invalid: empty string + { + name: "invalid hash empty string", + hash: "", + shouldErr: true, + }, + + // Invalid: only one character + { + name: "invalid hash single character", + hash: "a", + shouldErr: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + err := narinfo.ValidateHash(test.hash) + if test.shouldErr { + assert.ErrorIs(t, err, narinfo.ErrInvalidHash) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/pkg/ncps/migrate_narinfo_test.go b/pkg/ncps/migrate_narinfo_test.go index 5d34f7b4..5ce97caf 100644 --- a/pkg/ncps/migrate_narinfo_test.go +++ b/pkg/ncps/migrate_narinfo_test.go @@ -839,8 +839,8 @@ func testMigrateNarInfoLargeNarInfo(factory migrationFactory) func(*testing.T) { const numSignatures = 50 - hash := "largenarinfo1234567890abcdef1234567890abcdef" - narHash := "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" + hash := "0a90gw9sdyz3680wfncd5xf0qg6zh27w" + narHash := "024wilh5y46xqqjnwp159s13kgvsh8zfr6g6znb8ix2vlyf61rwp" // Build references string var referencesBuilder strings.Builder diff --git a/pkg/storage/local/local_test.go b/pkg/storage/local/local_test.go index c6bebead..b88fe613 100644 --- a/pkg/storage/local/local_test.go +++ b/pkg/storage/local/local_test.go @@ -8,26 +8,28 @@ import ( "strings" "testing" - "github.com/nix-community/go-nix/pkg/narinfo" "github.com/nix-community/go-nix/pkg/narinfo/signature" "github.com/rs/zerolog" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + narinfopkg "github.com/nix-community/go-nix/pkg/narinfo" + "github.com/kalbasit/ncps/pkg/nar" + "github.com/kalbasit/ncps/pkg/narinfo" "github.com/kalbasit/ncps/pkg/storage" "github.com/kalbasit/ncps/pkg/storage/local" "github.com/kalbasit/ncps/testdata" ) const ( - cacheName = "cache.example.com" - testHashABC = "abc123" - testHashABC456 = "abc456" - testHashACD = "acd456" - testHashXYZ = "xyz789" - testHashXYZ123 = "xyz123" - testHashXYZ456 = "xyz456" + cacheName = "cache.example.com" + narInfoHash1 = "0amzzlz5w7ihknr59cn0q56pvp17bqqz" + narInfoHash2 = "0b04gz1zzpapkni0yib4jk3xb6a7rmkh" + narInfoHash3 = "0bz5d30q8f28yz8yhf65aya4jbcxn33n" + narHash1 = "1s8p1kgdms8rmxkq24q51wc7zpn0aqcwgzvc473v9cii7z2qyxq0" + narHash2 = "123x3zvy8mfbxw8c9i7pqh2cmcya3g6w8y8yhldp5s39685dhsx4" + narHash3 = "00ji9synj1r6h6sjw27wwv8fw98myxsg92q5ma1pvrbmh451kc27" ) func TestNew(t *testing.T) { @@ -407,7 +409,7 @@ func TestPutNarInfo(t *testing.T) { s, err := local.New(ctx, dir) require.NoError(t, err) - ni1, err := narinfo.Parse(strings.NewReader(testdata.Nar1.NarInfoText)) + ni1, err := narinfopkg.Parse(strings.NewReader(testdata.Nar1.NarInfoText)) require.NoError(t, err) require.NoError(t, s.PutNarInfo(ctx, testdata.Nar1.NarInfoHash, ni1)) @@ -426,7 +428,7 @@ func TestPutNarInfo(t *testing.T) { defer ni2c.Close() - ni2, err := narinfo.Parse(ni2c) + ni2, err := narinfopkg.Parse(ni2c) require.NoError(t, err) assert.Equal(t, @@ -460,7 +462,7 @@ func TestPutNarInfo(t *testing.T) { err = os.WriteFile(narInfoPath, []byte(testdata.Nar1.NarInfoText), 0o400) require.NoError(t, err) - ni, err := narinfo.Parse(strings.NewReader(testdata.Nar1.NarInfoText)) + ni, err := narinfopkg.Parse(strings.NewReader(testdata.Nar1.NarInfoText)) require.NoError(t, err) err = s.PutNarInfo(ctx, testdata.Nar1.NarInfoHash, ni) @@ -840,17 +842,13 @@ func TestDeleteNarInfo_RemovesEmptyParentDirectories(t *testing.T) { s, err := local.New(ctx, dir) require.NoError(t, err) - // Use a hash that will create a unique directory structure: abc123 - // This creates: store/narinfo/a/ab/abc123.narinfo - hash := testHashABC - narInfoPath := filepath.Join( - dir, - "store", - "narinfo", - "a", - "ab", - hash+".narinfo", - ) + // Use a hash that will create a unique directory structure (narInfoHash1) + // The actual path is computed by narinfo.FilePath() + hash := narInfoHash1 + relPath, err := narinfo.FilePath(hash) + require.NoError(t, err) + + narInfoPath := filepath.Join(dir, "store", "narinfo", relPath) require.NoError(t, os.MkdirAll(filepath.Dir(narInfoPath), 0o700)) require.NoError(t, os.WriteFile(narInfoPath, []byte("test"), 0o400)) @@ -861,14 +859,10 @@ func TestDeleteNarInfo_RemovesEmptyParentDirectories(t *testing.T) { // Verify file is deleted assert.NoFileExists(t, narInfoPath) - // Verify ab/ directory is removed - assert.NoDirExists(t, filepath.Join(dir, "store", "narinfo", "a", "ab")) - - // Verify a/ directory is removed - assert.NoDirExists(t, filepath.Join(dir, "store", "narinfo", "a")) - - // Verify narinfo/ directory is removed - assert.NoDirExists(t, filepath.Join(dir, "store", "narinfo")) + // Verify directory structure is removed + relDir := filepath.Dir(relPath) + assert.NoDirExists(t, filepath.Join(dir, "store", "narinfo", relDir)) + assert.NoDirExists(t, filepath.Join(dir, "store", "narinfo", filepath.Dir(relDir))) } func TestDeleteNarInfo_PreservesNonEmptyDirectories(t *testing.T) { @@ -885,28 +879,22 @@ func TestDeleteNarInfo_PreservesNonEmptyDirectories(t *testing.T) { require.NoError(t, err) // Create two narinfo files in the same level-2 directory - // abc123 and abc456 both go into a/ab/ - hash1 := testHashABC - hash2 := testHashABC456 - - narInfoPath1 := filepath.Join( - dir, - "store", - "narinfo", - "a", - "ab", - hash1+".narinfo", - ) - narInfoPath2 := filepath.Join( - dir, - "store", - "narinfo", - "a", - "ab", - hash2+".narinfo", - ) + // narInfoHash1 and narInfoHash2 will be placed in the same level-2 directory + hash1 := narInfoHash1 + hash2 := narInfoHash2 + + relPath1, err := narinfo.FilePath(hash1) + require.NoError(t, err) + + narInfoPath1 := filepath.Join(dir, "store", "narinfo", relPath1) + + relPath2, err := narinfo.FilePath(hash2) + require.NoError(t, err) + + narInfoPath2 := filepath.Join(dir, "store", "narinfo", relPath2) require.NoError(t, os.MkdirAll(filepath.Dir(narInfoPath1), 0o700)) + require.NoError(t, os.MkdirAll(filepath.Dir(narInfoPath2), 0o700)) require.NoError(t, os.WriteFile(narInfoPath1, []byte("test1"), 0o400)) require.NoError(t, os.WriteFile(narInfoPath2, []byte("test2"), 0o400)) @@ -919,14 +907,10 @@ func TestDeleteNarInfo_PreservesNonEmptyDirectories(t *testing.T) { // Verify the other file still exists assert.FileExists(t, narInfoPath2) - // Verify ab/ directory still exists (contains abc456.narinfo) - assert.DirExists(t, filepath.Join(dir, "store", "narinfo", "a", "ab")) - - // Verify a/ directory still exists - assert.DirExists(t, filepath.Join(dir, "store", "narinfo", "a")) - - // Verify narinfo/ directory still exists - assert.DirExists(t, filepath.Join(dir, "store", "narinfo")) + // Verify directory structure still exists (contains narinfo for hash2) + relDir2 := filepath.Dir(relPath2) + assert.DirExists(t, filepath.Join(dir, "store", "narinfo", relDir2)) + assert.DirExists(t, filepath.Join(dir, "store", "narinfo", filepath.Dir(relDir2))) } func TestDeleteNarInfo_PartialCleanup(t *testing.T) { @@ -943,50 +927,42 @@ func TestDeleteNarInfo_PartialCleanup(t *testing.T) { require.NoError(t, err) // Create narinfo files in multiple level-2 dirs under same level-1 - // abc goes into a/ab/ - // acd goes into a/ac/ - hashAB := testHashABC - hashAC := testHashACD - - narInfoPathAB := filepath.Join( - dir, - "store", - "narinfo", - "a", - "ab", - hashAB+".narinfo", - ) - narInfoPathAC := filepath.Join( - dir, - "store", - "narinfo", - "a", - "ac", - hashAC+".narinfo", - ) + // narInfoHash1 goes into one level-2 dir + // narInfoHash3 goes into a different level-2 dir under same level-1 + hashAB := narInfoHash1 + hashAC := narInfoHash3 + + relPathAB, err := narinfo.FilePath(hashAB) + require.NoError(t, err) + + narInfoPathAB := filepath.Join(dir, "store", "narinfo", relPathAB) + + relPathAC, err := narinfo.FilePath(hashAC) + require.NoError(t, err) + + narInfoPathAC := filepath.Join(dir, "store", "narinfo", relPathAC) require.NoError(t, os.MkdirAll(filepath.Dir(narInfoPathAB), 0o700)) require.NoError(t, os.MkdirAll(filepath.Dir(narInfoPathAC), 0o700)) require.NoError(t, os.WriteFile(narInfoPathAB, []byte("test1"), 0o400)) require.NoError(t, os.WriteFile(narInfoPathAC, []byte("test2"), 0o400)) - // Delete abc123 + // Delete the first narinfo file (hashAB) require.NoError(t, s.DeleteNarInfo(ctx, hashAB)) // Verify deleted file is gone assert.NoFileExists(t, narInfoPathAB) - // Verify ab/ is removed (was empty) - assert.NoDirExists(t, filepath.Join(dir, "store", "narinfo", "a", "ab")) - - // Verify ac/ still exists - assert.DirExists(t, filepath.Join(dir, "store", "narinfo", "a", "ac")) + // Verify ab directory is removed (was empty) + relDirAB := filepath.Dir(relPathAB) + assert.NoDirExists(t, filepath.Join(dir, "store", "narinfo", relDirAB)) - // Verify a/ still exists (contains ac/) - assert.DirExists(t, filepath.Join(dir, "store", "narinfo", "a")) + // Verify ac directory still exists (contains hash for hashAC) + relDirAC := filepath.Dir(relPathAC) + assert.DirExists(t, filepath.Join(dir, "store", "narinfo", relDirAC)) - // Verify narinfo/ still exists - assert.DirExists(t, filepath.Join(dir, "store", "narinfo")) + // Verify level-1 directory still exists (contains ac/) + assert.DirExists(t, filepath.Join(dir, "store", "narinfo", filepath.Dir(relDirAC))) } func TestDeleteNar_RemovesEmptyParentDirectories(t *testing.T) { @@ -1002,17 +978,13 @@ func TestDeleteNar_RemovesEmptyParentDirectories(t *testing.T) { s, err := local.New(ctx, dir) require.NoError(t, err) - // Use a hash that will create a unique directory structure: xyz789 - // This creates: store/nar/x/xy/xyz789.nar.xz - hash := testHashXYZ - narPath := filepath.Join( - dir, - "store", - "nar", - "x", - "xy", - hash+".nar.xz", - ) + // Use a hash that will create a unique directory structure (narHash1) + // The actual path is computed by nar.FilePath() + hash := narHash1 + relPath, err := nar.FilePath(hash, nar.CompressionTypeXz.ToFileExtension()) + require.NoError(t, err) + + narPath := filepath.Join(dir, "store", "nar", relPath) require.NoError(t, os.MkdirAll(filepath.Dir(narPath), 0o700)) require.NoError(t, os.WriteFile(narPath, []byte("test"), 0o400)) @@ -1028,14 +1000,10 @@ func TestDeleteNar_RemovesEmptyParentDirectories(t *testing.T) { // Verify file is deleted assert.NoFileExists(t, narPath) - // Verify xy/ directory is removed - assert.NoDirExists(t, filepath.Join(dir, "store", "nar", "x", "xy")) - - // Verify x/ directory is removed - assert.NoDirExists(t, filepath.Join(dir, "store", "nar", "x")) - - // Verify nar/ directory is removed - assert.NoDirExists(t, filepath.Join(dir, "store", "nar")) + // Verify directory structure is removed + relDir := filepath.Dir(relPath) + assert.NoDirExists(t, filepath.Join(dir, "store", "narinfo", relDir)) + assert.NoDirExists(t, filepath.Join(dir, "store", "narinfo", filepath.Dir(relDir))) } func TestDeleteNar_PreservesNonEmptyDirectories(t *testing.T) { @@ -1052,28 +1020,22 @@ func TestDeleteNar_PreservesNonEmptyDirectories(t *testing.T) { require.NoError(t, err) // Create two nar files in the same level-2 directory - // xyz123 and xyz456 both go into x/xy/ - hash1 := testHashXYZ123 - hash2 := testHashXYZ456 - - narPath1 := filepath.Join( - dir, - "store", - "nar", - "x", - "xy", - hash1+".nar.xz", - ) - narPath2 := filepath.Join( - dir, - "store", - "nar", - "x", - "xy", - hash2+".nar.zst", - ) + // narHash2 and narHash3 will be placed in the same level-2 directory + hash1 := narHash2 + hash2 := narHash3 + + relPath1, err := nar.FilePath(hash1, nar.CompressionTypeXz.ToFileExtension()) + require.NoError(t, err) + + narPath1 := filepath.Join(dir, "store", "nar", relPath1) + + relPath2, err := nar.FilePath(hash2, nar.CompressionTypeZstd.ToFileExtension()) + require.NoError(t, err) + + narPath2 := filepath.Join(dir, "store", "nar", relPath2) require.NoError(t, os.MkdirAll(filepath.Dir(narPath1), 0o700)) + require.NoError(t, os.MkdirAll(filepath.Dir(narPath2), 0o700)) require.NoError(t, os.WriteFile(narPath1, []byte("test1"), 0o400)) require.NoError(t, os.WriteFile(narPath2, []byte("test2"), 0o400)) @@ -1091,14 +1053,10 @@ func TestDeleteNar_PreservesNonEmptyDirectories(t *testing.T) { // Verify the other file still exists assert.FileExists(t, narPath2) - // Verify xy/ directory still exists (contains xyz456.nar.zst) - assert.DirExists(t, filepath.Join(dir, "store", "nar", "x", "xy")) - - // Verify x/ directory still exists - assert.DirExists(t, filepath.Join(dir, "store", "nar", "x")) - - // Verify nar/ directory still exists - assert.DirExists(t, filepath.Join(dir, "store", "nar")) + // Verify directory structure still exists (contains narpath for hash2) + relDir2 := filepath.Dir(relPath2) + assert.DirExists(t, filepath.Join(dir, "store", "nar", relDir2)) + assert.DirExists(t, filepath.Join(dir, "store", "nar", filepath.Dir(relDir2))) } func newContext() context.Context { diff --git a/pkg/storage/s3/s3_test.go b/pkg/storage/s3/s3_test.go index ae4082f7..928bde5a 100644 --- a/pkg/storage/s3/s3_test.go +++ b/pkg/storage/s3/s3_test.go @@ -584,7 +584,7 @@ func TestDeleteNarInfo_ErrorPaths(t *testing.T) { store, err := storage_s3.New(ctx, cfgWithMock) require.NoError(t, err) - err = store.DeleteNarInfo(ctx, "hash") + err = store.DeleteNarInfo(ctx, "0a90gw9sdyz3680wfncd5xf0qg6zh27w") require.Error(t, err) assert.Contains(t, err.Error(), "error checking if narinfo exists") }) @@ -615,7 +615,7 @@ func TestDeleteNarInfo_ErrorPaths(t *testing.T) { store, err := storage_s3.New(ctx, cfgWithMock) require.NoError(t, err) - err = store.DeleteNarInfo(ctx, "hash") + err = store.DeleteNarInfo(ctx, "0a90gw9sdyz3680wfncd5xf0qg6zh27w") require.Error(t, err) assert.Contains(t, err.Error(), "error deleting narinfo from S3") }) @@ -674,7 +674,7 @@ func TestNarInfo_ErrorPaths(t *testing.T) { store, err := storage_s3.New(ctx, cfgWithMock) require.NoError(t, err) - _, err = store.GetNarInfo(ctx, "nonexistent") + _, err = store.GetNarInfo(ctx, "0a90gw9sdyz3680wfncd5xf0qg6zh27w") assert.ErrorIs(t, err, storage.ErrNotFound) }) @@ -706,7 +706,7 @@ func TestNarInfo_ErrorPaths(t *testing.T) { ni, err := narinfo.Parse(strings.NewReader(testdata.Nar1.NarInfoText)) require.NoError(t, err) - err = store.PutNarInfo(ctx, "hash", ni) + err = store.PutNarInfo(ctx, testdata.Nar1.NarInfoHash, ni) require.Error(t, err) assert.Contains(t, err.Error(), "put failed") })