Files
2026-09-13 10:28:33 +02:00

304 lines
8.4 KiB
Go

package store
import (
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestCleanID(t *testing.T) {
valid := []string{"my-file", "a1", "godot.zip", "a_b.c-d", "ABC"}
for _, in := range valid {
got, err := CleanID(in)
if err != nil {
t.Errorf("CleanID(%q): %v", in, err)
continue
}
if got != strings.ToLower(in) {
t.Errorf("CleanID(%q) = %q, want it lowercased", in, got)
}
}
// Anything that could escape the objects directory or collide on a
// case-insensitive filesystem must be refused.
invalid := []string{
"", "a", ".", "..", "...", "../etc/passwd", "a/b", `a\b`, "/abs",
".hidden", "a..b", "trailing.", "d", "i", "with space", "emoji-🙂",
strings.Repeat("x", 65), "a\x00b", "a\nb",
}
for _, in := range invalid {
if got, err := CleanID(in); err == nil {
t.Errorf("CleanID(%q) = %q, want an error", in, got)
}
}
// Names that merely look like something of ours are ordinary names: an id
// lives under /d/ and /i/ and in a directory of its own, so it shadows
// nothing. Refusing these would take names from people for no benefit.
for _, in := range []string{"api", "static", "admin", "upload", "login",
"robots.txt", "tokens.json", "favicon.png", "index.html"} {
if got, err := CleanID(in); err != nil || got != in {
t.Errorf("CleanID(%q) = %q, %v; want it accepted unchanged", in, got, err)
}
}
}
func TestCleanIDAcceptsGeneratedUUIDs(t *testing.T) {
for range 100 {
id, err := NewUUID()
if err != nil {
t.Fatal(err)
}
if len(id) != 36 || id[14] != '4' {
t.Fatalf("NewUUID() = %q, not a v4 UUID", id)
}
if got, err := CleanID(id); err != nil || got != id {
t.Fatalf("CleanID(%q) = %q, %v", id, got, err)
}
}
}
func TestSanitizeFilename(t *testing.T) {
cases := map[string]string{
"MyGame.zip": "MyGame.zip",
`C:\Users\me\Desktop\thing.exe`: "thing.exe",
"/etc/passwd": "passwd",
"../../escape.txt": "escape.txt",
"": "download.bin",
".": "download.bin",
"..": "download.bin",
" ": "download.bin",
"with\r\nheader: injected": "withheader: injected",
"null\x00byte": "nullbyte",
"naïve fïle.txt": "naïve fïle.txt",
`quo"te.txt`: `quo"te.txt`,
}
for in, want := range cases {
if got := SanitizeFilename(in); got != want {
t.Errorf("SanitizeFilename(%q) = %q, want %q", in, got, want)
}
}
long := SanitizeFilename(strings.Repeat("é", 400))
if len(long) > maxFilenameBytes {
t.Errorf("a long name was not truncated: %d bytes", len(long))
}
}
func TestUploadIsInvisibleUntilCommitted(t *testing.T) {
s, err := Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
now := time.Now()
up, err := s.Reserve("thing")
if err != nil {
t.Fatal(err)
}
up.Write([]byte("partial"))
// The name is claimed, but the object does not exist yet.
if !s.Exists("thing") {
t.Error("the name was not claimed")
}
if _, err := s.Get("thing", now); err != ErrNotFound {
t.Errorf("Get on an uncommitted upload = %v, want ErrNotFound", err)
}
if _, err := s.Reserve("thing"); err != ErrExists {
t.Error("a claimed name was handed out twice")
}
if err := up.Commit(&Meta{Created: now}); err != nil {
t.Fatal(err)
}
m, err := s.Get("thing", now)
if err != nil {
t.Fatalf("Get after Commit: %v", err)
}
if m.Size != 7 {
t.Errorf("size = %d, want 7", m.Size)
}
want := sha256.Sum256([]byte("partial"))
if m.SHA256 != hex.EncodeToString(want[:]) {
t.Errorf("SHA256 = %q, want %x", m.SHA256, want)
}
if s.Total() != 7 {
t.Errorf("Total() = %d, want 7", s.Total())
}
}
func TestAbortReleasesTheName(t *testing.T) {
dir := t.TempDir()
s, err := Open(dir)
if err != nil {
t.Fatal(err)
}
up, err := s.Reserve("thing")
if err != nil {
t.Fatal(err)
}
up.Write([]byte("partial"))
up.Abort()
if s.Exists("thing") {
t.Error("Abort did not release the name")
}
if _, err := os.Stat(filepath.Join(dir, "objects", "thing")); !os.IsNotExist(err) {
t.Error("Abort left the directory behind")
}
if _, err := s.Reserve("thing"); err != nil {
t.Errorf("the name could not be reused: %v", err)
}
}
func TestLimitStopsAtTheCap(t *testing.T) {
s, err := Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
up, err := s.Reserve("thing")
if err != nil {
t.Fatal(err)
}
defer up.Abort()
up.SetLimit(10)
if _, err := up.Write([]byte("0123456789")); err != nil {
t.Fatalf("writing exactly the limit: %v", err)
}
if _, err := up.Write([]byte("x")); err != ErrTooLarge {
t.Errorf("writing past the limit = %v, want ErrTooLarge", err)
}
if up.Size() != 10 {
t.Errorf("Size() = %d, want 10", up.Size())
}
}
func TestIndexIsRebuiltFromDisk(t *testing.T) {
dir := t.TempDir()
now := time.Now()
s, err := Open(dir)
if err != nil {
t.Fatal(err)
}
up, _ := s.Reserve("survivor")
up.Write([]byte("bytes"))
if err := up.Commit(&Meta{Created: now}); err != nil {
t.Fatal(err)
}
// A .part with no metadata is what a killed upload leaves behind.
stale, _ := s.Reserve("stale")
stale.Write([]byte("half"))
// Reopening is what a restart does.
s2, err := Open(dir)
if err != nil {
t.Fatal(err)
}
if _, err := s2.Get("survivor", now); err != nil {
t.Errorf("a committed object did not survive a restart: %v", err)
}
if _, err := s2.Get("stale", now); err != ErrNotFound {
t.Error("an uncommitted object became visible after a restart")
}
if s2.Total() != 5 {
t.Errorf("Total() = %d, want 5", s2.Total())
}
}
func TestSweepRemovesExpired(t *testing.T) {
s, err := Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
now := time.Now()
deadline := now.Add(time.Hour)
up, _ := s.Reserve("temporary")
up.Write([]byte("x"))
up.Commit(&Meta{Created: now, Expires: &deadline})
keep, _ := s.Reserve("permanent")
keep.Write([]byte("x"))
keep.Commit(&Meta{Created: now})
if n := s.Sweep(now); n != 0 {
t.Errorf("swept %d objects before anything expired", n)
}
if n := s.Sweep(now.Add(2 * time.Hour)); n != 1 {
t.Errorf("swept %d objects, want 1", n)
}
if s.Count() != 1 || s.Total() != 1 {
t.Errorf("after sweeping: count = %d, total = %d, want 1 and 1", s.Count(), s.Total())
}
if _, err := s.Get("permanent", now.Add(10*365*24*time.Hour)); err != nil {
t.Error("an object with no expiry was swept")
}
}
// The objects directory is group-writable by design, so a planted symlink is a
// realistic way to try to make the service read or clobber a file elsewhere.
// Every object operation goes through an os.Root, which refuses to follow one
// out of the directory.
func TestSymlinksCannotEscapeTheObjectsDirectory(t *testing.T) {
dir := t.TempDir()
outside := filepath.Join(dir, "outside")
if err := os.WriteFile(filepath.Join(dir, "secret.txt"), []byte("password"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(outside, 0o755); err != nil {
t.Fatal(err)
}
s, err := Open(dir)
if err != nil {
t.Fatal(err)
}
defer s.Close()
objects := filepath.Join(dir, "objects")
now := time.Now()
// A blob that is a symlink to a file outside the store.
if err := os.Mkdir(filepath.Join(objects, "sneaky"), 0o775); err != nil {
t.Fatal(err)
}
if err := os.Symlink(filepath.Join(dir, "secret.txt"), filepath.Join(objects, "sneaky", "blob")); err != nil {
t.Fatal(err)
}
meta := []byte(`{"id":"sneaky","filename":"x","size":8,"created":"2026-01-01T00:00:00Z","expires":null}`)
if err := os.WriteFile(filepath.Join(objects, "sneaky", "meta.json"), meta, 0o664); err != nil {
t.Fatal(err)
}
// Reopen so the planted object is indexed, as it would be after a restart.
s2, err := Open(dir)
if err != nil {
t.Fatal(err)
}
defer s2.Close()
if _, _, err := s2.OpenBlob("sneaky", now); err == nil {
t.Error("a blob symlinked outside the store was opened")
}
// A whole object directory that is a symlink elsewhere.
if err := os.Symlink(outside, filepath.Join(objects, "elsewhere")); err != nil {
t.Fatal(err)
}
if _, err := s2.Reserve("elsewhere"); err != ErrExists {
t.Errorf("Reserve over a symlink = %v, want ErrExists", err)
}
// Writing through it must not reach the target directory either.
if err := s2.writeMetaAtomic("elsewhere", &Meta{ID: "elsewhere"}); err == nil {
t.Error("metadata was written through a symlinked directory")
}
if entries, _ := os.ReadDir(outside); len(entries) != 0 {
t.Errorf("%d files were created outside the store", len(entries))
}
}