diff --git a/README.md b/README.md index 347edcd..46b2e66 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ Fast, private file handoffs: **[cd.yash0.in](https://cd.yash0.in)** - **Anyone → anyone:** pick files in the browser or run `cdx send `, share the 5-digit code, and the receiver - types it into the **Receive** box, scans the QR code, or runs `cdx receive `. Several files arrive as one .zip. + types it into the **Receive** box, scans the QR code, or runs `cdx receive `. Several files share one code and arrive as separate files. CD never stores files. Transfers are live and end-to-end encrypted. @@ -21,7 +21,7 @@ The shell installers check release checksums. The CLI is named `cdx` because `cd ```bash cdx send ./report.pdf # prints a code, e.g. 48291 -cdx send ./shots ./notes.md # several paths arrive as one .zip +cdx send ./shots ./notes.md # several files, one code; they arrive separately cdx send --link ./secret.zip # private end-to-end encrypted link instead of a code cdx receive 48291 # save to the current directory cdx receive 48291 --out ~/Downloads diff --git a/cmd/cdx/README.md b/cmd/cdx/README.md index d0b9fb7..332e6d4 100644 --- a/cmd/cdx/README.md +++ b/cmd/cdx/README.md @@ -65,8 +65,10 @@ Rules for v1: - One path sends that file as-is. A folder, or several paths, is streamed as one uncompressed `.zip` built on the fly (its exact size is computed up - front, since the protocol offers a known size). Symlinks inside folders are - skipped, not followed. A file that changes size mid-send fails the + front, since the protocol offers a known size) and marked as a bundle in + the offer. `cdx receive` and the browser unpack it, so each file arrives on + its own (`--out` names the folder they land in); receivers that predate the + marker keep the `.zip`. Symlinks inside folders are skipped, not followed. A file that changes size mid-send fails the transfer. - Flags may come before or after the file path. Use `--` before a file whose name starts with `-` (for example `cdx send -- -weird-name.bin`). diff --git a/cmd/cdx/agentskill/SKILL.md b/cmd/cdx/agentskill/SKILL.md index 16fcb12..58ff20f 100644 --- a/cmd/cdx/agentskill/SKILL.md +++ b/cmd/cdx/agentskill/SKILL.md @@ -11,7 +11,9 @@ Run: cdx send ... ``` -Several paths or a folder arrive as one `.zip`. +To share several files, pass them all to one `cdx send` instead of zipping +them yourself: one code delivers them, and they arrive as separate files with +folders kept. It prints one line to stdout, a short share code such as `48291`, and exits 0 while a background sender keeps the transfer live. Reply to the user with the diff --git a/cmd/cdx/bundle.go b/cmd/cdx/bundle.go index b9dac70..1513b02 100644 --- a/cmd/cdx/bundle.go +++ b/cmd/cdx/bundle.go @@ -24,6 +24,8 @@ type shareSource struct { open func() (io.ReadCloser, error) // skipped counts symlinks and special files left out of a bundle. skipped int + // files counts a bundle's regular files; zero for a single file. + files int } // bundleEntry is one file or directory inside a zip bundle. @@ -119,7 +121,13 @@ func prepareBundle(paths []string) (shareSource, error) { if err != nil { return shareSource{}, err } - return shareSource{name: bundleName(paths), size: size, skipped: skipped, open: func() (io.ReadCloser, error) { + files := 0 + for _, entry := range entries { + if !entry.dir { + files++ + } + } + return shareSource{name: bundleName(paths), size: size, skipped: skipped, files: max(files, 1), open: func() (io.ReadCloser, error) { reader, writer := io.Pipe() go func() { writer.CloseWithError(writeBundle(writer, entries, openBundleEntry)) diff --git a/cmd/cdx/main.go b/cmd/cdx/main.go index 2858fd0..ef4f72c 100644 --- a/cmd/cdx/main.go +++ b/cmd/cdx/main.go @@ -39,6 +39,9 @@ type readyOutput struct { Code string `json:"code,omitempty"` Filename string `json:"filename"` Size uint64 `json:"size"` + // Files counts the files in a multi-file send. The receiver unpacks + // them, so each one arrives on its own. + Files int `json:"files,omitempty"` } func writeReady(output io.Writer, value readyOutput, jsonOutput bool) error { @@ -57,6 +60,14 @@ func writeReceived(output io.Writer, value receiveResult, jsonOutput bool) error if jsonOutput { return json.NewEncoder(output).Encode(value) } + if len(value.Files) > 0 { + for _, file := range value.Files { + if _, err := fmt.Fprintln(output, file.Path); err != nil { + return err + } + } + return nil + } _, err := fmt.Fprintln(output, value.Path) return err } @@ -75,7 +86,7 @@ func usage(output io.Writer) { fmt.Fprintln(output, "saves the offered file and prints the saved path to stdout.") fmt.Fprintln(output, "") fmt.Fprintln(output, "commands:") - fmt.Fprintln(output, " send ... send a file, or several files and folders as one .zip") + fmt.Fprintln(output, " send ... send one or more files and folders with one code") fmt.Fprintln(output, " receive receive one file (the 4-5 digit code, or a full link)") fmt.Fprintln(output, " status [code] show background sends from this machine") fmt.Fprintln(output, " wait wait for a background send; exit 0 once the receiver verified it") @@ -88,8 +99,9 @@ func usage(output io.Writer) { fmt.Fprintln(output, "") fmt.Fprintln(output, "options for receive:") fmt.Fprintln(output, " --out save to this file or directory (default: sender's filename)") + fmt.Fprintln(output, " several files unpack into this folder (created if missing)") fmt.Fprintln(output, " --force overwrite an existing file") - fmt.Fprintln(output, " --json print {\"version\",\"filename\",\"size\",\"path\"} instead of the bare path") + fmt.Fprintln(output, " --json print {\"version\",\"filename\",\"size\",\"path\",\"files\"} instead of the bare path(s)") fmt.Fprintln(output, " -- treat the next argument as the code even if it starts with -") fmt.Fprintln(output, " -h, --help show help for receive") fmt.Fprintln(output, "") @@ -127,7 +139,7 @@ func usage(output io.Writer) { func sendUsage(output io.Writer) { fmt.Fprintln(output, "usage: cdx send [--link] [--json] [--detach|--wait] [--] ...") fmt.Fprintln(output, "") - fmt.Fprintln(output, " ... a file, or several files and folders (sent as one .zip)") + fmt.Fprintln(output, " ... a file, or several files and folders (they arrive as separate files)") fmt.Fprintln(output, " --link print a private end-to-end encrypted link instead of a share code") fmt.Fprintln(output, " --json print {\"version\",\"url\",\"code\",\"filename\",\"size\"} instead of the bare code") fmt.Fprintln(output, " --detach send in the background even from a terminal") @@ -148,8 +160,9 @@ func receiveUsage(output io.Writer) { fmt.Fprintln(output, " the 4-5 digit code from `cdx send` or a browser sender") fmt.Fprintln(output, " (a full share link works too)") fmt.Fprintln(output, " --out save to this file or directory (default: sender's filename)") + fmt.Fprintln(output, " several files unpack into this folder (created if missing)") fmt.Fprintln(output, " --force overwrite an existing file") - fmt.Fprintln(output, " --json print {\"version\",\"filename\",\"size\",\"path\"} instead of the bare path") + fmt.Fprintln(output, " --json print {\"version\",\"filename\",\"size\",\"path\",\"files\"} instead of the bare path(s)") fmt.Fprintln(output, " -- treat the next argument as the code even if it starts with -") fmt.Fprintln(output, " -h, --help show this help") fmt.Fprintln(output, "") diff --git a/cmd/cdx/receive.go b/cmd/cdx/receive.go index b94d567..5854315 100644 --- a/cmd/cdx/receive.go +++ b/cmd/cdx/receive.go @@ -100,6 +100,8 @@ type receiveResult struct { Filename string `json:"filename"` Size uint64 `json:"size"` Path string `json:"path"` + // Files lists what a multi-file send unpacked into Path, a folder. + Files []receivedFile `json:"files,omitempty"` } // parseReceivedOffer validates the sender's encrypted file offer against the @@ -262,11 +264,23 @@ func receiveFile(ctx context.Context, code, out string, force bool, onReceived f if err != nil { return err } - final, staging, err := resolveReceiveDest(offer.Name, out, force) - if err != nil { - return err + var final, staging, bundleDir string + if offer.Bundle { + // Several files: stage the zip inside the folder they unpack into. + if bundleDir, err = resolveBundleDir(out); err != nil { + return err + } + // The sender's name is never used on disk here: the zip has a fixed + // name, and each file is named by the unpacker's own checks. + final = filepath.Join(bundleDir, "cd-bundle.zip") + staging = final + ".cd-part" + fmt.Fprintf(os.Stderr, "receiving several files (%s)\n", formatBytes(size)) + } else { + if final, staging, err = resolveReceiveDest(offer.Name, out, force); err != nil { + return err + } + fmt.Fprintf(os.Stderr, "receiving %s (%s)\n", offer.Name, formatBytes(size)) } - fmt.Fprintf(os.Stderr, "receiving %s (%s)\n", offer.Name, formatBytes(size)) stagingFile, err := os.OpenFile(staging, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) if err != nil { @@ -344,6 +358,10 @@ func receiveFile(ctx context.Context, code, out string, force bool, onReceived f if err := stagingFile.Close(); err != nil { return fmt.Errorf("save file while receiving: %s", underlyingReason(err)) } + if offer.Bundle { + succeeded = true + return finishBundle(link, staging, final, bundleDir, force, offer.Name, size, chunks, received, onReceived) + } if force { _ = os.Remove(final) } @@ -375,6 +393,43 @@ func receiveFile(ctx context.Context, code, out string, force bool, onReceived f } } +// finishBundle confirms delivery, then unpacks the received zip so each file +// lands on its own. If unpacking fails the zip is kept, so nothing is lost. +func finishBundle(link *recordLink, staging, final, dir string, force bool, name string, size uint64, chunks uint32, received uint64, onReceived func(receiveResult) error) error { + if err := link.send(kindComplete, encodeCounts(chunks, received)); err != nil { + _ = os.Remove(staging) + return friendlyRelayError(fmt.Errorf("verify transfer: %w", err)) + } + link.finish(true) + files, err := unpackBundle(staging, dir, force) + if err != nil { + kept := staging + if _, statErr := os.Lstat(final); statErr != nil && os.Rename(staging, final) == nil { + kept = final + } + abs, _ := filepath.Abs(kept) + fmt.Fprintf(os.Stderr, "kept the files as one zip: %s\n", abs) + return err + } + _ = os.Remove(staging) + abs, _ := filepath.Abs(dir) + if abs == "" { + abs = dir + } + noun := "files" + if len(files) == 1 { + noun = "file" + } + fmt.Fprintf(os.Stderr, "received %d %s (%s) — saved to %s\n", len(files), noun, formatBytes(size), abs) + for _, file := range files { + fmt.Fprintf(os.Stderr, " %s\n", file.Filename) + } + if onReceived != nil { + return onReceived(receiveResult{Version: 1, Filename: name, Size: size, Path: abs, Files: files}) + } + return nil +} + func copyFile(source, dest string) error { in, err := os.Open(source) if err != nil { diff --git a/cmd/cdx/sender.go b/cmd/cdx/sender.go index a4a2cac..2602ddc 100644 --- a/cmd/cdx/sender.go +++ b/cmd/cdx/sender.go @@ -46,6 +46,10 @@ type fileOffer struct { // Direct, when present, offers a WebRTC path (see direct.go). Receivers // that predate it ignore the field. Direct *directOffer `json:"direct,omitempty"` + // Bundle marks a zip that cdx or the browser built from several files. + // Receivers that know it unpack the files; older ones keep the .zip. + Bundle bool `json:"bundle,omitempty"` + Files int `json:"files,omitempty"` } func endpointBase() (string, string, error) { @@ -300,7 +304,7 @@ func sendFile(ctx context.Context, paths []string, linkMode bool, hooks sendHook ready := readyOutput{ Version: 1, URL: publicBase + "/s/" + invitation.encodedID() + "#v1." + invitation.encodedKey(), - Filename: filename, Size: source.size, + Filename: filename, Size: source.size, Files: source.files, } if linkMode { if err := hooks.ready(ready); err != nil { @@ -455,7 +459,7 @@ func waitRelayEvent(ctx context.Context, connection *websocket.Conn, expected st // transfer streams one source over a paired link. offerSDP, when not nil, // yields the direct-path offer to embed in the file offer. func transfer(ctx context.Context, link *recordLink, file io.Reader, ready readyOutput, offerSDP <-chan string, hooks sendHooks) error { - offer := fileOffer{Name: ready.Filename, MediaType: fileMediaType(ready.Filename), Size: strconv.FormatUint(ready.Size, 10), ChunkSize: chunkSize} + offer := fileOffer{Name: ready.Filename, MediaType: fileMediaType(ready.Filename), Size: strconv.FormatUint(ready.Size, 10), ChunkSize: chunkSize, Bundle: ready.Files > 0, Files: ready.Files} if offerSDP != nil { if sdp := <-offerSDP; sdp != "" { offer.Direct = &directOffer{SDP: sdp} diff --git a/cmd/cdx/unbundle.go b/cmd/cdx/unbundle.go new file mode 100644 index 0000000..4d0fe3a --- /dev/null +++ b/cmd/cdx/unbundle.go @@ -0,0 +1,161 @@ +package main + +import ( + "archive/zip" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path" + "path/filepath" + "strings" + "unicode/utf8" +) + +// resolveBundleDir picks the folder a multi-file send unpacks into: --out +// when given (created if missing), else the current directory. +func resolveBundleDir(out string) (string, error) { + if out == "" { + return ".", nil + } + info, err := os.Stat(out) + if err == nil { + if !info.IsDir() { + return "", fmt.Errorf("%q is a file: several files are coming, so pass a folder to --out", out) + } + return out, nil + } + if !errors.Is(err, fs.ErrNotExist) { + return "", fmt.Errorf("cannot use %q: %s", out, underlyingReason(err)) + } + if err := os.MkdirAll(out, 0o755); err != nil { + return "", fmt.Errorf("cannot create %q: %s", out, underlyingReason(err)) + } + return out, nil +} + +// bundleEntryPath validates a zip entry name and returns it as a local, +// slash-separated path. Senders only produce plain relative names, so +// anything else is refused rather than repaired. +func bundleEntryPath(name string) (string, bool) { + if name == "" || !utf8.ValidString(name) || strings.Contains(name, `\`) { + return "", false + } + for _, character := range name { + if character < 0x20 || character == 0x7f { + return "", false + } + } + clean := strings.TrimSuffix(name, "/") + if clean == "" || path.Clean(clean) != clean || !filepath.IsLocal(filepath.FromSlash(clean)) { + return "", false + } + for _, part := range strings.Split(clean, "/") { + if len(part) > 255 { + return "", false + } + } + return clean, true +} + +// unpackBundle extracts a received bundle into dir, one file at a time. +// Every target is checked first, so a name clash leaves nothing half +// written. os.Root keeps writes inside dir even through symlinks. +func unpackBundle(archivePath, dir string, force bool) ([]receivedFile, error) { + archive, err := zip.OpenReader(archivePath) + if err != nil { + return nil, errors.New("the sender's files could not be unpacked") + } + defer archive.Close() + root, err := os.OpenRoot(dir) + if err != nil { + return nil, fmt.Errorf("cannot write to %q: %s", dir, underlyingReason(err)) + } + defer root.Close() + + type target struct { + file *zip.File + name string + } + var files []target + var folders []string + seen := map[string]bool{} + for _, entry := range archive.File { + name, ok := bundleEntryPath(entry.Name) + if !ok || seen[name] { + return nil, errors.New("the sender offered an unsafe filename") + } + seen[name] = true + mode := entry.Mode() + switch { + case mode.IsDir(): + folders = append(folders, name) + case mode.IsRegular(): + if entry.Method != zip.Store && entry.Method != zip.Deflate { + return nil, errors.New("the sender's files could not be unpacked") + } + if !force { + if _, err := root.Lstat(filepath.FromSlash(name)); err == nil { + return nil, fmt.Errorf("%q already exists in %s: use --out or --force to overwrite", name, dir) + } + } + files = append(files, target{file: entry, name: name}) + } + } + + for _, folder := range folders { + if err := root.MkdirAll(filepath.FromSlash(folder), 0o755); err != nil { + return nil, fmt.Errorf("cannot create %q: %s", folder, underlyingReason(err)) + } + } + absolute, _ := filepath.Abs(dir) + received := make([]receivedFile, 0, len(files)) + for _, item := range files { + local := filepath.FromSlash(item.name) + if parent := filepath.Dir(local); parent != "." { + if err := root.MkdirAll(parent, 0o755); err != nil { + return received, fmt.Errorf("cannot create %q: %s", parent, underlyingReason(err)) + } + } + if err := extractEntry(root, item.file, local); err != nil { + return received, err + } + received = append(received, receivedFile{Filename: item.name, Size: item.file.UncompressedSize64, Path: filepath.Join(absolute, local)}) + } + return received, nil +} + +func extractEntry(root *os.Root, entry *zip.File, local string) error { + source, err := entry.Open() + if err != nil { + return errors.New("the sender's files could not be unpacked") + } + defer source.Close() + permissions := fs.FileMode(0o644) + if entry.Mode()&0o111 != 0 { + permissions = 0o755 + } + staging := local + ".cd-part" + output, err := root.OpenFile(staging, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, permissions) + if err != nil { + return fmt.Errorf("cannot write %q: %s", local, underlyingReason(err)) + } + // zip's reader checks each entry's size and CRC as it reaches EOF. + _, copyErr := io.Copy(output, source) + closeErr := output.Close() + if copyErr == nil { + copyErr = closeErr + } + if copyErr == nil { + copyErr = root.Rename(staging, local) + } + if copyErr != nil { + _ = root.Remove(staging) + if errors.Is(copyErr, zip.ErrChecksum) || errors.Is(copyErr, zip.ErrFormat) { + return errors.New("the sender's files could not be unpacked") + } + return fmt.Errorf("save %q: %s", local, underlyingReason(copyErr)) + } + return nil +} diff --git a/cmd/cdx/unbundle_test.go b/cmd/cdx/unbundle_test.go new file mode 100644 index 0000000..e213a91 --- /dev/null +++ b/cmd/cdx/unbundle_test.go @@ -0,0 +1,99 @@ +package main + +import ( + "archive/zip" + "os" + "path/filepath" + "strings" + "testing" +) + +func writeTestZip(t *testing.T, path string, files map[string]string) { + t.Helper() + output, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + archive := zip.NewWriter(output) + for name, contents := range files { + writer, err := archive.CreateHeader(&zip.FileHeader{Name: name, Method: zip.Store}) + if err != nil { + t.Fatal(err) + } + if _, err := writer.Write([]byte(contents)); err != nil { + t.Fatal(err) + } + } + if err := archive.Close(); err != nil { + t.Fatal(err) + } + _ = output.Close() +} + +func TestUnpackBundleRestoresFilesAndFolders(t *testing.T) { + source := t.TempDir() + if err := os.MkdirAll(filepath.Join(source, "docs", "deep"), 0o755); err != nil { + t.Fatal(err) + } + _ = os.WriteFile(filepath.Join(source, "a.txt"), []byte("alpha"), 0o644) + _ = os.WriteFile(filepath.Join(source, "docs", "deep", "note.md"), []byte("nested"), 0o644) + bundle, err := prepareBundle([]string{filepath.Join(source, "a.txt"), filepath.Join(source, "docs")}) + if err != nil { + t.Fatal(err) + } + if bundle.files != 2 { + t.Fatalf("files = %d, want 2", bundle.files) + } + archivePath := filepath.Join(t.TempDir(), "bundle.zip") + reader, _ := bundle.open() + output, _ := os.Create(archivePath) + if _, err := output.ReadFrom(reader); err != nil { + t.Fatal(err) + } + _ = output.Close() + + dest := t.TempDir() + files, err := unpackBundle(archivePath, dest, false) + if err != nil { + t.Fatal(err) + } + if len(files) != 2 || files[0].Filename != "a.txt" || files[1].Filename != "docs/deep/note.md" { + t.Fatalf("files = %+v", files) + } + if data, _ := os.ReadFile(filepath.Join(dest, "docs", "deep", "note.md")); string(data) != "nested" { + t.Fatalf("note.md = %q", data) + } + + // A second unpack clashes and writes nothing without --force. + _, err = unpackBundle(archivePath, dest, false) + if err == nil || !strings.Contains(err.Error(), "already exists") { + t.Fatalf("err = %v, want exists", err) + } + if _, err := unpackBundle(archivePath, dest, true); err != nil { + t.Fatalf("force: %v", err) + } +} + +func TestUnpackBundleRefusesUnsafeNames(t *testing.T) { + for _, name := range []string{"../escape.txt", "/abs.txt", `a\b.txt`, "a/../../b.txt"} { + archivePath := filepath.Join(t.TempDir(), "evil.zip") + writeTestZip(t, archivePath, map[string]string{name: "x"}) + dest := t.TempDir() + if _, err := unpackBundle(archivePath, dest, false); err == nil { + t.Fatalf("%q: unpacked", name) + } + } +} + +func TestResolveBundleDir(t *testing.T) { + base := t.TempDir() + fresh := filepath.Join(base, "new", "folder") + if dir, err := resolveBundleDir(fresh); err != nil || dir != fresh { + t.Fatalf("dir = %q, err = %v", dir, err) + } + file := filepath.Join(base, "file.txt") + _ = os.WriteFile(file, nil, 0o644) + if _, err := resolveBundleDir(file); err == nil { + t.Fatal("accepted a file as the bundle folder") + } +} diff --git a/scripts/verify-agent.mjs b/scripts/verify-agent.mjs index a175853..d60fa93 100644 --- a/scripts/verify-agent.mjs +++ b/scripts/verify-agent.mjs @@ -125,8 +125,8 @@ try { await rm(work, { recursive: true, force: true }); } -// A folder plus a file go out detached as one streamed zip; pasting the link -// into the home page's Receive box receives it in place, without leaving. +// A folder plus a file go out detached under one code; pasting the link into +// the home page's Receive box downloads each file on its own, in place. async function verifyFolderBundle(context) { const folder = join(work, 'bundle folder'); await mkdir(join(folder, 'nested'), { recursive: true }); @@ -141,18 +141,20 @@ async function verifyFolderBundle(context) { const page = await context.newPage(); await page.goto(publicBase); await page.locator('#receive-mode-btn').click(); - const downloadEvent = page.waitForEvent('download'); + const downloads = []; + const onDownload = (download) => downloads.push(download); + page.on('download', onDownload); await page.locator('#code-input').fill(bundleLink); - const download = await downloadEvent; - assert.equal(download.suggestedFilename(), 'bundle folder-and-1-more.zip'); - const bundlePath = join(work, 'received-bundle.zip'); - await download.saveAs(bundlePath); - const bundle = await readFile(bundlePath); - assert.deepEqual([...bundle.subarray(0, 4)], [0x50, 0x4b, 0x03, 0x04], 'bundle is not a zip'); - assert.ok(bundle.includes(Buffer.from('bundle folder/nested/a.bin')), 'bundle is missing the nested file'); + for (let waited = 0; downloads.length < 2 && waited < 300; waited += 1) await page.waitForTimeout(100); + page.off('download', onDownload); + assert.deepEqual(downloads.map((download) => download.suggestedFilename()).sort(), ['a.bin', 'extra.txt'], 'folder send did not arrive as separate files'); + const nested = downloads.find((download) => download.suggestedFilename() === 'a.bin'); + const nestedPath = join(work, 'received-a.bin'); + await nested.saveAs(nestedPath); + assert.deepEqual(await readFile(nestedPath), Buffer.from(source), 'nested file differs from the original'); assert.equal(new URL(page.url()).pathname, '/', 'receiving left the home page'); await page.close(); - console.log(`verified a ${bundle.byteLength}-byte folder bundle into the browser`); + console.log(`verified a folder send arrived as ${downloads.length} separate files in the browser`); } async function assertVisibleBrand(page) { diff --git a/scripts/verify-browser.mjs b/scripts/verify-browser.mjs index 2c819cb..a654679 100644 --- a/scripts/verify-browser.mjs +++ b/scripts/verify-browser.mjs @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { execFileSync, spawn } from 'node:child_process'; +import { spawn } from 'node:child_process'; import { createHash } from 'node:crypto'; import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; @@ -102,23 +102,22 @@ try { await context.close(); } - // 2. Browser -> cdx receive, several files as one zip. + // 2. Browser -> cdx receive, several files arrive as separate files. { const { context, page, code } = await startSend(batchPaths); - const outDir = join(work, 'zip'); + const outDir = join(work, 'unpacked'); await mkdir(outDir); const receiver = spawn(executable, ['receive', code, '--out', outDir, '--json'], { env: childEnv, stdio: ['ignore', 'pipe', 'pipe'] }); const stdout = collect(receiver.stdout); const stderr = collect(receiver.stderr); assert.equal(await withTimeout(childExit(receiver), 60_000, 'cdx receive did not finish'), 0, await stderr); - const { path } = JSON.parse(await stdout); - assert.match(path, /cd-\d{8}-\d{6}\.zip$/); - execFileSync('unzip', ['-q', path, '-d', join(outDir, 'unzipped')]); + const { files } = JSON.parse(await stdout); + assert.deepEqual(files.map((file) => file.filename).sort(), batch.map(([name]) => name).sort()); for (const [name, bytes] of batch) { - assert.equal(sha(await readFile(join(outDir, 'unzipped', name))), sha(bytes), `${name} differs`); + assert.equal(sha(await readFile(join(outDir, name))), sha(bytes), `${name} differs`); } await page.locator('#sender-complete:not(.hidden)').waitFor(); - console.log('verified browser->terminal: three files arrive as one exact zip'); + console.log('verified browser->terminal: three files arrive as separate exact files'); await context.close(); } diff --git a/scripts/verify-multi-file.mjs b/scripts/verify-multi-file.mjs new file mode 100644 index 0000000..c9099e5 --- /dev/null +++ b/scripts/verify-multi-file.mjs @@ -0,0 +1,99 @@ +// Sends several files with one code in both directions against a running +// site: `cdx send` → browser Receive box, and browser picker → `cdx receive`. +// Each file must arrive on its own, byte for byte. +// +// CD_BASE=http://127.0.0.1:8787 CDX=./bin/cdx node scripts/verify-multi-file.mjs +import assert from 'node:assert/strict'; +import { execFile, spawn } from 'node:child_process'; +import { access, mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { randomBytes } from 'node:crypto'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { chromium } from 'playwright-core'; + +const base = (process.env.CD_BASE || 'http://127.0.0.1:8787').replace(/\/$/, ''); +const cdx = resolve(process.env.CDX || './bin/cdx'); +const env = { + ...process.env, + CD_PUBLIC_URL: base, + CD_RELAY_URL: base.replace(/^http/, 'ws') + '/ws/v1', + CD_NO_UPDATE_CHECK: '1' +}; + +const work = await mkdtemp(join(tmpdir(), 'cd-multi-')); +const source = join(work, 'source'); +await mkdir(join(source, 'notes'), { recursive: true }); +const files = { + 'report.txt': Buffer.from('quarterly numbers\n'), + 'photo.bin': randomBytes(3 * 1024 * 1024 + 17), + 'notes/todo.md': Buffer.from('- ship multi-file\n') +}; +for (const [name, bytes] of Object.entries(files)) await writeFile(join(source, name), bytes); + +function run(args, options = {}) { + return new Promise((resolve, reject) => { + execFile(cdx, args, { env, cwd: source, ...options }, (error, stdout, stderr) => { + if (error) reject(new Error(`${args.join(' ')}: ${stderr || error.message}`)); + else resolve(stdout.trim()); + }); + }); +} + +async function findChromium() { + for (const candidate of [process.env.CHROMIUM_PATH, '/usr/bin/chromium', '/usr/bin/google-chrome-stable', '/usr/bin/chromium-browser'].filter(Boolean)) { + try { await access(candidate); return candidate; } catch {} + } + throw new Error('Chromium was not found; set CHROMIUM_PATH'); +} + +const browser = await chromium.launch({ executablePath: await findChromium() }); +try { + // 1. cdx send → browser. + const code = await run(['send', 'report.txt', 'photo.bin', 'notes']); + assert.match(code, /^\d{4,5}$/); + const context = await browser.newContext({ acceptDownloads: true }); + const page = await context.newPage(); + const downloads = []; + page.on('download', (download) => downloads.push(download)); + await page.goto(base); + await page.click('#receive-mode-btn'); + await page.fill('#code-input', code); + if (await page.locator('#connect-btn').isVisible()) await page.click('#connect-btn').catch(() => {}); + const accept = page.locator('[data-relay="accept"]'); + await Promise.race([ + accept.waitFor({ state: 'visible', timeout: 20_000 }).then(() => accept.click()).catch(() => {}), + page.locator('.bundle-files li').first().waitFor({ timeout: 20_000 }) + ]); + await page.locator('.bundle-files li').nth(2).waitFor({ timeout: 30_000 }); + const listed = await page.locator('.bundle-files a').allTextContents(); + assert.deepEqual(listed.sort(), Object.keys(files).sort()); + if (downloads.length === 0) await page.click('[data-relay="download"]'); + for (let waited = 0; downloads.length < 3 && waited < 50; waited += 1) await page.waitForTimeout(100); + assert.equal(downloads.length, 3, 'browser should download three separate files'); + for (const download of downloads) { + const name = download.suggestedFilename(); + const original = Object.keys(files).find((path) => path.endsWith(name)); + assert.ok(original, `unexpected download ${name}`); + assert.deepEqual(await readFile(await download.path()), files[original], name); + } + console.log(`ok cdx send → browser: ${downloads.map((download) => download.suggestedFilename()).join(', ')}`); + await context.close(); + + // 2. browser → cdx receive. + const senderPage = await browser.newPage(); + await senderPage.goto(base); + await senderPage.setInputFiles('#file-input', Object.keys(files).map((name) => join(source, name))); + const shareCode = senderPage.locator('#share-code'); + await shareCode.filter({ hasText: /^\d{4,5}$/ }).waitFor({ timeout: 20_000 }); + const browserCode = (await shareCode.textContent()).trim(); + const out = join(work, 'from-browser'); + const result = JSON.parse(await run(['receive', '--json', '--out', out, browserCode])); + assert.equal(result.files.length, 3); + for (const [name, bytes] of Object.entries(files)) { + const flat = name.slice(name.lastIndexOf('/') + 1); + assert.deepEqual(await readFile(join(out, flat)), bytes, flat); + } + console.log(`ok browser → cdx receive: ${result.files.map((file) => file.filename).join(', ')}`); +} finally { + await browser.close(); +} diff --git a/skills/cd-file-sharing/SKILL.md b/skills/cd-file-sharing/SKILL.md index 16fcb12..58ff20f 100644 --- a/skills/cd-file-sharing/SKILL.md +++ b/skills/cd-file-sharing/SKILL.md @@ -11,7 +11,9 @@ Run: cdx send ... ``` -Several paths or a folder arrive as one `.zip`. +To share several files, pass them all to one `cdx send` instead of zipping +them yourself: one code delivers them, and they arrive as separate files with +folders kept. It prints one line to stdout, a short share code such as `48291`, and exits 0 while a background sender keeps the transfer live. Reply to the user with the diff --git a/src/main.js b/src/main.js index 40dcf1b..434585c 100644 --- a/src/main.js +++ b/src/main.js @@ -316,7 +316,7 @@ function updateProgress(container, bytes, total, startedAt, force, lastUpdateRef // Browser sends go through the relay with the same protocol as `cdx send`, // so one numeric code works in any browser Receive box and in -// `cdx receive`. Several files travel as one .zip. +// `cdx receive`. Several files travel as one .zip that receivers unpack. const Sender = (() => { let active = null; let files = []; diff --git a/src/relay-sender.js b/src/relay-sender.js index 8875fc4..356b70e 100644 --- a/src/relay-sender.js +++ b/src/relay-sender.js @@ -241,7 +241,8 @@ export function startRelaySend(source, hooks = {}) { name: source.name, mediaType: source.type || 'application/octet-stream', size: String(source.size), - chunkSize: CHUNK_SIZE + chunkSize: CHUNK_SIZE, + ...(source.bundle ? { bundle: true, files: source.files } : {}) }))); await waitForRecord(opener, [KIND_ACCEPT], CONSENT_WAIT_MS, 'The receiver did not accept within 10 minutes.'); diff --git a/src/share.js b/src/share.js index 2fb6940..6a35631 100644 --- a/src/share.js +++ b/src/share.js @@ -15,7 +15,8 @@ import { parseInvitation, receiverAdmission } from './agent-protocol.js'; -import { createSink as createDownloadSink } from './sink.js'; +import { createSink as createDownloadSink, createStageSink, downloadUrlFor } from './sink.js'; +import { downloadNames, readBundle } from './zip-unbundle.js'; import { answerDirect, createRecordMerger, parseDirectOffer } from './direct-link.js'; const ACK_INTERVAL = 1024n * 1024n; @@ -38,7 +39,8 @@ const PANEL_HTML = ` 0% - `; + + `; function formatSize(bytes) { const value = Number(bytes); @@ -60,7 +62,10 @@ function parseOffer(plaintext) { if (typeof size !== 'string' || !/^(0|[1-9][0-9]{0,19})$/.test(size)) throw new Error('The sender offered an invalid file size.'); const byteSize = BigInt(size); if (byteSize > 0xffffffffffffffffn || (chunkSize !== 64 * 1024 && chunkSize !== 256 * 1024)) throw new Error('The sender uses unsupported transfer limits.'); - return { name, mediaType, size: byteSize, directSdp: parseDirectOffer(value) }; + // A bundle is several files sent as one zip; it is unpacked on arrival. + const bundle = value.bundle === true; + const files = bundle && Number.isSafeInteger(value.files) && value.files > 0 ? value.files : 0; + return { name, mediaType, size: byteSize, bundle, files, directSdp: parseDirectOffer(value) }; } function encodeCounts(chunks, bytes) { @@ -94,7 +99,8 @@ export function mountRelayReceiver(container, shareUrl, { autoReceive = false } progressBar: pick('progress').querySelector('.progress-bar'), progressFill: pick('progress').querySelector('.progress-fill'), progressCopy: pick('progress-copy'), - download: pick('download') + download: pick('download'), + files: pick('files') }; let failureShown = false; // Leaving before the socket exists must still stop receive() from joining. @@ -102,11 +108,83 @@ export function mountRelayReceiver(container, shareUrl, { autoReceive = false } let stop = () => { stopped = true; failureShown = true; }; async function createSink(offer) { + if (offer.bundle) { + // Stage the whole zip (OPFS when available), then hand out its files. + const staged = await createStageSink({ name: offer.name, size: offer.size }); + return { + kind: 'bundle', + write: (bytes) => staged.write(bytes), + close: () => staged.toFile('application/zip'), + abort: () => staged.discard() + }; + } return createDownloadSink({ name: offer.name, size: offer.size, mediaType: offer.mediaType, preferDownload: autoReceive }); } + function offerLabel(offer) { + if (!offer.bundle) return offer.name; + return offer.files > 1 ? `${offer.files} files` : 'Several files'; + } + + // Lists each file from a received bundle with its own download link. If the + // archive cannot be read, the .zip itself is offered instead. + async function showBundle(offer, staged) { + let files; + try { files = await readBundle(staged.file); } + catch { + const result = downloadUrlFor(staged.file, offer.name, staged.cleanup); + elements.download.href = result.url; + elements.download.download = offer.name; + elements.download.textContent = `Download ${offer.name}`; + elements.download.hidden = false; + elements.status.textContent = 'Files verified. Download them as one zip.'; + window.addEventListener('pagehide', result.revoke, { once: true }); + return; + } + const names = downloadNames(files.map((file) => file.path)); + const links = files.map((file, index) => { + const url = URL.createObjectURL(file.blob); + const item = document.createElement('li'); + const link = document.createElement('a'); + link.href = url; + link.download = names[index]; + link.textContent = file.path; + const size = document.createElement('span'); + size.textContent = formatSize(file.size); + item.append(link, size); + elements.files.append(item); + return link; + }); + window.addEventListener('pagehide', () => { + for (const link of links) URL.revokeObjectURL(link.href); + void Promise.resolve(staged.cleanup()).catch(() => {}); + }, { once: true }); + const noun = files.length === 1 ? 'file' : 'files'; + elements.files.hidden = false; + elements.download.textContent = files.length === 1 ? 'Download file' : `Download all ${files.length} files`; + elements.download.removeAttribute('href'); + elements.download.hidden = files.length === 0; + elements.status.textContent = `${files.length} ${noun} verified and ready to download.`; + // Browsers may ask once before allowing several downloads; spacing + // the clicks keeps them from being dropped. + const downloadAll = async () => { + for (const link of links) { + link.click(); + await new Promise((resolve) => setTimeout(resolve, 300)); + } + }; + elements.download.addEventListener('click', (event) => { + event.preventDefault(); + void downloadAll(); + }); + if (autoReceive) { + await downloadAll(); + elements.status.textContent = `${files.length} ${noun} verified. Downloads started; use the links below if your browser blocked any.`; + } + } + function waitForAcceptance(offer) { - elements.fileName.textContent = offer.name; + elements.fileName.textContent = offerLabel(offer); elements.fileSize.textContent = formatSize(offer.size); elements.offer.hidden = false; if (autoReceive) { @@ -114,7 +192,10 @@ export function mountRelayReceiver(container, shareUrl, { autoReceive = false } elements.status.textContent = 'Preparing your download...'; return createSink(offer); } - elements.status.textContent = 'Ready when you are. The file stays with the sender until you accept.'; + if (offer.bundle) elements.accept.textContent = 'Receive files'; + elements.status.textContent = offer.bundle + ? 'Ready when you are. The files stay with the sender until you accept.' + : 'Ready when you are. The file stays with the sender until you accept.'; return new Promise((resolve, reject) => { elements.accept.addEventListener('click', async () => { elements.accept.disabled = true; @@ -287,6 +368,8 @@ export function mountRelayReceiver(container, shareUrl, { autoReceive = false } async function processMessage(data) { if (typeof data === 'string') { + // The sender leaving after delivery is expected, not a failure. + if (state === 'complete') return; const value = JSON.parse(data); if (value.protocol !== 'cd-transfer-v1') throw new Error('The sender uses an incompatible CD version.'); // Once the direct path carries the transfer, the relay is optional. @@ -305,7 +388,7 @@ export function mountRelayReceiver(container, shareUrl, { autoReceive = false } return; } elements.offer.hidden = true; - elements.status.textContent = `Receiving ${offer.name}...`; + elements.status.textContent = `Receiving ${offerLabel(offer)}...`; state = 'receiving'; renderProgress(0n, offer.size, true); await send(KIND_ACCEPT); @@ -330,7 +413,8 @@ export function mountRelayReceiver(container, shareUrl, { autoReceive = false } state = 'complete'; renderProgress(received, offer.size, true); await send(KIND_COMPLETE, encodeCounts(chunks, received)); - if (sink.kind === 'download') { + if (sink.kind === 'bundle') await showBundle(offer, result); + else if (sink.kind === 'download') { elements.download.href = result.url; elements.download.download = offer.name; elements.download.textContent = `Download ${offer.name}`; diff --git a/src/style.css b/src/style.css index e16ef58..25416bd 100644 --- a/src/style.css +++ b/src/style.css @@ -646,6 +646,10 @@ html.scanning, html.scanning body { overflow: hidden; } .share-transfer-page .share-panel { margin-top: 0; padding-top: 20px; border-top: 0; } .share-transfer-page .status { margin-top: 0; } .share-download { margin-top: 16px; } +.bundle-files { display: grid; gap: 6px; margin: 14px 0 0; padding: 12px 0 0; list-style: none; border-top: 1px solid var(--line); } +.bundle-files li { display: flex; justify-content: space-between; gap: 14px; min-width: 0; } +.bundle-files a { min-width: 0; overflow-wrap: anywhere; color: inherit; } +.bundle-files span { flex: none; color: var(--muted); font: 12px var(--mono); } .agent-offer { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 5px 14px; margin-top: 16px; padding: 16px 0 0; border-top: 1px solid var(--line); } .agent-offer strong { min-width: 0; overflow-wrap: anywhere; } .agent-offer span { grid-column: 1; color: var(--muted); font: 12px var(--mono); } diff --git a/src/zip-bundle.js b/src/zip-bundle.js index 7419b51..5dd3a19 100644 --- a/src/zip-bundle.js +++ b/src/zip-bundle.js @@ -140,7 +140,7 @@ export function createZipBundle(files, { now = new Date(), chunkBytes = 256 * 10 yield end; } - return { name: bundleName(now), type: 'application/zip', size, stream }; + return { name: bundleName(now), type: 'application/zip', size, stream, bundle: true, files: files.length }; } // fileSource streams one file as-is. diff --git a/src/zip-unbundle.js b/src/zip-unbundle.js new file mode 100644 index 0000000..8fa09fa --- /dev/null +++ b/src/zip-unbundle.js @@ -0,0 +1,112 @@ +// Reads a received multi-file bundle (an uncompressed .zip from `cdx send +// a b c` or a browser sender) back into its files. Each file is a lazy +// slice of the received Blob, so nothing is copied into memory and the +// staged bytes back every download. + +const END = 0x06054b50; +const END_BYTES = 22; +const ZIP64_LOCATOR = 0x07064b50; +const ZIP64_END = 0x06064b50; +const CENTRAL = 0x02014b50; +const LOCAL = 0x04034b50; +const MAX_COMMENT = 0xffff; +const decoder = new TextDecoder('utf-8', { fatal: true }); + +async function bytesAt(blob, start, length) { + const end = Math.min(blob.size, start + length); + if (start < 0 || end - start !== length) throw new Error('bundle is truncated'); + return new DataView(await blob.slice(start, end).arrayBuffer()); +} + +// safeEntryPath rejects absolute, parent-relative, and control-character +// names. Senders only write plain relative paths. +export function safeEntryPath(name) { + const clean = name.endsWith('/') ? name.slice(0, -1) : name; + if (!clean || /[\\\u0000-\u001f\u007f]/.test(clean) || clean.startsWith('/')) return null; + const parts = clean.split('/'); + if (parts.some((part) => !part || part === '.' || part === '..' || new TextEncoder().encode(part).byteLength > 255)) return null; + return clean; +} + +// readBundle returns [{ path, size, blob }] for every file in the archive. +// Folders are implied by paths. Throws on anything it cannot read safely; +// callers fall back to offering the .zip itself. +export async function readBundle(blob) { + const tailLength = Math.min(blob.size, END_BYTES + MAX_COMMENT); + const tail = await bytesAt(blob, blob.size - tailLength, tailLength); + let endAt = -1; + for (let index = tailLength - END_BYTES; index >= 0; index -= 1) { + if (tail.getUint32(index, true) === END) { endAt = index; break; } + } + if (endAt < 0) throw new Error('bundle has no directory'); + let count = tail.getUint16(endAt + 10, true); + let directorySize = tail.getUint32(endAt + 12, true); + let directoryOffset = tail.getUint32(endAt + 16, true); + if (count === 0xffff || directorySize === 0xffffffff || directoryOffset === 0xffffffff) { + const locatorAt = blob.size - tailLength + endAt - 20; + const locator = await bytesAt(blob, locatorAt, 20); + if (locator.getUint32(0, true) !== ZIP64_LOCATOR) throw new Error('bundle directory is invalid'); + const zip64 = await bytesAt(blob, Number(locator.getBigUint64(8, true)), 56); + if (zip64.getUint32(0, true) !== ZIP64_END) throw new Error('bundle directory is invalid'); + count = Number(zip64.getBigUint64(32, true)); + directorySize = Number(zip64.getBigUint64(40, true)); + directoryOffset = Number(zip64.getBigUint64(48, true)); + } + const directory = await bytesAt(blob, directoryOffset, directorySize); + const files = []; + const seen = new Set(); + let at = 0; + for (let entry = 0; entry < count; entry += 1) { + if (directory.getUint32(at, true) !== CENTRAL) throw new Error('bundle directory is invalid'); + const method = directory.getUint16(at + 10, true); + let compressed = directory.getUint32(at + 20, true); + let size = directory.getUint32(at + 24, true); + const nameLength = directory.getUint16(at + 28, true); + const extraLength = directory.getUint16(at + 30, true); + const commentLength = directory.getUint16(at + 32, true); + let offset = directory.getUint32(at + 42, true); + const nameBytes = new Uint8Array(directory.buffer, directory.byteOffset + at + 46, nameLength); + const name = decoder.decode(nameBytes); + // Zip64 extra field: 64-bit values replace whichever are saturated. + let extra = at + 46 + nameLength; + const extraEnd = extra + extraLength; + while (extra + 4 <= extraEnd) { + const id = directory.getUint16(extra, true); + const length = directory.getUint16(extra + 2, true); + if (id === 0x0001) { + let field = extra + 4; + if (size === 0xffffffff) { size = Number(directory.getBigUint64(field, true)); field += 8; } + if (compressed === 0xffffffff) { compressed = Number(directory.getBigUint64(field, true)); field += 8; } + if (offset === 0xffffffff) { offset = Number(directory.getBigUint64(field, true)); } + } + extra += 4 + length; + } + at = extraEnd + commentLength; + + const path = safeEntryPath(name); + if (!path || seen.has(path)) throw new Error('bundle has an unsafe filename'); + seen.add(path); + if (name.endsWith('/')) continue; + if (method !== 0 || compressed !== size) throw new Error('bundle is compressed'); + const local = await bytesAt(blob, offset, 30); + if (local.getUint32(0, true) !== LOCAL) throw new Error('bundle entry is invalid'); + const start = offset + 30 + local.getUint16(26, true) + local.getUint16(28, true); + if (start + size > directoryOffset) throw new Error('bundle entry is invalid'); + files.push({ path, size, blob: blob.slice(start, start + size) }); + } + return files; +} + +// downloadNames gives each file a flat download name: its own name, or its +// folder path joined with "_" when two files would otherwise collide. +export function downloadNames(paths) { + const counts = new Map(); + for (const path of paths) { + const base = path.slice(path.lastIndexOf('/') + 1); + counts.set(base, (counts.get(base) || 0) + 1); + } + return paths.map((path) => { + const base = path.slice(path.lastIndexOf('/') + 1); + return counts.get(base) > 1 ? path.replaceAll('/', '_') : base; + }); +} diff --git a/src/zip-unbundle.test.js b/src/zip-unbundle.test.js new file mode 100644 index 0000000..f35792a --- /dev/null +++ b/src/zip-unbundle.test.js @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { createZipBundle } from './zip-bundle.js'; +import { downloadNames, readBundle, safeEntryPath } from './zip-unbundle.js'; + +async function collect(iterable) { + const parts = []; + for await (const part of iterable) parts.push(part); + return new Blob(parts); +} + +test('readBundle returns every file from a browser bundle', async () => { + const big = new Uint8Array(300_000).map((_, index) => index % 251); + const files = [new File(['hello'], 'a.txt'), new File([big], 'big.bin'), new File([], 'empty'), new File(['dup'], 'a.txt')]; + const read = await readBundle(await collect(createZipBundle(files, { chunkBytes: 4096 }).stream())); + assert.deepEqual(read.map((file) => [file.path, file.size]), [['a.txt', 5], ['big.bin', 300_000], ['empty', 0], ['a (2).txt', 3]]); + assert.equal(await read[0].blob.text(), 'hello'); + assert.deepEqual(new Uint8Array(await read[1].blob.arrayBuffer()), big); +}); + +test('readBundle reads folders from a Go-built bundle', async (t) => { + const dir = mkdtempSync(join(tmpdir(), 'cd-unbundle-')); + mkdirSync(join(dir, 'docs', 'deep'), { recursive: true }); + writeFileSync(join(dir, 'docs', 'deep', 'note.md'), 'nested'); + writeFileSync(join(dir, 'top.txt'), 'top'); + try { + execFileSync('zip', ['-0', '-qr', 'out.zip', 'docs', 'top.txt'], { cwd: dir }); + } catch { + t.skip('zip is not installed'); + return; + } + const read = await readBundle(new Blob([readFileSync(join(dir, 'out.zip'))])); + assert.deepEqual(read.map((file) => file.path).sort(), ['docs/deep/note.md', 'top.txt']); + assert.equal(await read.find((file) => file.path === 'top.txt').blob.text(), 'top'); +}); + +test('readBundle refuses archives it cannot hand out as-is', async () => { + await assert.rejects(readBundle(new Blob(['not a zip']))); +}); + +test('safeEntryPath rejects escapes and control characters', () => { + assert.equal(safeEntryPath('a/b.txt'), 'a/b.txt'); + assert.equal(safeEntryPath('folder/'), 'folder'); + for (const name of ['../x', '/etc/passwd', 'a/../b', 'a\\b', 'a\u0000b', '', 'a//b', './a']) assert.equal(safeEntryPath(name), null, name); +}); + +test('downloadNames flattens only colliding paths', () => { + assert.deepEqual(downloadNames(['a.txt', 'x/b.txt', 'y/b.txt']), ['a.txt', 'x_b.txt', 'y_b.txt']); +});