Skip to content

Commit fc2bd09

Browse files
authored
test(utils): cover path verification, sanitization, and unique naming (#9978)
pkg/utils/path.go provides the security primitives for download paths (VerifyPath, InTrustedRoot) and the file-naming helpers used by every import flow (SanitizeFileName, GenerateUniqueFileName). None of them had test coverage, so a future regression in the traversal check or in the ".." stripping inside SanitizeFileName would land unnoticed. The new specs pin the lexical contract for each helper: - VerifyPath accepts strict descendants and inner traversal that stays inside the base, rejects "..", compound traversal, and the base path itself. An explicit spec documents that the check is purely lexical (filepath.Clean, not EvalSymlinks) so any future caller that needs symlink-aware defence knows to EvalSymlinks first. - InTrustedRoot rejects the trusted root and sibling directories, accepts deeply nested descendants. - SanitizeFileName covers the leading-directory and absolute-prefix paths plus the embedded ".." case ("foo..bar" -> "foobar") that the Clean+Base layer alone would leave intact. - GenerateUniqueFileName covers the no-collision, single-collision, walk-the-counter, and empty-extension cases using GinkgoT().TempDir() so the suite stays hermetic. Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: TLoE419 <tloemizuchizu@gmail.com>
1 parent a473a32 commit fc2bd09

1 file changed

Lines changed: 157 additions & 0 deletions

File tree

pkg/utils/path_test.go

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
package utils_test
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
7+
. "github.com/mudler/LocalAI/pkg/utils"
8+
. "github.com/onsi/ginkgo/v2"
9+
. "github.com/onsi/gomega"
10+
)
11+
12+
var _ = Describe("utils/path tests", func() {
13+
Describe("VerifyPath", func() {
14+
It("accepts a simple file directly inside the base path", func() {
15+
Expect(VerifyPath("model.bin", "/srv/models")).To(Succeed())
16+
})
17+
18+
It("accepts a nested subdirectory inside the base path", func() {
19+
Expect(VerifyPath("subdir/model.bin", "/srv/models")).To(Succeed())
20+
})
21+
22+
It("accepts traversal sequences that stay inside the base", func() {
23+
// "a/b/../c" collapses to "a/c", still strictly inside the base,
24+
// so the verifier should permit it.
25+
Expect(VerifyPath("a/b/../c", "/srv/models")).To(Succeed())
26+
})
27+
28+
It("rejects a single parent-traversal that escapes the base", func() {
29+
Expect(VerifyPath("../etc/passwd", "/srv/models")).ToNot(Succeed())
30+
})
31+
32+
It("rejects compound traversal that climbs above the base", func() {
33+
Expect(VerifyPath("a/../../etc/passwd", "/srv/models")).ToNot(Succeed())
34+
})
35+
36+
It("rejects a deeply-escaping path that lands on the filesystem root", func() {
37+
Expect(VerifyPath("../../etc/passwd", "/srv/models")).ToNot(Succeed())
38+
})
39+
40+
It("rejects the base path itself", func() {
41+
// Documents that VerifyPath requires a strict descendant: an
42+
// empty user input resolves to the base directory and is
43+
// rejected, which is the safer default for a download helper
44+
// that expects a target file inside the base.
45+
Expect(VerifyPath("", "/srv/models")).ToNot(Succeed())
46+
})
47+
48+
It("treats an absolute-looking user input as relative to the base", func() {
49+
// filepath.Join discards no segments here: the result is
50+
// "/srv/models/etc/passwd", which is still inside the base.
51+
// This protects callers that forward untrusted user paths
52+
// directly to the verifier.
53+
Expect(VerifyPath("/etc/passwd", "/srv/models")).To(Succeed())
54+
})
55+
56+
It("is purely lexical and does not follow symlinks", func() {
57+
// VerifyPath uses filepath.Clean, not filepath.EvalSymlinks,
58+
// so a symlink that escapes the base is not detected here.
59+
// Callers who must defend against symlink escapes need to
60+
// EvalSymlinks before delegating to VerifyPath. This test
61+
// pins the current contract so the trade-off stays explicit.
62+
tmpDir := GinkgoT().TempDir()
63+
base := filepath.Join(tmpDir, "base")
64+
outside := filepath.Join(tmpDir, "outside")
65+
Expect(os.Mkdir(base, 0o755)).To(Succeed())
66+
Expect(os.Mkdir(outside, 0o755)).To(Succeed())
67+
Expect(os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("x"), 0o600)).To(Succeed())
68+
Expect(os.Symlink(outside, filepath.Join(base, "escape"))).To(Succeed())
69+
70+
Expect(VerifyPath("escape/secret.txt", base)).To(Succeed())
71+
})
72+
})
73+
74+
Describe("InTrustedRoot", func() {
75+
It("accepts a strict descendant of the trusted root", func() {
76+
Expect(InTrustedRoot("/srv/models/file", "/srv/models")).To(Succeed())
77+
})
78+
79+
It("accepts a deeply nested descendant", func() {
80+
Expect(InTrustedRoot("/srv/models/a/b/c/file", "/srv/models")).To(Succeed())
81+
})
82+
83+
It("rejects the trusted root itself", func() {
84+
// The implementation walks up before comparing, so the input
85+
// path must have at least one component beneath the root.
86+
Expect(InTrustedRoot("/srv/models", "/srv/models")).ToNot(Succeed())
87+
})
88+
89+
It("rejects a sibling directory that shares the parent", func() {
90+
Expect(InTrustedRoot("/srv/other/file", "/srv/models")).ToNot(Succeed())
91+
})
92+
93+
It("rejects an unrelated absolute path", func() {
94+
Expect(InTrustedRoot("/etc/passwd", "/srv/models")).ToNot(Succeed())
95+
})
96+
})
97+
98+
Describe("SanitizeFileName", func() {
99+
It("returns the original name when nothing is unsafe", func() {
100+
Expect(SanitizeFileName("model.bin")).To(Equal("model.bin"))
101+
})
102+
103+
It("strips leading directory components", func() {
104+
Expect(SanitizeFileName("subdir/model.bin")).To(Equal("model.bin"))
105+
})
106+
107+
It("strips absolute path prefixes", func() {
108+
Expect(SanitizeFileName("/etc/passwd")).To(Equal("passwd"))
109+
})
110+
111+
It("collapses parent-traversal sequences and keeps only the leaf", func() {
112+
Expect(SanitizeFileName("../etc/passwd")).To(Equal("passwd"))
113+
})
114+
115+
It("removes embedded .. sequences that Clean+Base alone do not catch", func() {
116+
// After Clean+Base "foo..bar" survives unchanged; the explicit
117+
// ReplaceAll on ".." in the implementation is the last line of
118+
// defence against filenames that look benign but still contain
119+
// traversal markers.
120+
Expect(SanitizeFileName("foo..bar")).To(Equal("foobar"))
121+
})
122+
123+
It("returns an empty string when the input is only a parent reference", func() {
124+
Expect(SanitizeFileName("..")).To(Equal(""))
125+
})
126+
})
127+
128+
Describe("GenerateUniqueFileName", func() {
129+
It("returns the bare filename when no collision exists", func() {
130+
tmpDir := GinkgoT().TempDir()
131+
Expect(GenerateUniqueFileName(tmpDir, "model", ".bin")).To(Equal("model.bin"))
132+
})
133+
134+
It("suffixes with _2 when the bare filename already exists", func() {
135+
tmpDir := GinkgoT().TempDir()
136+
Expect(os.WriteFile(filepath.Join(tmpDir, "model.bin"), nil, 0o600)).To(Succeed())
137+
138+
Expect(GenerateUniqueFileName(tmpDir, "model", ".bin")).To(Equal("model_2.bin"))
139+
})
140+
141+
It("advances the counter past every existing collision", func() {
142+
tmpDir := GinkgoT().TempDir()
143+
for _, name := range []string{"model.bin", "model_2.bin", "model_3.bin"} {
144+
Expect(os.WriteFile(filepath.Join(tmpDir, name), nil, 0o600)).To(Succeed())
145+
}
146+
147+
Expect(GenerateUniqueFileName(tmpDir, "model", ".bin")).To(Equal("model_4.bin"))
148+
})
149+
150+
It("preserves an empty extension when generating the suffixed name", func() {
151+
tmpDir := GinkgoT().TempDir()
152+
Expect(os.WriteFile(filepath.Join(tmpDir, "README"), nil, 0o600)).To(Succeed())
153+
154+
Expect(GenerateUniqueFileName(tmpDir, "README", "")).To(Equal("README_2"))
155+
})
156+
})
157+
})

0 commit comments

Comments
 (0)