Found by walking every handler and the UI with odd inputs, then a battery of requests against the live hub. Fixed: - A save over the 8 MiB cap was cut short in place: the file was truncated, then the first 8 MiB written, and the caller told 204. Now the body is read whole first and a body over the cap is a 413 with the file untouched. - A link to a file showed the link's own time; now the file's, so the row reads right and the preview key follows the file. - Reading a link to nothing answered 200 with an empty body (the open failed after the headers in root mode); now a 404 that names the target. - A play request with an unknown codec or size was answered by inspecting the file first, so an audio-only file said "no video stream" instead of what was wrong; the choice is checked before any work, and is a 400. - A stream that ended in failure was handed back to the next identical request for ten minutes; a failed one is now dropped and a fresh one made. - A player that left while its stream was still finding a first segment made the loop try the next recipe; it stops. A stream closed mid-start is not started again. - Ending a root-mode ffmpeg could wait forever: the daemon cannot signal root's processes, so the wait is bounded and the helper's own grace does the rest. - A page going away (a phone closing the tab) releases its stream by a beacon, rather than ten minutes later by the sweeper. - The path box resolves like a shell — relative to the folder shown, . and .. applied, trailing slashes dropped — in the tab and in the Move picker, with a test. A typed path that is a file opens it; a remembered folder that is gone falls back to / once. - A dropped folder arrived as a nameless empty file; it is refused with a word. The upload toast counts what was actually uploaded. - The viewer names a device, pipe or socket, and a dangling link, instead of calling them binary files. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
196 lines
6.6 KiB
Go
196 lines
6.6 KiB
Go
package files
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestInside(t *testing.T) {
|
|
for _, c := range []struct {
|
|
from, to string
|
|
want bool
|
|
}{
|
|
{"/a/b", "/a/b", true}, {"/a/b", "/a/b/c", true}, {"/a/b", "/a/bc", false}, {"/a/b", "/a", false}, {"/", "/x", true},
|
|
} {
|
|
if got := Inside(c.from, c.to); got != c.want {
|
|
t.Errorf("Inside(%q, %q) = %v, want %v", c.from, c.to, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIsCrossDevice(t *testing.T) {
|
|
if !IsCrossDevice(&os.LinkError{Op: "rename", Old: "/a", New: "/b", Err: syscall.EXDEV}) {
|
|
t.Fatal("the kernel's EXDEV")
|
|
}
|
|
if !IsCrossDevice(errors.New("rename /a /b: invalid cross-device link")) {
|
|
t.Fatal("the helper's words for it")
|
|
}
|
|
if IsCrossDevice(errors.New("permission denied")) || IsCrossDevice(nil) {
|
|
t.Fatal("anything else is not")
|
|
}
|
|
}
|
|
|
|
// The copy path, exercised directly: a tree with nested folders, a symlink, an odd mode
|
|
// and old mtimes lands whole under the new name, and the original is gone only then.
|
|
func TestMoveByCopy(t *testing.T) {
|
|
root := t.TempDir()
|
|
src := filepath.Join(root, "src", "album")
|
|
if err := os.MkdirAll(filepath.Join(src, "sub", "deep"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
old := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC)
|
|
write := func(p, s string, mode os.FileMode) {
|
|
if err := os.WriteFile(p, []byte(s), mode); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = os.Chmod(p, mode)
|
|
_ = os.Chtimes(p, old, old)
|
|
}
|
|
write(filepath.Join(src, "a.txt"), "hello", 0o644)
|
|
write(filepath.Join(src, "sub", "deep", "b.bin"), strings.Repeat("x", 100000), 0o600)
|
|
write(filepath.Join(src, "run.sh"), "#!/bin/sh\n", 0o755)
|
|
if err := os.Symlink("a.txt", filepath.Join(src, "link")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = os.Chtimes(filepath.Join(src, "sub"), old, old)
|
|
|
|
dst := filepath.Join(root, "dst", "moved")
|
|
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var out strings.Builder
|
|
if err := moveByCopy(context.Background(), src, dst, &out); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := os.Lstat(src); !os.IsNotExist(err) {
|
|
t.Fatal("the original is still there")
|
|
}
|
|
if b, _ := os.ReadFile(filepath.Join(dst, "a.txt")); string(b) != "hello" {
|
|
t.Fatal("a.txt")
|
|
}
|
|
if st, err := os.Stat(filepath.Join(dst, "sub", "deep", "b.bin")); err != nil || st.Size() != 100000 || st.Mode().Perm() != 0o600 || !st.ModTime().Equal(old) {
|
|
t.Fatalf("b.bin: %v %v", st, err)
|
|
}
|
|
if st, _ := os.Stat(filepath.Join(dst, "run.sh")); st.Mode().Perm() != 0o755 {
|
|
t.Fatal("mode lost")
|
|
}
|
|
if target, err := os.Readlink(filepath.Join(dst, "link")); err != nil || target != "a.txt" {
|
|
t.Fatal("symlink not copied as a symlink")
|
|
}
|
|
if st, _ := os.Stat(filepath.Join(dst, "sub")); !st.ModTime().Equal(old) {
|
|
t.Fatal("folder mtime lost")
|
|
}
|
|
if left, _ := filepath.Glob(filepath.Join(root, "dst", ".*hamlab-moving*")); len(left) != 0 {
|
|
t.Fatal("temporary copy left behind")
|
|
}
|
|
if !strings.Contains(out.String(), "moved ") || !strings.Contains(out.String(), "4 files") {
|
|
t.Fatalf("progress said: %q", out.String())
|
|
}
|
|
}
|
|
|
|
// A failure part-way leaves the original as it was and no partial copy.
|
|
func TestMoveByCopyFailsClean(t *testing.T) {
|
|
root := t.TempDir()
|
|
src := filepath.Join(root, "src")
|
|
if err := os.MkdirAll(src, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(src, "a"), []byte("a"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := syscall.Mkfifo(filepath.Join(src, "pipe"), 0o644); err != nil {
|
|
t.Skip("no fifos here")
|
|
}
|
|
dst := filepath.Join(root, "dst")
|
|
err := moveByCopy(context.Background(), src, dst, &strings.Builder{})
|
|
if err == nil || !strings.Contains(err.Error(), "pipe") {
|
|
t.Fatalf("expected the pipe to be refused, got %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(src, "a")); err != nil {
|
|
t.Fatal("the original was touched")
|
|
}
|
|
if _, err := os.Lstat(dst); !os.IsNotExist(err) {
|
|
t.Fatal("a partial copy was left")
|
|
}
|
|
if left, _ := filepath.Glob(filepath.Join(root, ".*hamlab-moving*")); len(left) != 0 {
|
|
t.Fatal("temporary copy left behind")
|
|
}
|
|
// a cancel is the same story
|
|
if err := os.Remove(filepath.Join(src, "pipe")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
if err := moveByCopy(ctx, src, dst, &strings.Builder{}); err == nil {
|
|
t.Fatal("a cancelled move must fail")
|
|
}
|
|
if _, err := os.Stat(filepath.Join(src, "a")); err != nil {
|
|
t.Fatal("the original was touched by a cancel")
|
|
}
|
|
}
|
|
|
|
// Move on one filesystem is a rename: nothing is copied, and the guards hold.
|
|
func TestMoveSameFilesystem(t *testing.T) {
|
|
root := t.TempDir()
|
|
src := filepath.Join(root, "f")
|
|
if err := os.WriteFile(src, []byte("x"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var l Local
|
|
var out strings.Builder
|
|
if err := l.Move(context.Background(), src, filepath.Join(root, "g"), &out); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if out.Len() != 0 {
|
|
t.Fatal("a rename has nothing to say")
|
|
}
|
|
if _, err := os.Stat(filepath.Join(root, "g")); err != nil {
|
|
t.Fatal("not renamed")
|
|
}
|
|
if err := l.Move(context.Background(), root, filepath.Join(root, "inside"), &out); err == nil {
|
|
t.Fatal("into itself")
|
|
}
|
|
if err := l.Move(context.Background(), filepath.Join(root, "g"), filepath.Join(root, "g"), &out); err == nil {
|
|
t.Fatal("onto itself")
|
|
}
|
|
if err := os.WriteFile(filepath.Join(root, "h"), nil, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := l.Move(context.Background(), filepath.Join(root, "g"), filepath.Join(root, "h"), &out); err == nil || !strings.Contains(err.Error(), "exists") {
|
|
t.Fatalf("onto an existing file: %v", err)
|
|
}
|
|
}
|
|
|
|
// A link to a file carries the file's size and time, so the row and the preview key follow
|
|
// the file; a link to a folder is a folder; a link to nothing says so.
|
|
func TestEntryOfLinks(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(dir, "f"), []byte("12345"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
old := time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
_ = os.Chtimes(filepath.Join(dir, "f"), old, old)
|
|
_ = os.Mkdir(filepath.Join(dir, "d"), 0o755)
|
|
for _, l := range [][2]string{{"lf", "f"}, {"ld", "d"}, {"lx", "nowhere"}} {
|
|
if err := os.Symlink(l[1], filepath.Join(dir, l[0])); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
ent := func(n string) Entry { st, _ := os.Lstat(filepath.Join(dir, n)); return entryOf(dir, n, st) }
|
|
if e := ent("lf"); e.Type != "link" || e.Size != 5 || e.Mtime != old.UnixMilli() || e.Target != "f" || e.Broken {
|
|
t.Fatalf("link to file: %+v", e)
|
|
}
|
|
if e := ent("ld"); e.Type != "dir" || e.Target != "d" {
|
|
t.Fatalf("link to dir: %+v", e)
|
|
}
|
|
if e := ent("lx"); e.Type != "link" || !e.Broken || e.Preview != "" {
|
|
t.Fatalf("dangling: %+v", e)
|
|
}
|
|
}
|