Skip to content
Open
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
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ go 1.25.5

require (
github.com/XSAM/otelsql v0.41.0
github.com/cespare/xxhash/v2 v2.3.0
github.com/go-chi/chi/v5 v5.2.4
github.com/go-redsync/redsync/v4 v4.15.0
github.com/go-sql-driver/mysql v1.9.3
Expand Down Expand Up @@ -49,7 +50,6 @@ require (
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
Expand Down Expand Up @@ -77,6 +77,7 @@ require (
github.com/prometheus/otlptranslator v1.0.0 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/tinylib/msgp v1.6.1 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 // indirect
Expand Down
6 changes: 2 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,8 @@ github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
Expand Down Expand Up @@ -212,12 +214,8 @@ golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
Expand Down
2 changes: 1 addition & 1 deletion nix/packages/ncps/default.nix
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

version = if tag != "" then tag else rev;

vendorHash = "sha256-nnt4HIG4Fs7RhHjVb7mYJ39UgvFKc46Cu42cURMmr1s=";
vendorHash = "sha256-VxIMr0QgsvkZpTe+fvGNF+cT3xfAa0m22q31z+Rf+Ds=";
in
pkgs.buildGoModule {
name = "ncps-${shortRev}";
Expand Down
85 changes: 85 additions & 0 deletions pkg/nixcacheindex/base32.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package nixcacheindex

import (
"fmt"
"math/big"
"strings"
)

const (
// Alphabet is the Nix Base32 alphabet.
// Note: 'e', 'o', 'u', 't' are excluded to avoid offensive words.
Alphabet = "0123456789abcdfghijklmnpqrsvwxyz"

// HashLength is the length of a Nix store path hash in base32 characters.
HashLength = 32

// HashBits is the number of bits in a full store path hash (160).
HashBits = 160
)

//nolint:gochecknoglobals
var alphabetMap map[rune]int64

//nolint:gochecknoinits
func init() {
alphabetMap = make(map[rune]int64)
for i, c := range Alphabet {
alphabetMap[c] = int64(i)
}
}

// ParseHash parses a 32-character Nix base32 string into a big.Int.
// The string is interpreted as a Big-Endian 160-bit unsigned integer.
// This means the first character is the most significant.
func ParseHash(s string) (*big.Int, error) {
if len(s) != HashLength {
return nil, fmt.Errorf("%w: expected %d, got %d", ErrInvalidHashLength, HashLength, len(s))
}

result := new(big.Int)

for _, char := range s {
val, ok := alphabetMap[char]
if !ok {
return nil, fmt.Errorf("%w: %q", ErrInvalidHashChar, char)
}

// result = (result << 5) | val
result.Lsh(result, 5)
result.Or(result, big.NewInt(val))
}

return result, nil
}

// FormatHash formats a big.Int into a 32-character Nix base32 string.
// The integer is treated as Big-Endian.
func FormatHash(i *big.Int) string {
if i == nil {
return strings.Repeat("0", HashLength)
}

// Work with a copy since we'll act on it
n := new(big.Int).Set(i)

// Create a buffer for 32 characters
chars := make([]byte, HashLength)

// Extract 5 bits at a time from right to left (least significant first)
// But we fill the string from right to left too, so it matches Big-Endian
// i.e. last 5 bits of integer -> last char of string

mask := big.NewInt(0x1f) // 5 ones

for idx := HashLength - 1; idx >= 0; idx-- {
// val = n & 0x1f
val := new(big.Int).And(n, mask)
chars[idx] = Alphabet[val.Int64()]

// n = n >> 5
n.Rsh(n, 5)
}

return string(chars)
}
101 changes: 101 additions & 0 deletions pkg/nixcacheindex/base32_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package nixcacheindex_test

import (
"math/big"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/kalbasit/ncps/pkg/nixcacheindex"
)

func TestParseHash(t *testing.T) {
t.Parallel()

tests := []struct {
name string
input string
want *big.Int
wantErr bool
}{
{
name: "Zero hash",
input: "00000000000000000000000000000000",
want: big.NewInt(0),
},
{
name: "One (last bit set)",
input: "00000000000000000000000000000001",
want: big.NewInt(1),
},
{
// RFC Example: "100...000" maps to 2^155
// First char '1' (value 1) is most significant 5 bits
name: "2^155 (first bit set)",
input: "10000000000000000000000000000000",
want: new(big.Int).Exp(big.NewInt(2), big.NewInt(155), nil),
},
{
// RFC Example: "010...000" maps to 2^150
// Second char '1' (value 1) shifted left by (32-2)*5 = 30*5 = 150
name: "2^150 (second char set)",
input: "01000000000000000000000000000000",
want: new(big.Int).Exp(big.NewInt(2), big.NewInt(150), nil),
},
{
// RFC Example had a typo saying g=16, but g is 15 in the 0-indexed alphabet.
// 0-9 (10), a-d (4), f (1), g (1) -> 10+4+1 = 15.
name: "Max single char (g)",
input: "g0000000000000000000000000000000",
want: new(big.Int).Mul(big.NewInt(15), new(big.Int).Exp(big.NewInt(2), big.NewInt(155), nil)),
},
{
// Max value: all z's
name: "Max value",
input: "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
want: new(big.Int).Sub(new(big.Int).Exp(big.NewInt(2), big.NewInt(160), nil), big.NewInt(1)),
},
{
name: "Invalid length (short)",
input: "000",
wantErr: true,
},
{
name: "Invalid length (long)",
input: "000000000000000000000000000000000",
wantErr: true,
},
{
name: "Invalid character",
input: "0000000000000000000000000000000e", // 'e' is not in alphabet
wantErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

got, err := nixcacheindex.ParseHash(tt.input)
if tt.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
assert.Equal(t, 0, tt.want.Cmp(got), "expected %s, got %s", tt.want, got)

// Verify round trip
formatted := nixcacheindex.FormatHash(got)
assert.Equal(t, tt.input, formatted, "FormatHash mismatch")
}
})
}
}

func TestFormatHash(t *testing.T) {
t.Parallel()

// Focus on round-trip property for random big ints within range
// But since we covered round trip in TestParseHash, we just add explicit nil check
assert.Equal(t, "00000000000000000000000000000000", nixcacheindex.FormatHash(nil))
}
Loading