Skip to content

Commit 1b37829

Browse files
authored
Merge pull request #302 from Warp-net/develop
Develop
2 parents b16ba22 + 27c500d commit 1b37829

12 files changed

Lines changed: 216 additions & 143 deletions

File tree

security/challenge.go

Lines changed: 31 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -25,19 +25,18 @@ resulting from the use or misuse of this software.
2525
package security
2626

2727
import (
28-
"bufio"
2928
"errors"
3029
"fmt"
3130
"io/fs"
3231
"math/rand/v2" //#nosec
33-
"path/filepath"
32+
"path"
3433
"sort"
3534
"strconv"
3635
)
3736

3837
type SampleLocation struct {
3938
DirStack []int // every index is level and value is dir num
40-
FileStack []int // file index, line index, left line border, right line border
39+
FileStack []int // file index
4140
}
4241

4342
func ResolveChallenge(codebase FileSystem, location SampleLocation, nonce int64) ([]byte, error) {
@@ -60,10 +59,7 @@ func GenerateChallenge(codebase FileSystem, nonce int64) ([]byte, SampleLocation
6059

6160
var (
6261
ErrNoSampleFiles = errors.New("challenge: no usable files or subdirectories")
63-
ErrEmptySampleLine = errors.New("empty sample line found")
64-
ErrNoNonEmptySampleLines = errors.New("no non-empty lines found")
6562
ErrSampleIndexOutOfBounds = errors.New("sample index out of bounds")
66-
ErrInvalidSubstringBounds = errors.New("invalid substring bounds")
6763
)
6864

6965
func generateSample(codebase FileSystem, dir string, dirStack []int) (_ string, result SampleLocation, err error) {
@@ -85,79 +81,37 @@ func generateSample(codebase FileSystem, dir string, dirStack []int) (_ string,
8581
}
8682
}
8783

88-
perm := rand.Perm(len(dirs))
89-
for _, dirIndex := range perm {
90-
selectedDir := dirs[dirIndex]
91-
subPath := filepath.Join(dir, selectedDir.Name())
92-
93-
subEntries, err := fs.ReadDir(codebase, subPath)
94-
if err != nil {
95-
continue
96-
}
97-
98-
var fileCount int
99-
for _, e := range subEntries {
100-
if !e.IsDir() {
101-
fileCount++
84+
// Subdirectories and the files of this directory compete on equal footing,
85+
// so files outside leaf directories are sampled too.
86+
total := len(dirs) + len(files)
87+
for _, c := range rand.Perm(total) {
88+
if c < len(dirs) {
89+
subPath := path.Join(dir, dirs[c].Name())
90+
sample, subResult, err := generateSample(codebase, subPath, append(dirStack, c))
91+
if err == nil {
92+
return sample, subResult, nil
10293
}
103-
}
104-
if fileCount == 0 {
10594
continue
10695
}
10796

108-
sample, subResult, err := generateSample(codebase, subPath, append(dirStack, dirIndex))
109-
if err == nil {
110-
return sample, subResult, nil
111-
}
112-
}
113-
114-
if len(files) == 0 {
115-
return "", result, ErrNoSampleFiles
116-
}
117-
118-
fileIndex := rand.IntN(len(files)) //#nosec
119-
selectedFile := files[fileIndex]
120-
fullPath := filepath.Join(dir, selectedFile.Name())
121-
122-
line, lineNum, err := getRandomLine(codebase, fullPath)
123-
if err != nil {
124-
return "", result, fmt.Errorf("challenge: read random line from %s: %w", fullPath, err)
125-
}
126-
if len(line) == 0 {
127-
return "", result, fmt.Errorf("challenge: %w, path: %s", ErrEmptySampleLine, fullPath)
128-
}
129-
130-
lineLen := len(line)
131-
var left, right int
132-
if lineLen == 1 {
133-
left, right = 0, 1
134-
} else {
135-
left = rand.IntN(lineLen - 1) //#nosec
136-
right = left + 1 + rand.IntN(lineLen-left-1) //#nosec
137-
}
138-
139-
sample := line[left:right]
97+
fileIndex := c - len(dirs)
98+
fullPath := path.Join(dir, files[fileIndex].Name())
14099

141-
return sample, SampleLocation{
142-
DirStack: dirStack,
143-
FileStack: []int{fileIndex, lineNum, left, right},
144-
}, nil
145-
}
100+
content, err := fs.ReadFile(codebase, fullPath)
101+
if err != nil || len(content) == 0 {
102+
continue
103+
}
146104

147-
func getRandomLine(codebase FileSystem, path string) (string, int, error) {
148-
lines, err := readLines(codebase, path)
149-
if err != nil {
150-
return "", 0, err
151-
}
152-
if len(lines) == 0 {
153-
return "", 0, fmt.Errorf("challenge: %w, path %s", ErrNoNonEmptySampleLines, path)
105+
return string(content), SampleLocation{
106+
DirStack: dirStack,
107+
FileStack: []int{fileIndex},
108+
}, nil
154109
}
155110

156-
index := rand.IntN(len(lines)) //#nosec
157-
return lines[index], index, nil
111+
return "", result, ErrNoSampleFiles
158112
}
159113

160-
var ErrInvalidStackSize = errors.New("challenge: invalid file stack size - expected 4 elements")
114+
var ErrInvalidStackSize = errors.New("challenge: invalid file stack size - expected at least 1 element")
161115

162116
func findSample(codebase FileSystem, loc SampleLocation) (string, error) {
163117
currentDir := "."
@@ -178,12 +132,12 @@ func findSample(codebase FileSystem, loc SampleLocation) (string, error) {
178132
dirs = append(dirs, e)
179133
}
180134
}
181-
if dirIndex >= len(dirs) {
135+
if dirIndex < 0 || dirIndex >= len(dirs) {
182136
return "", fmt.Errorf("challenge: dir index %d: level %d: %w", dirIndex, level, ErrSampleIndexOutOfBounds)
183137
}
184138

185139
nextDir := dirs[dirIndex].Name()
186-
currentDir = filepath.Join(currentDir, nextDir)
140+
currentDir = path.Join(currentDir, nextDir)
187141
}
188142

189143
entries, err := fs.ReadDir(codebase, currentDir)
@@ -201,56 +155,22 @@ func findSample(codebase FileSystem, loc SampleLocation) (string, error) {
201155
regularFiles = append(regularFiles, e)
202156
}
203157
}
204-
if len(loc.FileStack) != 4 {
158+
if len(loc.FileStack) < 1 {
205159
return "", ErrInvalidStackSize
206160
}
207161

208162
fileIndex := loc.FileStack[0]
209-
lineIndex := loc.FileStack[1]
210-
left := loc.FileStack[2]
211-
right := loc.FileStack[3]
212-
213-
if fileIndex >= len(regularFiles) {
163+
if fileIndex < 0 || fileIndex >= len(regularFiles) {
214164
return "", fmt.Errorf("challenge: %d %w - found %d files", fileIndex, ErrSampleIndexOutOfBounds, len(regularFiles))
215165
}
216166

217167
targetFile := regularFiles[fileIndex].Name()
218-
fullPath := filepath.Join(currentDir, targetFile)
168+
fullPath := path.Join(currentDir, targetFile)
219169

220-
lines, err := readLines(codebase, fullPath)
170+
content, err := fs.ReadFile(codebase, fullPath)
221171
if err != nil {
222-
return "", fmt.Errorf("challenge: read lines from %s: %w", fullPath, err)
223-
}
224-
if lineIndex >= len(lines) {
225-
return "", fmt.Errorf("challenge: %d %w, len=%d", lineIndex, ErrSampleIndexOutOfBounds, len(lines))
172+
return "", fmt.Errorf("challenge: read file %s: %w", fullPath, err)
226173
}
227174

228-
line := lines[lineIndex]
229-
if left > right || left < 0 || right > len(line) {
230-
return "", fmt.Errorf("challenge: %w: [%d:%d] on len=%d", ErrInvalidSubstringBounds, left, right, len(line))
231-
}
232-
233-
return line[left:right], nil
234-
}
235-
236-
func readLines(codebase FileSystem, path string) ([]string, error) {
237-
f, err := codebase.Open(path)
238-
if err != nil {
239-
return nil, err
240-
}
241-
242-
var lines []string
243-
scanner := bufio.NewScanner(f)
244-
for scanner.Scan() {
245-
text := scanner.Text()
246-
if len(text) <= 2 { // drop '}',')', '\t', '\n' etc.
247-
continue
248-
}
249-
250-
lines = append(lines, text)
251-
}
252-
253-
_ = f.Close()
254-
255-
return lines, scanner.Err()
175+
return string(content), nil
256176
}

security/challenge_test.go

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,11 @@ resulting from the use or misuse of this software.
2626
package security
2727

2828
import (
29+
"bytes"
30+
"errors"
31+
"strconv"
2932
"testing"
33+
"testing/fstest"
3034

3135
root "github.com/Warp-net/warpnet"
3236
)
@@ -50,3 +54,152 @@ func TestChallengeResolve_Success(t *testing.T) {
5054
}
5155
}
5256
}
57+
58+
const (
59+
rootGo = "package main\n\nfunc Root() {}\n"
60+
aGo = "package a\n\nfunc A() {}\n"
61+
bGo = "package b\n\nfunc B() {}\n"
62+
cGo = "package c\n\nfunc C() {}\n"
63+
dGo = "package d\n\nfunc D() {}\n"
64+
zGo = "package z\n\nfunc Z() {}\n"
65+
)
66+
67+
// sampleTestFS is a tree with files at every depth: the root, intermediate
68+
// directories that also contain subdirectories, and leaves.
69+
func sampleTestFS() fstest.MapFS {
70+
return fstest.MapFS{
71+
"rootfile.go": {Data: []byte(rootGo)},
72+
"a/afile.go": {Data: []byte(aGo)},
73+
"a/b/bfile.go": {Data: []byte(bGo)},
74+
"a/b/c/cfile.go": {Data: []byte(cGo)},
75+
"a/b/d/dfile.go": {Data: []byte(dGo)},
76+
"x/y/z/zfile.go": {Data: []byte(zGo)},
77+
}
78+
}
79+
80+
func allContents() map[string]bool {
81+
return map[string]bool{rootGo: true, aGo: true, bGo: true, cGo: true, dGo: true, zGo: true}
82+
}
83+
84+
// TestGenerateResolve_RoundTrip guards the DirStack/FileStack contract: a
85+
// generated location must resolve back to the same challenge on the same tree.
86+
func TestGenerateResolve_RoundTrip(t *testing.T) {
87+
codebase := sampleTestFS()
88+
for i := 0; i < 500; i++ {
89+
nonce := int64(i)
90+
gen, loc, err := GenerateChallenge(codebase, nonce)
91+
if err != nil {
92+
t.Fatalf("generate (i=%d): %v", i, err)
93+
}
94+
res, err := ResolveChallenge(codebase, loc, nonce)
95+
if err != nil {
96+
t.Fatalf("resolve (i=%d, loc=%+v): %v", i, loc, err)
97+
}
98+
if !bytes.Equal(gen, res) {
99+
t.Fatalf("round-trip mismatch at i=%d, loc=%+v", i, loc)
100+
}
101+
}
102+
}
103+
104+
// TestGenerateSample_WholeFile asserts every sample is a complete file's
105+
// contents, never a substring (regression for the 1-char substring bug).
106+
func TestGenerateSample_WholeFile(t *testing.T) {
107+
codebase := sampleTestFS()
108+
contents := allContents()
109+
for i := 0; i < 500; i++ {
110+
sample, _, err := generateSample(codebase, ".", []int{})
111+
if err != nil {
112+
t.Fatalf("generateSample (i=%d): %v", i, err)
113+
}
114+
if !contents[sample] {
115+
t.Fatalf("sample is not a whole file: %q", sample)
116+
}
117+
}
118+
}
119+
120+
// TestGenerateSample_SamplesAllLevels asserts files at every depth get
121+
// sampled, not just leaves (regression for the leaf-biased traversal bug).
122+
func TestGenerateSample_SamplesAllLevels(t *testing.T) {
123+
codebase := sampleTestFS()
124+
seen := map[string]bool{}
125+
for i := 0; i < 5000; i++ {
126+
sample, _, err := generateSample(codebase, ".", []int{})
127+
if err != nil {
128+
t.Fatalf("generateSample (i=%d): %v", i, err)
129+
}
130+
seen[sample] = true
131+
}
132+
for content := range allContents() {
133+
if !seen[content] {
134+
t.Errorf("file at content %q was never sampled - traversal still biased", content)
135+
}
136+
}
137+
}
138+
139+
// TestResolveChallenge_WholeFileHash locks in the wire contract: the resolved
140+
// challenge is SHA256(wholeFileContents + nonce).
141+
func TestResolveChallenge_WholeFileHash(t *testing.T) {
142+
codebase := sampleTestFS()
143+
loc := SampleLocation{FileStack: []int{0}} // root, first regular file = rootfile.go
144+
nonce := int64(7)
145+
146+
got, err := ResolveChallenge(codebase, loc, nonce)
147+
if err != nil {
148+
t.Fatalf("resolve: %v", err)
149+
}
150+
want := ConvertToSHA256([]byte(rootGo + strconv.FormatInt(nonce, 10)))
151+
if !bytes.Equal(got, want) {
152+
t.Fatalf("challenge is not SHA256(wholeFile + nonce)")
153+
}
154+
}
155+
156+
func TestResolveChallenge_Deterministic(t *testing.T) {
157+
codebase := sampleTestFS()
158+
loc := SampleLocation{FileStack: []int{0}}
159+
160+
r1, err := ResolveChallenge(codebase, loc, 42)
161+
if err != nil {
162+
t.Fatal(err)
163+
}
164+
r2, err := ResolveChallenge(codebase, loc, 42)
165+
if err != nil {
166+
t.Fatal(err)
167+
}
168+
if !bytes.Equal(r1, r2) {
169+
t.Fatal("same location and nonce produced different challenges")
170+
}
171+
172+
r3, err := ResolveChallenge(codebase, loc, 43)
173+
if err != nil {
174+
t.Fatal(err)
175+
}
176+
if bytes.Equal(r1, r3) {
177+
t.Fatal("different nonce produced identical challenge")
178+
}
179+
}
180+
181+
func TestResolveChallenge_Errors(t *testing.T) {
182+
codebase := sampleTestFS()
183+
184+
if _, err := ResolveChallenge(codebase, SampleLocation{}, 1); !errors.Is(err, ErrInvalidStackSize) {
185+
t.Fatalf("empty FileStack: want ErrInvalidStackSize, got %v", err)
186+
}
187+
if _, err := ResolveChallenge(codebase, SampleLocation{FileStack: []int{999}}, 1); !errors.Is(err, ErrSampleIndexOutOfBounds) {
188+
t.Fatalf("file index out of bounds: want ErrSampleIndexOutOfBounds, got %v", err)
189+
}
190+
if _, err := ResolveChallenge(codebase, SampleLocation{DirStack: []int{999}, FileStack: []int{0}}, 1); !errors.Is(err, ErrSampleIndexOutOfBounds) {
191+
t.Fatalf("dir index out of bounds: want ErrSampleIndexOutOfBounds, got %v", err)
192+
}
193+
if _, err := ResolveChallenge(codebase, SampleLocation{FileStack: []int{-1}}, 1); !errors.Is(err, ErrSampleIndexOutOfBounds) {
194+
t.Fatalf("negative file index: want ErrSampleIndexOutOfBounds, got %v", err)
195+
}
196+
if _, err := ResolveChallenge(codebase, SampleLocation{DirStack: []int{-1}, FileStack: []int{0}}, 1); !errors.Is(err, ErrSampleIndexOutOfBounds) {
197+
t.Fatalf("negative dir index: want ErrSampleIndexOutOfBounds, got %v", err)
198+
}
199+
}
200+
201+
func TestGenerateChallenge_NoFiles(t *testing.T) {
202+
if _, _, err := GenerateChallenge(fstest.MapFS{}, 1); !errors.Is(err, ErrNoSampleFiles) {
203+
t.Fatalf("empty codebase: want ErrNoSampleFiles, got %v", err)
204+
}
205+
}

version

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.7.329
1+
0.7.338

0 commit comments

Comments
 (0)