The sender only handled regular files: a staged directory would fail at upload time, so users could only send individual files. Now a staged directory is walked recursively and each file is advertised with a name relative to the folder's parent (e.g. "Trip/day1/img.jpg"), which is the LocalSend-compatible way to carry structure. The receiver recreates those subdirectories under the receive dir, creating parents as needed. The path-traversal guard is preserved: names are cleaned against a leading "/" to collapse "..", and a containment check ensures the result stays within the receive dir. In the TUI send picker, "a" stages the folder currently being browsed; staged folders are tagged in the panel and the help text is updated. Tests: directory expansion produces relative names; an end-to-end folder send recreates the structure on the receiver; destPath preserves subdirs, rejects traversal, and de-duplicates within subfolders. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
124 lines
3.4 KiB
Go
124 lines
3.4 KiB
Go
package client
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"testing"
|
|
"time"
|
|
|
|
"omarchy-send/internal/discovery"
|
|
"omarchy-send/internal/protocol"
|
|
"omarchy-send/internal/server"
|
|
"omarchy-send/internal/transfer"
|
|
)
|
|
|
|
// expand must walk a directory and advertise each file with a name relative to
|
|
// the selected folder's parent, so the folder itself is part of the path.
|
|
func TestExpandDirectory(t *testing.T) {
|
|
root := t.TempDir()
|
|
dir := filepath.Join(root, "Trip")
|
|
mustWrite(t, filepath.Join(dir, "cover.jpg"), "a")
|
|
mustWrite(t, filepath.Join(dir, "day1", "img.jpg"), "bb")
|
|
mustWrite(t, filepath.Join(dir, "day1", "notes.txt"), "ccc")
|
|
|
|
s := New(protocol.DeviceInfo{})
|
|
items := s.expand([]string{dir})
|
|
|
|
got := make([]string, len(items))
|
|
for i, it := range items {
|
|
got[i] = it.name
|
|
}
|
|
sort.Strings(got)
|
|
want := []string{"Trip/cover.jpg", "Trip/day1/img.jpg", "Trip/day1/notes.txt"}
|
|
if len(got) != len(want) {
|
|
t.Fatalf("expand returned %v, want %v", got, want)
|
|
}
|
|
for i := range want {
|
|
if got[i] != want[i] {
|
|
t.Fatalf("expand[%d] = %q, want %q (all: %v)", i, got[i], want[i], got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A regular file path passes through with just its base name.
|
|
func TestExpandFile(t *testing.T) {
|
|
p := filepath.Join(t.TempDir(), "single.bin")
|
|
mustWrite(t, p, "x")
|
|
items := New(protocol.DeviceInfo{}).expand([]string{p})
|
|
if len(items) != 1 || items[0].name != "single.bin" {
|
|
t.Fatalf("expand file = %+v", items)
|
|
}
|
|
}
|
|
|
|
// End-to-end: sending a directory recreates its structure on the receiver.
|
|
func TestSendDirectoryPreservesStructure(t *testing.T) {
|
|
recvDir := t.TempDir()
|
|
recvInfo := protocol.DeviceInfo{
|
|
Alias: "recv", Version: protocol.ProtocolVersion, Port: 53998, Protocol: "http",
|
|
}
|
|
srv := server.New(server.Options{Info: recvInfo, ReceiveDir: recvDir, AutoAccept: true})
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
if err := srv.Start(ctx); err != nil {
|
|
t.Fatalf("server start: %v", err)
|
|
}
|
|
go func() {
|
|
for range srv.Transfers() {
|
|
}
|
|
}()
|
|
time.Sleep(50 * time.Millisecond)
|
|
|
|
srcRoot := t.TempDir()
|
|
dir := filepath.Join(srcRoot, "Trip")
|
|
files := map[string]string{
|
|
"cover.jpg": "cover-bytes",
|
|
"day1/img.jpg": "day1-image",
|
|
"day2/clip.mov": "day2-clip",
|
|
"day2/sub/note.md": "nested-note",
|
|
}
|
|
for rel, body := range files {
|
|
mustWrite(t, filepath.Join(dir, filepath.FromSlash(rel)), body)
|
|
}
|
|
|
|
sender := New(protocol.DeviceInfo{Alias: "sender", Fingerprint: "snd1", Version: "2.1", Protocol: "http"})
|
|
peer := discovery.Peer{Info: recvInfo, IP: "127.0.0.1"}
|
|
sender.Send(peer, []string{dir}, "")
|
|
|
|
done := 0
|
|
deadline := time.After(5 * time.Second)
|
|
for done < len(files) {
|
|
select {
|
|
case ev := <-sender.Events():
|
|
if ev.Kind == transfer.Error {
|
|
t.Fatalf("send error: %v", ev.Err)
|
|
}
|
|
if ev.Kind == transfer.FileDone {
|
|
done++
|
|
}
|
|
case <-deadline:
|
|
t.Fatalf("timed out after %d/%d files", done, len(files))
|
|
}
|
|
}
|
|
|
|
for rel, body := range files {
|
|
got, err := os.ReadFile(filepath.Join(recvDir, "Trip", filepath.FromSlash(rel)))
|
|
if err != nil {
|
|
t.Fatalf("missing received %q: %v", rel, err)
|
|
}
|
|
if string(got) != body {
|
|
t.Fatalf("%q content = %q, want %q", rel, got, body)
|
|
}
|
|
}
|
|
}
|
|
|
|
func mustWrite(t *testing.T, path, body string) {
|
|
t.Helper()
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|