Skip to content

dctl copy

Copy files from source to destination, skipping identical files.

Synopsis#

dctl copy makes the destination a superset of the source. Every file the source has and the destination lacks is transferred; every file that differs is re-transferred; every file that already matches is skipped; and every file that exists only at the destination is left exactly where it is. That last clause is the entire difference between copy and sync, and it is why copy is the verb to reach for when you are not sure which one you want.

copy is not classified destructive. It never removes a destination file, so it never prompts, never needs --force, and cannot be talked into emptying a tree by a mistyped source. The worst outcome of a wrong SOURCE is that the wrong data lands somewhere; nothing that was already there is lost.

Both arguments are containers. dctl copy ./photos vault:photos/2024 places the contents of ./photos under vault:photos/2024, preserving relative paths — ./photos/raw/a.cr3 becomes vault:photos/2024/raw/a.cr3. A SOURCE that names a single file is allowed and lands inside DEST under its own name, matching rclone: dctl copy report.pdf vault:archive writes vault:archive/report.pdf. When you want DEST to be the object's name rather than the directory it goes in, use copyto. Both kinds of remote address exactly as written: the path after the colon is applied to the stored key for a plain remote and for a vault alike. It was not always so, and What runs today records what this page claimed while it was not.

What "identical" means. The decision is made once, per file, by the shared comparison rules — the same code sync uses, which is what stops the two verbs from disagreeing about which files are current. The rules apply in this order:

  1. Nothing at the destination means copy. No flag overrides this; a flag that skipped a file the destination does not have would silently lose data.
  2. --ignore-existing skips anything already present, without comparing it.
  3. --update skips anything whose destination copy is newer than the source.
  4. Otherwise the configured comparison decides: size and modification time by default (with a one-second tolerance, so filesystems with different timestamp granularity do not re-transfer everything); content hashes under the global --checksum; size alone under the global --size-only.

A size difference always wins over a matching timestamp. A destination whose timestamp cannot be read is treated as modified rather than identical — the safe direction, because re-transferring costs bandwidth and skipping costs data.

What a modification time means here, and the one place it is not carried. Every transfer records the source's modification time at the destination, so a file that has not changed compares equal on the next run and is skipped. A vault destination stores it in the index record beside the object; a filesystem destination has it stamped on the file before it is published. That is what makes copy, sync and check incremental, in both directions: dctl copy ./src archive: and dctl copy archive: ./restored both transfer nothing the second time they run.

It was not always so, and the failure is worth recording. dctl-core's put_file used to take a logical path and the plaintext and nothing else, so the index record it committed stamped the time of the write. That number is true and describes something else, which made the default comparison unanswerable against a vault: dctl copy ./src archive: re-uploaded every file on every run, forever, and dctl check ./src archive: immediately afterwards called the tree it had just stored entirely different.

For a while the answer was a substitution — against a vault the default silently became a content comparison, announced on stderr, and it worked at the price of reading and hashing the other side in full on every run. On a nightly backup that is close to the cost of the transfer it was avoiding. The write now takes the time (dctl_core::Modified), so the substitution, the module that decided it and the warning that announced it are all gone. If you have a script keying on that warning, it will no longer appear.

The limit this leaves. Size-and-modification-time cannot see an edit that changes neither — a file rewritten in place, byte-for-byte the same length, with its timestamp restored afterwards. That is the same trade rclone and rsync make, and --checksum is the answer: it compares the plaintext BLAKE3 the vault recorded at write time against a hash of the local file.

A plain remote carries it too, and that was the second half of the defect. Backend::put used to store bytes under a key with no parameter for a time, and the page you are reading claimed a bucket could not accept one. That was wrong. A local file's inode takes it, an SFTP SETSTAT takes it, and B2 has a documented src_last_modified_millis file-info field that rclone has used for years. All three now do, in both directions, so dctl sync ./src backup: and dctl sync backup: ./restored are each incremental after their first run.

