304 lines
8.4 KiB
Go
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))
|
|
}
|
|
}
|