Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file>`, share the 5-digit code, and the receiver
types it into the **Receive** box, scans the QR code, or runs `cdx receive <code>`. Several files arrive as one .zip.
types it into the **Receive** box, scans the QR code, or runs `cdx receive <code>`. Several files share one code and arrive as separate files.

CD never stores files. Transfers are live and end-to-end encrypted.

Expand All @@ -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
Expand Down
6 changes: 4 additions & 2 deletions cmd/cdx/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
4 changes: 3 additions & 1 deletion cmd/cdx/agentskill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ Run:
cdx send <path>...
```

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
Expand Down
10 changes: 9 additions & 1 deletion cmd/cdx/bundle.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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))
Expand Down
21 changes: 17 additions & 4 deletions cmd/cdx/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
Expand All @@ -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 <path>... send a file, or several files and folders as one .zip")
fmt.Fprintln(output, " send <path>... send one or more files and folders with one code")
fmt.Fprintln(output, " receive <code> 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 <code> wait for a background send; exit 0 once the receiver verified it")
Expand All @@ -88,8 +99,9 @@ func usage(output io.Writer) {
fmt.Fprintln(output, "")
fmt.Fprintln(output, "options for receive:")
fmt.Fprintln(output, " --out <path> 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, "")
Expand Down Expand Up @@ -127,7 +139,7 @@ func usage(output io.Writer) {
func sendUsage(output io.Writer) {
fmt.Fprintln(output, "usage: cdx send [--link] [--json] [--detach|--wait] [--] <file|folder>...")
fmt.Fprintln(output, "")
fmt.Fprintln(output, " <path>... a file, or several files and folders (sent as one .zip)")
fmt.Fprintln(output, " <path>... 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")
Expand All @@ -148,8 +160,9 @@ func receiveUsage(output io.Writer) {
fmt.Fprintln(output, " <code> 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 <path> 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, "")
Expand Down
63 changes: 59 additions & 4 deletions cmd/cdx/receive.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@
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
Expand Down Expand Up @@ -262,11 +264,23 @@
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 {
Expand Down Expand Up @@ -344,6 +358,10 @@
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)
}
Expand Down Expand Up @@ -375,6 +393,43 @@
}
}

// 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)
Comment thread
YashasVM marked this conversation as resolved.
Dismissed
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)
Comment thread
YashasVM marked this conversation as resolved.
Dismissed
Comment thread
YashasVM marked this conversation as resolved.
Dismissed
Comment thread
YashasVM marked this conversation as resolved.
Dismissed
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
Comment thread
YashasVM marked this conversation as resolved.
Dismissed
}
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 {
Expand Down
8 changes: 6 additions & 2 deletions cmd/cdx/sender.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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}
Expand Down
Loading
Loading