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)) } }