The one provider pair this does not reach is S3 and R2. The time is written (x-amz-meta-mtime, rclone's spelling) and head reads it back, but ListObjectsV2 does not return user metadata and a transfer compares against the listing — so an S3 or R2 destination still re-transfers an unchanged tree. Closing it means one HEAD per listed object, which is a per-object request against a provider that bills them and therefore a cost decision to take deliberately.

Vaults written by an earlier build. Their index rows hold write times. The first run of this build against one finds those rows disagreeing with the sources and re-transfers the tree once; every run after that skips, because the re-transfer writes the source's time. Nothing compares wrongly in the meantime — a write time and a source time agree only when the file really was stored the second it was last modified, in which case skipping it is correct anyway. dctl index rebuild writes rows with no time at all, which compare as "not comparable" and are re-transferred for the same one-off reason.

The verified-write contract. DCTL never reports a file as stored until it is. What "stored" means depends on where the file is going, so both directions are spelled out rather than described as one thing.

Into a vault. The plaintext is read, sealed, and written to the backend under its object key by a verified write: the sealed bytes are hashed before they are sent and the stored object is compared against that hash. The local backend goes further — it writes a temporary file, fsyncs it, reads it back off the disk, compares again, and only then publishes it with an atomic rename, so a half-written object is never visible under its real key. Only after that write returns is the index record committed — and the index commit is what makes the file exist as far as DCTL is concerned. dctl-core's put_file returns Ok only once all of it has happened, so there is no window in which an object is stored but uncommitted. The "Files" counter is incremented after it returns, never before.

To a local path. The destination's parent directories are created, the file is written, and fsync is awaited before success is reported — a write still sitting in the page cache has not survived a power cut. Note what this is not: the destination is written in place rather than staged under a temporary name and renamed, so an interrupted overwrite can leave a destination file truncated. The source is never touched by that, which is what keeps move safe.

The five progress stages are honest about where the work happens. read, encrypt, upload, verify, commit are real positions in the pipeline and the bar reports the one a file is actually at. For a vault destination the seal, the verified write and the index commit are a single core operation performed at upload; encrypt and commit therefore do no work of their own and claim none. That is a stronger guarantee than performing them separately, not a weaker one — there is no moment at which bytes are uploaded but uncommitted. The stages separate again when dctl-core grows the streaming API of PLAN.md §16.2, without any change to the commands above it.

What happens on a checksum mismatch. For a vault destination the comparison is inside the verified write, so a mismatch aborts during the upload stage — before the index commit. Nothing is committed, nothing is reported as transferred, and the run exits 20 (checksum_mismatch) rather than the generic error code, so a script can tell corruption apart from a timeout. It is a per-file failure: the remaining files are still attempted, and the mismatch still decides the process exit code at the end.

What --verify actually does. Be precise about this, because the three names promise more separation than the engine currently delivers:

  • checksum (the default) adds nothing to the verified write described above. Against a filesystem destination that means it adds nothing at all — there is no second party holding a checksum to disagree with.
  • sample and strict are identical today, and both are a full read-back. On an upload each file is fetched again, decrypted, and its plaintext BLAKE3 compared with the hash the index recorded; a failure exits 21 (integrity_failure) with read-back verification failed for '<path>'. That is the egress PLAN.md §12 says must be opt-in — it downloads everything just uploaded.
  • --verify-samples parses and is not consulted. Partial sampling does not exist yet, which is why sample costs exactly what strict costs.
  • Filesystem-to-filesystem, verify does nothing in any mode. The guarantee there is the durable write, and nothing else is claimed.

The summary's Verified: row counts the bytes that reached the end of the pipeline, and labels them checksum-matched. For an upload that label is accurate. For a local-to-local copy read it as "written and flushed" — that stage did no comparison, because there was nothing to compare against.

Failure policy. One bad file does not abandon the run. A per-file error is counted and printed on stderr, the loop continues, and the accumulated errors downgrade the process exit code to 6 (partial_failure) — never rolled up into success. A fatal failure is different: a locked vault, an index error, a cancelled run or a usage error would make every remaining file fail identically, so the run stops rather than emitting one copy of the same error per file.

Filters are all honoured, through one engine. --include, --exclude, --filter-from, --files-from, --min-size, --max-size and --max-depth are evaluated by crate::filter, the single implementation the listing family and dctl backup also use. It matters most in sync: a rule is applied to both listings, so an excluded file is invisible on both sides and is never seen as a destination extra and deleted. rclone's semantics are kept exactly — first match wins, and a single --include makes the unmatched default an exclusion. A pattern that will not compile is a usage error (exit 1) before anything is listed, because a run that proceeded with a rule the operator believes is in force is the data-loss case.

Omissions are announced. Symbolic links found during the walk are never followed — a link to an ancestor makes a walk loop forever and a link out of the tree copies data nobody named — and filenames that are not valid UTF-8 cannot be stored. Both are counted and warned about on stderr rather than passed over quietly, because finding out from a restore is far too late. Names are NFC-normalised on their way into a vault, so a file typed on macOS and on Linux becomes one object.

The source you name is not one of them. dctl copy /data vault: where /data -> /mnt/disk/data walks the tree the link points at. The root is entered exactly once and it is what you typed, so neither reason above applies to it; a dangling link names nothing and is reported as a missing source (exit 3), never as an empty tree. Skipping the root instead was a data-loss path: the copy stored nothing and printed Files: 0 / 0 Errors: 0 with exit 0, and dctl sync --force over the same source read that emptiness as permission to delete every object at the destination — while dctl ls, dctl size, dctl tree and dctl check all followed the link and showed the files.

--dry-run is authoritative. The plan is a pure function of two listings and a policy: no I/O, no clock, no mutation. The same value is either printed for review or handed to the executor, so what a dry run shows is what a real run performs. There is no second traversal that decides while it acts.

What runs today#

copy transfers real bytes in both directions. Five shapes work end to end:

  • Filesystem to filesystem. dctl copy /srv/src /srv/dst reads, writes and flushes every planned file, creates the directories it needs, and reports what it did. Nothing about it is a stub.
  • Filesystem into a vault. Each file is sealed, written with the verified write above, and committed to the index.
  • A vault out to the filesystem. dctl copy archive: ./export enumerates the vault through crate::source — the same reader dctl ls uses, so the two agree about what is there — fetches each object, authenticates it and writes it durably.
  • Filesystem into a plain remote — including a bucket. dctl copy ./src backup: — where backup is an ordinary remote, dctl config create backup local path=/mnt/backup — stores each file through that remote's backend, under the prefix you named. dctl copy ./src b2:mybucket does the same thing through B2's backend: the provider is behind the Backend trait, so the transfer above it is identical. Nothing is encrypted, because you did not address anything that encrypts, and no password is involved at all: this shape works unchanged under --no-ask-password, which is what makes it usable from a cron job that has no vault. Provider credentials are still required, from the environment, and a missing one is named by variable.
  • A plain remote out to the filesystem. dctl copy backup: ./out fetches each object as it is stored and writes it durably, again with no password.

Which of the two a REMOTE: is comes from your configuration, never from the argument's shape. A remote whose type is vault is sealed; everything else — local, b2, s3, r2 — is plain. This is the same one-line rule dctl ls uses to decide what it is reading, so a remote that lists as plain also writes as plain. (It did not used to be: every NAME: destination was treated as a vault, so an ordinary remote demanded a vault password and exited 22 having written nothing. If you have a runbook that works around that, delete the workaround.)

The password is acquired once, before the first file, and only for a shape that needs one — in this order: --password or DCTL_PASSWORD, then --password-command, then --password-file, then a terminal prompt, which --no-ask-password turns into exit 22 rather than a job that blocks forever on an invisible prompt.

--no-traverse is now an optimisation rather than a requirement. A vault destination is listed like any other, so a re-run skips the files that are already stored instead of re-uploading them. Passing --no-traverse still means "do not look", and every source file is then planned as a copy with the reason destination-not-listed.

Remote to remote is still refused, by the engine, at connect time, and the message says which of two different gaps you have hit — the capability, the crate that owes it, and whether any phase delivers it. If either end is sealed, a direct path needs a re-encrypting transfer that dctl-core does not expose, and no PLAN.md §11 phase schedules one (§8 keeps the root key wrapped rather than re-encrypting data, so this is not a release to wait for). If both ends are plain, nothing needs re-sealing and nothing is waiting on the core: the dctl-cli engine simply holds one backend and one local side, and no phase names remote-to-remote either. Either way: copy down to a local path first, then copy that up. To move a vault's stored objects between two stores with no password and no re-encryption, use dctl replicate instead.

Re-running a copy#

Into a vault, a re-run skips what is already stored, and it does so from metadata alone: the index recorded the source's own modification time, so the default size-and-time comparison answers the question without reading a byte of either side.

Out to a local path, the same: the restored file is stamped with the time the vault recorded for it before it is published, so a second dctl copy archive: ./restored transfers nothing.

Into a plain remote — a local: remote, an SFTP host or a B2 bucket — the same. The write records the source's own modification time (the file's inode, an SFTP SETSTAT, or B2's src_last_modified_millis), so the default size-and-time comparison skips:

console
$ dctl copy ./src backup:            # first run
       Files: 3 / 3
$ dctl copy ./src backup:            # and every run after
       Files: 0 / 0

S3 and R2 are the exception. The time is written as x-amz-meta-mtime and head reads it back, but ListObjectsV2 does not return user metadata and a transfer compares against the listing — so those two still re-transfer an unchanged tree, and --size-only is the comparison to reach for there.

Objects a bucket already holds were written before DCTL sent a time, so B2 falls back to their upload timestamp and the first run of this build against such a bucket re-uploads the tree once. After that run every object carries its own source time and is never sent again.

--checksum against a plain destination is still refused rather than approximated: a store holds the object and nothing about it, and a provider's own checksum is a SHA-1 or an ETag rather than the BLAKE3 of the plaintext. dctl check --checksum can answer over the same remote, because it reads each object back and hashes it — a full download of the destination, which is the price of that question.

Writing a plain object into a bucket works. dctl copy ./src b2:mybucket stores unencrypted objects under the prefix you named, through the same Backend::put a local: remote uses — the same verified write (the store must hold the bytes it was handed, or nothing is committed), the same key mapping the next listing reads, and no password anywhere on the path. There is nothing sealed about it: if you want the bytes encrypted, address a vault remote.

Two things to know before pointing a nightly job at one:

  • It has not been exercised against live B2, S3 or R2 credentials. The code path is provider-neutral and the provider put implementations are the ones every sealed vault write to those providers already uses, but the plain-object write itself has only been run against the local-filesystem backend behind the same trait. Try it with --dry-run and a small tree first.
  • A plain B2 destination is incrementally comparable; an S3 or R2 one is not — see "Re-running a copy" above. On S3 and R2 the source time is stored but a listing cannot return it, so use --size-only for a remote of those two that you re-copy on a schedule, and remember that a provider charges for the upload either way.

Reading a bucket is unchanged and needs no flags: the backend copy fetches from is the one dctl ls lists through.

A sub-path on a vault destination is honoured. dctl copy ./src archive:photos stores photos/a.txt and photos/sub/b.txt, the same way a plain destination does. The reproduction this page used to carry now round-trips intact:

console
$ dctl copy ./tree-one archive:site-a
$ dctl copy ./tree-two archive:site-b
$ dctl ls archive:
      17 B site-a/report.txt
      17 B site-b/report.txt
$ dctl cat archive:site-a/report.txt
TREE-ONE-CONTENT

That block is worth reading as a history of how this page has been wrong, because it has now been wrong in both directions about the same behaviour. It first claimed the gap "does not lose data or report success falsely" when the sealed path did both: two copies to different prefixes collided at the vault root and the second silently destroyed the first, with Errors: 0 and exit 0 on each. The correction replaced that with a DATA LOSS banner telling readers never to use a sub-path with a vault — and the banner outlived the defect, so the page then spent its most prominent warning forbidding an operation that works.

The overselling direction is the dangerous one and the underselling direction is not free: a standing "DATA LOSS" notice that is not true is how a reader learns to skim the ones that are. Both sentences are recorded rather than deleted because a safety claim, in either direction, is the kind of thing a reviewer relies on, and its removal should be visible.

A plain destination behaves identically — dctl copy ./src backup:photos stores photos/a.txt — because it must: the destination listing is taken under the same prefix, so writing anywhere else would make every re-run copy the same files again.

local: is not a way round any of this — that prefix means "read the rest as a filesystem path", so it lands in the plain-write refusal below, which is the correct answer to it.

Three refusals protect data rather than announce missing features, all exit 7:

  • A plain write into a vault's object namespace. Writing there as an ordinary filesystem path would store the data unencrypted, next to the ciphertext, and report success — for a tool whose whole promise is that data is sealed before it lands, that is the worst available outcome, so it is refused rather than done quietly. The rule comes from your configuration, not from what the destination currently contains:

    • If DEST is a store remote — archive-store: — or a local path at or inside a store remote's location, the run stops before the first file with '<dir>' is the object store for remote 'archive', and the hint names both views: archive: to store the data sealed, and dctl replicate archive-store: DEST-STORE: to copy the stored objects exactly as they are with no vault password.
    • If DEST is a local path that holds a vault envelope but no configured remote describes it, the refusal says exactly that and points at dctl config import. There is no remote to name, so none is invented.

    What never happens is DCTL deciding to encrypt for you. Encryption follows the remote name typed, so the same command means the same thing tomorrow as it does today, whatever has been created at the destination in between.

  • --checksum when a side genuinely cannot supply a hash. Most sides can: a vault carries the plaintext BLAKE3 it recorded at write time, and a local file is read and hashed (streamed, never buffered). What cannot answer is a plain object store, which knows the provider's checksum of whatever bytes it holds — a different claim entirely. Rather than compare two incomparable values, or silently downgrade to size-and-time, the run fails and names the file. The user asked for content equality; answering a different question would be exactly the misreporting the durability contract forbids.

  • --immutable when the plan contains an update. An existing destination object being replaced is exactly what the flag forbids, so the whole run fails with exit 7 before any byte moves and the message names the paths. A destination that does not exist yet is an addition, not an overwrite, and still copies. --immutable with --no-traverse is a usage error (exit 1): an unlisted destination cannot be checked for overwrites.

Still parsed and not yet consulted: --transfers, --checkers, --bwlimit, --retries and --low-level-retries. Files are transferred one at a time, in plan order, with no bandwidth shaping and no retry loop — the Retries row of the summary is therefore always absent.

Nothing is ever reported as copied when it was not, on any of those paths.

sh
dctl copy SOURCE DEST [flags]

The hidden compatibility aliases dctl put and dctl get parse the same arguments and run this command.

Examples#

The stderr run summary (Transferred: / Files: / Errors:) and the structured ERROR log line are omitted below except where they are the point.

Copy one local tree into another. This runs for real: two files are read, written and flushed, and the third is skipped because it was proven identical:

console
$ dctl copy /mnt/media/incoming /srv/archive/incoming -v
2 to copy, 0 to update, 0 to delete, 1 unchanged (1.91 MiB)
 
 Transferred: 1.91 MiB / 1.91 MiB, 100%, 42 MiB/s
    Verified: 1.91 MiB checksum-matched
       Files: 2 / 2
      Checks: 3 / 3
     Skipped: 1 (unchanged)
      Errors: 0
     Elapsed: 0s
$ echo $?
0

Copy a local tree into a vault. archive is the sealed remote dctl init registered; the password is read once, before the first file, and every object is encrypted on the way:

console
$ dctl copy ./dailies archive: --password-command 'pass dctl' --progress

Relative paths inside the tree are preserved — dailies/2024-06-01/a1.mov is stored as 2024-06-01/a1.mov.

Copy the same tree to an ordinary remote instead. Nothing here is encrypted and nothing here asks for a key, so it runs unattended with prompting switched off:

console
$ dctl config create backup local path=/mnt/backup
$ dctl --no-ask-password copy ./dailies backup:2024
$ ls /mnt/backup/2024/2024-06-01/
a1.mov

The two commands differ by one argument and by everything they mean, which is the point: what is encrypted follows the remote name typed, and nothing else.

Preview first. --dry-run prints the plan on stdout in the active format and changes nothing; -v adds the one-line shape summary on stderr:

console
$ dctl copy /mnt/media/incoming /srv/archive/incoming --dry-run -v
2 to copy, 0 to update, 0 to delete, 1 unchanged (1.91 MiB)
Action      Size  Path
------  --------  ---------
copy    1.91 MiB  big.mov
copy         3 B  sub/b.txt

Plan the upload before running it. --no-traverse means the destination is never enumerated, so a dry run needs no password at all. Every source file is assumed missing, and the plan says so — the reason slug is destination-not-listed rather than missing-at-destination, which is the honest distinction between "nothing is there" and "we did not look":

console
$ dctl copy /mnt/media/incoming vault:photos/2024 --dry-run --no-traverse --json
{
  "command": "copy",
  "source": "/mnt/media/incoming",
  "destination": "vault:photos/2024",
  "dry_run": true,
  "summary": {
    "copy": 3,
    "update": 0,
    "delete": 0,
    "skip": 0,
    "mkdir": 0,
    "bytes": 2000007
  },
  "actions": [
    {
      "action": "copy",
      "source": "a.txt",
      "dest": "a.txt",
      "size": 4,
      "reason": "destination-not-listed"
    },
    {
      "action": "copy",
      "source": "big.mov",
      "dest": "big.mov",
      "size": 2000000,
      "reason": "destination-not-listed"
    },
    {
      "action": "copy",
      "source": "sub/b.txt",
      "dest": "sub/b.txt",
      "size": 3,
      "reason": "destination-not-listed"
    }
  ]
}

The source and destination fields are roots: joining one to an action's relative path yields the object's full spec. A plan pulled out of a CI log is self-describing for exactly that reason.

Copy from Windows. A one-character prefix is always a drive letter, on every platform, so C:\media\dailies is a local path and never a remote called C. The same rule protects UNC paths (\\nas\media) and relative paths that happen to contain a colon:

console
$ dctl copy C:\media\dailies vault:footage --dry-run --no-traverse

If the drive is not mounted the source is missing, and that is an error rather than a successful transfer of nothing:

console
$ dctl copy C:\media\dailies vault:footage --dry-run
error: source not found: C:\media\dailies
warning: Check the path, and the remote name if one was given.
$ echo $?
3

Copy one file into a directory. DEST is the container, so the object keeps its own name and lands at /srv/archive/report.pdf — contrast copyto, which would treat archive as the new name:

console
$ dctl copy report.pdf /srv/archive

An up-to-date destination is an honest success, not a claim of work done. Nothing is opened and no password is asked for, because nothing needed transferring:

console
$ dctl copy /srv/src /srv/dst --size-only -v
0 to copy, 0 to update, 0 to delete, 1 unchanged (0 B)
nothing to transfer: the destination is up to date
$ echo $?
0

A plain copy into a vault's object store is refused, before the first file. The alternative is writing your data next to the ciphertext in the clear and calling it a success. With the vault registered — dctl init --name archive --base local:/srv/vault — the refusal comes from the configuration and can name both views, so there is something to do next:

console
$ dctl copy ./photos /srv/vault
error: '/srv/vault' is the object store for remote 'archive'
warning: Use `archive:` to store data sealed — every write through it is
encrypted, and no flag turns that off. To copy the objects already stored there
exactly as they are, run `dctl replicate archive-store: DEST-STORE:`, which
needs no vault password. DCTL will not switch between the two on its own: what a
command encrypts is decided by the remote name typed.
$ echo $?
7

Naming a subdirectory does not get round it — /srv/vault/photos is inside the same store, and the message still names /srv/vault so you can see what you hit. For a vault this machine's configuration knows nothing about, the envelope on disk is the only evidence there is, and the refusal says so rather than guessing at a remote name:

console
$ dctl copy ./photos /mnt/usb/vault-from-the-office
error: refusing to write plaintext into '/mnt/usb/vault-from-the-office': it
contains a vault that no configured remote describes
$ echo $?
7

There is no size limit. This command used to refuse any file above 1 GiB, because the engine read each file whole into memory and attempting a 50 GB video would have taken the machine down. It now moves every byte in bounded windows in both directions — measured flat at ~144 MiB of resident memory from a 256 MiB object to a 4 GiB one, inside a 512 MiB hard cap — so the refusal and the limit behind it are gone rather than raised.

console
$ dctl copy /mnt/ingest /srv/archive
$ echo $?
7

Pull a vault back out. The source is enumerated through the same reader dctl ls uses, so a listing and a copy always agree about what is there:

sh
$ dctl copy archive: ./export
 Transferred: 293.0 KiB / 293.0 KiB, 100%, 32.5 KiB/s
    Verified: 293.0 KiB checksum-matched
       Files: 4 / 4
      Checks: 4 / 4
      Errors: 0
     Elapsed: 9s
$ diff -r ./src ./export && echo IDENTICAL
IDENTICAL

Compare by content instead of by size and time. Every file is proven identical and none is re-sent:

sh
$ dctl copy ./src archive: --checksum
       Files: 0 / 0
      Checks: 4 / 4
     Skipped: 4 (unchanged)
      Errors: 0

…and when a side genuinely cannot answer, the run says so rather than quietly comparing something else:

sh
$ dctl copy plainbox: ./export --checksum
error: --checksum: no content hash for 'README.md'
  hint: A plain object store reports the provider's checksum of the bytes it
  holds, which is not the plaintext hash a vault records, so the two cannot be
  compared. Address the vault through its own remote, or compare by size and
  modification time (drop --checksum, or add --size-only).

Exclude a tree and see the rule applied rather than refused:

console
$ dctl copy /srv/src archive: --exclude 'cache/**'

A remote nobody configured is named rather than quietly read as the relative directory of that name:

console
$ dctl copy vault:photos ./restored
error: unknown remote 'vault'
$ echo $?
7

Options#

sh
      --create-empty-src-dirs  Recreate empty source directories at the destination
      --ignore-existing        Skip files that already exist at the destination, without comparing them
      --update                 Skip files where the destination is newer than the source
      --no-traverse            Do not list the destination; assume every source file is missing there
  -h, --help                   Print help (see more with '--help')
  -V, --version                Print version

Positional arguments:

ArgumentMeaning
<SOURCE>A local path, or REMOTE:PATH. A single file is allowed and lands inside DEST under its own name.
<DEST>A local path, or REMOTE:PATH. Its existing contents are never removed.

Notes on the flags:

  • --create-empty-src-dirs exists because an empty directory holds no objects and therefore has no representation in a vault — without this flag it disappears on the round trip. It adds mkdir entries to the plan, with the reason empty-source-dir.
  • --ignore-existing skips by presence, without comparing. It never applies to a file the destination does not have.
  • --update skips only when the destination is newer than the source by more than the one-second modification window.
  • --no-traverse trades correctness of the skip decision for speed: the destination is not listed at all, so every source file is planned as a copy even if an identical object is already there. Worth it when the destination holds far more files than the source. sync does not offer this flag; see dctl sync.

Options inherited from parent commands#

Every global flag is accepted; see ../GLOBAL_FLAGS.md for the full list. The ones that change what this command does:

FlagEffect here
--checksumCompare content hashes instead of size and time. Currently refuses with exit 7 when either side has no hash rather than downgrading. Conflicts with --size-only.
--size-onlyCompare size alone, ignoring timestamps. Useful against a destination that cannot report modification times.
--verify <MODE>checksum (default) adds nothing to the verified write. sample and strict are identical today and both re-read every uploaded object in full; against a filesystem destination all three do nothing.
--verify-samples <N>Parsed and not consulted: partial sampling does not exist yet.
-n, --dry-runPrint the plan and change nothing. Complete and trustworthy today.
--password, --password-command, --password-file, --no-ask-passwordHow the vault password is acquired, once, before the first file. Only a vault destination needs one. Nothing available exits 22.
--index <PATH>The index database the vault commit is written to. Defaults to the platform data directory.
--format, --jsonRender as a table, one JSON document, or one JSON Lines record per action. Both JSON forms carry a result object on a real run — the executor's own counters, including errors — so what was attempted can be told from what was achieved.
--min-size, --max-size, --max-depthHonoured by the walk, and applied to both listings. An unsatisfiable size range is a usage error rather than a silent transfer of nothing.
--include, --exclude, --filter-from, --files-fromHonoured, and applied to the walk before anything is transferred. A rule file that cannot be read or parsed is a usage error (exit 1) naming the file and the line, never a run with the rules dropped.
-P, --progress, --statsPer-file bars showing the real pipeline stage — a row at verify has been written but is not yet counted as stored.
--transfers, --bwlimit, --retries, --low-level-retriesParsed and not consulted. Files move one at a time, unshaped and unretried.
--immutableHonoured at plan time. Any update in the plan fails the run with exit 7 before anything moves, naming the paths; a missing destination is an addition and still copies. Refused with --no-traverse (exit 1), which never lists the destination.
--linksHonoured. skip (the default) passes over every symbolic link in the source tree, counting it and naming it at -v; follow stores what they point at; in-tree follows only those that stay under the source root. See GLOBAL_FLAGS.md.
-q, --quietSuppresses the summary and the skipped-symlink warnings. Errors are still printed.

--force and --interactive have no effect on copy: it is not destructive and never asks for confirmation.

Exit codes#

See ../EXIT_CODES.md for the full contract.

CodeNameWhen
0successEvery planned file was transferred, or a --dry-run completed, or every file was already identical so there was nothing to transfer.
1usageUnparseable command line; an empty spec or one that climbs above a remote's root with ..; source and destination are the same place; DEST is an existing file rather than a directory; an unparseable or unsatisfiable --min-size/--max-size; --immutable together with --no-traverse.
3dir_not_foundSOURCE does not exist. A missing DEST is not an error — that is the ordinary first run.
5temporary_errorA cloud backend failed in a way worth retrying. Reachable wherever a cloud backend is contacted: reading a plain b2:/s3:/r2: source, writing plain objects into one, or a vault whose store is one of them. A purely local transfer does not produce it.
6partial_failureThe run finished, and at least one file failed. The successful files are stored; the failures were printed on stderr as they happened. This is the code to branch on.
7fatal_errorTwo or more local source files share one vault path once their names are normalised, refused before anything is read (see ../RESTORE_DRILL.md); DEST is a local directory holding a vault; --checksum against a plain object store, which cannot supply a plaintext hash; both sides are remotes; --immutable and the plan would replace something. Files already transferred before the refusal stay transferred; nothing further is attempted — except the --immutable refusal, which happens before any transfer at all.
20checksum_mismatchThe backend stored bytes other than the ones sent. Nothing was committed for that file.
21integrity_failure--verify sample/strict read an object back and it did not authenticate, or a decrypted object did not match its recorded hash. It was written but must not be trusted.
22vault_lockedNo password was available, or the envelope did not unwrap. Nothing was transferred.
23index_errorThe index database could not be opened or committed.
25cancelledThe run was interrupted with Ctrl-C or SIGTERM.

See also#

  • dctl move — the same transfer, then delete each source after its own durable commit.
  • dctl sync — make the destination identical, deleting what the source does not have.
  • dctl copyto — copy to an exact destination name rather than into a container.
  • dctl movetocopyto's destination semantics with move's ordering guarantee.
  • dctl check — compare two sides without transferring anything.
  • dctl verify — prove that stored objects decrypt and match their recorded hashes.