A Dockerized, high-integrity Dropbox to Proton Drive migration appliance with independent source inventories, cryptographic local verification, resumable recovery, conflict-safe normalization, post-baseline catch-up, and full byte-level destination round-trip verification.
Use it to migrate Dropbox files into a verified local archive and then into Proton Drive without treating a copy command's exit code as proof that every file arrived.
Important
This project is pre-release. Its deterministic test suite and container build pass, but the refactored appliance still needs a complete live-account migration before production use. Test with non-critical data first and retain every source copy until the P250 final cutover report and your own review are complete.
People searching for a Dropbox alternative, encrypted cloud storage, data sovereignty, or a way to migrate Dropbox to Proton Drive need more than a drag and drop.
Cloud-provider terms, AI features, and content-use policies can change. The durable answer is practical data portability: keep your own verified copy, preserve provenance, and be able to leave a provider without guessing whether thousands of files survived the move.
Dropbox is not “unencrypted”: it documents encryption in transit and at rest. The distinction motivating this project is between provider-managed storage and an end-to-end encrypted destination. Proton states that Proton Drive files are encrypted on the user's device before upload. See Proton's Drive documentation and evaluate the current policies of every service yourself.
This tool cannot prove how a provider handled files in the past, prevent future policy changes, or delete historical copies. It gives you a reproducible, auditable exit path and a verified local source of truth before you decide whether to close the old account.
- 🔎 Two independent Dropbox inventories — Direct Dropbox API metadata is reconciled against rclone's Dropbox backend before download is trusted.
- 🧊 Source stability gate — Fresh reconciled inventories immediately before and after download detect changes and require a new migration ID and empty archive on drift.
- 🧮 Independent content verification — Calculates Dropbox's published block content hash itself, plus SHA-256 and SHA-1 for every regular file.
- 🧷 Symlink-aware scanning — Records file, directory, and broken symlinks without accidentally following them.
- 🩹 Missing-only recovery — Downloads only missing objects into staging, verifies each one, and merges with exclusive no-overwrite semantics.
- 🕒 Metadata preservation — Restores regular-file mtime from Dropbox
client_modifiedwhile permanently retaining IDs, revisions, hashes, and server timestamps in manifests. - 🧩 Conflict-safe normalization — Handles Selective Sync and view-only conflict names while preserving differing content and quarantining, not deleting, identical duplicates.
- 📜 Canonical provenance manifest — Maps every final object back to its Dropbox source object or identifies it as local-only.
- ☁️ Official Proton Drive CLI only — No unofficial implementation performs destination writes.
- 📉 Delta uploads — Inventories Proton first and uploads only missing or explicitly eligible mismatched objects.
- ✅ Fresh destination verification — Discards the before-upload inventory as proof and recursively inventories Proton again after upload.
- 🔁 Full Proton round trip — Downloads every destination file into UID-isolated staging and independently verifies size, SHA-256, SHA-1, and Dropbox content hash.
- 🔄 Bounded final catch-up — Advances from the stable post-download Dropbox cursor, reconciles changed payloads locally, and applies only a reviewed Proton delta.
- 🧾 Revision-preserving updates — Same-path modified files use Proton's
create-new-revisionstrategy; source deletes and renames preserve old Proton nodes as reported review evidence. - ✂️ Explicit cutover boundary — P120 records a verified baseline; P250 reports disconnect readiness only after fresh destination bytes and Dropbox quiescence pass.
- 💾 SQLite checkpoints and resume — Long inventories and phase state survive interruption.
- 🧾 Human and machine-readable evidence — Produces Markdown, JSON, JSONL, CSV, structured logs, phase logs, checksums, and a final migration report.
- 🛑 Fail-closed safety — No Dropbox deletion implementation, no Proton deletion implementation, and no silent overwrite of differing content.
The central rule is: never trust one implementation to verify itself.
DROPBOX
│
┌──────────────────┴──────────────────┐
│ │
▼ ▼
Direct Dropbox API rclone Dropbox backend
authoritative metadata independent inventory
│ │
└──────────────────┬──────────────────┘
▼
source reconciliation gate
path + size + hash agreement
│
▼
no-overwrite local copy
│
▼
independent local filesystem walker
Dropbox hash + SHA-256 + SHA-1 + symlink target
│
┌───────────────┴────────────────┐
▼ ▼
missing-only recovery metadata restoration
└───────────────┬────────────────┘
▼
conflict-safe normalization
│
▼
canonical archive + provenance
│
▼
┌──────── Proton inventory before upload ────────┐
│ │
▼ │
read-only delta plan │
│ │
▼ │
explicit --apply upload │
│ │
▼ │
completely fresh Proton inventory ◀──────────────────────┘
│
▼
metadata verification
│
▼
full byte round-trip verification
│
▼
P120 baseline report
│
▼
Dropbox catch-up generation
source → local → plan → upload
│
▼
full cutover verification
fresh metadata + bytes + quiescence
│
▼
P250 final cutover report
Three different trust boundaries provide evidence:
- Dropbox API and rclone independently observe the source.
- Custom Python hashing independently verifies the local copy.
- A new official Proton CLI inventory verifies the destination after upload.
- Official-CLI downloads are independently hashed against the canonical archive.
dropbox-proton-migrator/
├── README.md
├── LICENSE
├── MIGRATION_SPEC.md
├── SECURITY.md
├── CHANGELOG.md
├── config/
│ ├── migration.example.toml
│ └── toolchain.lock.toml
├── docker/
│ ├── build.sh
│ ├── Dockerfile
│ ├── compose.yaml
│ └── entrypoint.sh
├── src/migrator/
│ ├── __main__.py
│ ├── config.py
│ ├── state.py
│ ├── logging.py
│ ├── paths.py
│ ├── hashing.py
│ ├── filesystem.py
│ ├── catchup.py
│ ├── normalization.py
│ ├── providers/
│ │ ├── dropbox_api.py
│ │ ├── dropbox_rclone.py
│ │ └── proton_cli.py
│ └── phases/
│ ├── p00_preflight.py
│ ├── p10_source_inventory.py
│ ├── p20_download.py
│ ├── p30_verify_local.py
│ ├── p40_recover_missing.py
│ ├── p50_restore_metadata.py
│ ├── p60_normalize_conflicts.py
│ ├── p70_canonical_manifest.py
│ ├── p80_proton_inventory.py
│ ├── p90_delta_plan.py
│ ├── p100_upload_delta.py
│ ├── p110_verify_proton.py
│ ├── p115_proton_round_trip.py
│ ├── p120_finalize.py
│ ├── p200_catchup_source.py
│ ├── p210_catchup_local.py
│ ├── p220_catchup_plan.py
│ ├── p230_catchup_upload.py
│ ├── p240_cutover_verify.py
│ └── p250_cutover_finalize.py
├── tests/
└── runs/
Generated migration evidence belongs under runs/<migration-id>/, never beside
the source code.
Operators edit only two non-secret files:
- one migration TOML for all account, path, transfer, policy, and safety behavior
config/toolchain.lock.tomlfor every Python image, Python package, test tool, rclone, and Proton Drive CLI version
Versions are not duplicated in the Dockerfile or Compose definition. Secrets remain outside both files and enter the container only through read-only credential mounts.
- Linux with Docker Engine and Docker Compose v2
- A Dropbox account and scoped Dropbox API token
- A Proton account with Proton Drive enabled
- Enough local capacity for the full Dropbox source, recovery staging, and logs
- Strongly recommended: an encrypted host filesystem or volume for both archive and run data
Docker is the only software this project requires on the host. Do not install Python, rclone, Proton Drive CLI, pytest, or Ruff locally; the container provides and verifies the locked versions.
Run the entire quick start from the repository root—the directory containing
README.md, config/, and docker/—and keep using the same terminal so the
exported variables remain available:
cd /absolute/path/to/dropbox_protonReplace that example with the actual path to your clone. First choose two paths under user-writable locations. The data root must have enough capacity for the Dropbox archive and run evidence; both roots should be on encrypted storage. If you open a new terminal later, return to the repository root and repeat this export step before continuing.
Choose one of the following location examples.
Home-directory locations work without root permissions:
export MIGRATION_DATA_ROOT="${HOME}/dropbox-proton-data/my-migration"
export MIGRATION_SECURE_ROOT="${HOME}/.config/dropbox-proton-migration"Alternatively, use any mounted disk or other location you own. This block asks for the paths instead of supplying placeholders; enter the actual absolute path for each prompt:
printf 'Absolute migration data root: '
IFS= read -r MIGRATION_DATA_ROOT
export MIGRATION_DATA_ROOT
printf 'Absolute migration secure root: '
IFS= read -r MIGRATION_SECURE_ROOT
export MIGRATION_SECURE_ROOTThe data and secure roots do not need to be beside the repository or beside each other. They only need sufficient capacity, appropriate encryption, and read/write access for your user. After choosing either pair above, export the derived paths. Shell variables are calculated when each command runs; if you change either root later, rerun this entire derived-path block:
export ARCHIVE_PATH="${MIGRATION_DATA_ROOT:?export MIGRATION_DATA_ROOT first}/archive"
export RUNS_PATH="${MIGRATION_DATA_ROOT:?export MIGRATION_DATA_ROOT first}/runs"
export RCLONE_CONFIG_DIR="${MIGRATION_SECURE_ROOT:?export MIGRATION_SECURE_ROOT first}/rclone"
export PROTON_STATE_DIR="${MIGRATION_SECURE_ROOT:?export MIGRATION_SECURE_ROOT first}/proton/state"
export PASSWORD_STORE_DIR="${MIGRATION_SECURE_ROOT:?export MIGRATION_SECURE_ROOT first}/proton/password-store"
export GNUPG_HOME_DIR="${MIGRATION_SECURE_ROOT:?export MIGRATION_SECURE_ROOT first}/proton/gnupg"
export MIGRATION_CONFIG="${MIGRATION_SECURE_ROOT:?export MIGRATION_SECURE_ROOT first}/jobs/my-migration.toml"
export DROPBOX_TOKEN_FILE="${MIGRATION_SECURE_ROOT:?export MIGRATION_SECURE_ROOT first}/dropbox.token"
export MIGRATOR_UID="$(id -u)"
export MIGRATOR_GID="$(id -g)"Review the resulting locations before creating anything:
printf '%-24s %s\n' \
"Archive:" "${ARCHIVE_PATH}" \
"Run evidence:" "${RUNS_PATH}" \
"rclone config directory:" "${RCLONE_CONFIG_DIR}" \
"Proton state directory:" "${PROTON_STATE_DIR}" \
"Migration TOML:" "${MIGRATION_CONFIG}" \
"Dropbox token:" "${DROPBOX_TOKEN_FILE}"All variables above are host paths. A leading / means the path starts at the
filesystem root; it is not relative to the current directory.
The home-directory and custom mounted-disk options only control where migration
data and secrets are stored. With either option, run the following block from
the repository root because config/migration.example.toml is a path inside
the repository:
sudo install -d -m 700 \
-o "${MIGRATOR_UID}" \
-g "${MIGRATOR_GID}" \
"${MIGRATION_DATA_ROOT}" \
"${ARCHIVE_PATH}" \
"${RUNS_PATH}" \
"${MIGRATION_SECURE_ROOT}" \
"${RCLONE_CONFIG_DIR}" \
"${PROTON_STATE_DIR}" \
"${PASSWORD_STORE_DIR}" \
"${GNUPG_HOME_DIR}" \
"$(dirname "${MIGRATION_CONFIG}")"
if [ ! -e "${MIGRATION_CONFIG}" ]; then
install -m 600 config/migration.example.toml "${MIGRATION_CONFIG}"
else
echo "Keeping existing migration config: ${MIGRATION_CONFIG}"
fisudo is used only to provision locations that may be owned by root, such as a
mounted disk. -o and -g immediately assign the resulting directories to
your user because the migration container does not run as root. If both chosen
roots are already writable by your user, you may omit sudo.
For now, edit only values you choose yourself:
- Set
migration.idto a permanent unique name such asdropbox-to-proton-2026. Use only letters, numbers,.,_, and-. - Leave the
[paths]values unchanged. They are container paths wired bydocker/compose.yaml, not the host paths exported above. - Set
dropbox.rclone.rootto""for the whole Dropbox namespace, or to a relative folder such as"Photos/Family"without a leading slash. The migration renders rclone paths with a leading slash (remote:/...) so rclone and the direct API both use Dropbox's root namespace. On a team account this includes team folders and the member folder;remote:...without the slash would incorrectly use only the member home. - Choose an empty
proton.destination, such as"/my-files/Dropbox". Step 5 explicitly creates this destination after Proton authentication. - Review the policy sections. The example defaults preserve uncertain or colliding data for review and do not delete source or destination content.
Leave the example values for dropbox.api.expected_account_id and
proton.expected_account_identifier temporarily. Step 6 retrieves them after
both providers are authenticated. Do not run preflight with placeholders.
The migration TOML contains behavior, not credentials. Never put Dropbox tokens, Proton passwords, refresh tokens, or rclone secrets in it.
The local canonical archive contains readable file content. Docker does not encrypt a bind mount for you. The run volume also contains sensitive filenames, hashes, timestamps, and provider identifiers.
Create a scoped Dropbox app using the Dropbox developer guide and grant only the account-information and file-metadata read scopes needed by the direct inventory. rclone uses its own OAuth configuration for transfer.
Create the token file, then paste the token at the hidden prompt and press Enter:
install -m 600 /dev/null "${DROPBOX_TOKEN_FILE}"
printf 'Paste Dropbox token: '
IFS= read -rs DROPBOX_TOKEN
printf '\n'
printf '%s' "${DROPBOX_TOKEN}" > "${DROPBOX_TOKEN_FILE}"
unset DROPBOX_TOKENConfirm that the file exists and is non-empty without displaying its contents:
test -s "${DROPBOX_TOKEN_FILE}" && echo "Dropbox token file is ready"Never put the token directly in a command because shell history may retain it. Do not commit the token.
Build the locked final image, then export its immutable image ID:
sh docker/build.sh final
export MIGRATOR_IMAGE="$(
docker image inspect dropbox-proton-migrator:latest --format '{{.Id}}'
)"
printf 'Pinned migration image: %s\n' "${MIGRATOR_IMAGE}"Keep this terminal and exact image ID unchanged through qualification and the
client run. Compose requires MIGRATOR_IMAGE, and the preflight artifact
records it.
Create the rclone Dropbox remote from the pinned container. --network host
allows rclone's temporary localhost OAuth callback to reach your browser on
Linux:
docker run --rm -it \
--network host \
--user "${MIGRATOR_UID}:${MIGRATOR_GID}" \
--env HOME=/tmp \
--volume "${RCLONE_CONFIG_DIR}:/config/rclone" \
--entrypoint rclone \
"${MIGRATOR_IMAGE}" \
config --config /config/rclone/rclone.confFollow the official
rclone Dropbox backend guide and name the remote
dropbox-source. Confirm that the configured name is present:
docker run --rm \
--user "${MIGRATOR_UID}:${MIGRATOR_GID}" \
--env HOME=/tmp \
--volume "${RCLONE_CONFIG_DIR}:/config/rclone:ro" \
--entrypoint rclone \
"${MIGRATOR_IMAGE}" \
listremotes --config /config/rclone/rclone.confThe official Proton Drive CLI stores its authenticated session in either the
operating-system keychain or
pass.
This container explicitly selects pass through
PROTON_DRIVE_CREDENTIALS_STORE=pass; it does not provide a desktop keychain.
Initialize the dedicated GPG home and password store once, before Proton login:
docker compose -f docker/compose.yaml run --rm \
--volume "${PWD}/docker/init-proton-pass.sh:/tmp/init-proton-pass.sh:ro" \
--entrypoint sh migrator /tmp/init-proton-pass.shThis creates a dedicated, non-expiring GPG key without a passphrase so each
one-shot migration container can use the store non-interactively. The private
key persists in GNUPG_HOME_DIR, while encrypted pass entries persist in
PASSWORD_STORE_DIR. Protect both directories with mode 0700, keep them on
encrypted storage, and never share them between migrations.
Now authenticate the official Proton Drive CLI:
docker compose -f docker/compose.yaml run --rm migrator proton-drive auth loginAuthentication uses the
official Proton Drive CLI. Browser login
is interactive. After successful login, the CLI writes the session as the
GPG-encrypted pass entry
ch.proton.drive/drive-sdk-cli/auth-session. CLI state and encrypted
credential storage persist in the exported host directories and are never
baked into the image.
Confirm that the encrypted session entry exists without displaying it:
test -f \
"${PASSWORD_STORE_DIR}/ch.proton.drive/drive-sdk-cli/auth-session.gpg" &&
echo "Encrypted Proton session is ready"Create the dedicated migration destination. For the example
proton.destination = "/my-files/Dropbox", run:
docker compose -f docker/compose.yaml run --rm migrator \
proton-drive filesystem create-folder /my-files DropboxThis command is expected to be run once. If Dropbox already exists, do not
create it again; confirm the existing folder instead:
docker compose -f docker/compose.yaml run --rm migrator \
proton-drive filesystem list -j /my-filesIf you configured a different destination, pass its existing parent path and
final folder name to create-folder. For example, destination
/my-files/Migrations/Dropbox requires:
docker compose -f docker/compose.yaml run --rm migrator \
proton-drive filesystem create-folder /my-files Migrations
docker compose -f docker/compose.yaml run --rm migrator \
proton-drive filesystem create-folder /my-files/Migrations DropboxDestination creation is an explicit setup mutation. The migration does not silently create this root during preflight because it must first record and verify the root's stable Proton UID. During an applied upload, it creates the required source subdirectories beneath this verified root.
Do not pass a Proton password through TOML, Compose environment variables, or command-line arguments.
Retrieve the immutable Dropbox account ID without printing the token:
docker run --rm \
--volume "${DROPBOX_TOKEN_FILE}:/run/secrets/dropbox_token:ro" \
--entrypoint python \
"${MIGRATOR_IMAGE}" -c '
import json, requests
token = open("/run/secrets/dropbox_token", encoding="utf-8").read().strip()
response = requests.post(
"https://api.dropboxapi.com/2/users/get_current_account",
headers={"Authorization": "Bearer " + token},
timeout=30,
)
response.raise_for_status()
data = response.json()
print(json.dumps({
"account_id": data["account_id"],
"display_name": data["name"]["display_name"],
"root_namespace_id": data["root_info"]["root_namespace_id"],
}, indent=2))
'Verify display_name, then copy the complete account_id, including its
dbid: prefix, into:
[dropbox.api]
root_namespace_id = ""
expected_account_id = "dbid:paste-complete-account-id-here"Normally leave root_namespace_id empty so the program uses the authenticated
account's observed root namespace automatically.
Retrieve the stable UID from the destination's parent listing:
docker compose -f docker/compose.yaml run --rm migrator \
proton-drive filesystem list -j /my-filesFor a different destination, list its immediate parent instead.
Find the single entry whose name is exactly Dropbox, confirm its type is
folder, and copy its complete top-level uid. For example, if that entry
contains "uid":"abc123...", configure:
[proton]
destination = "/my-files/Dropbox"
expected_account_identifier = "abc123..."This UID belongs to the exact destination folder, so retrieve it again if the
destination changes. Proton CLI 0.8.0's filesystem info can fail while parent
listing remains available. The migration therefore resolves the configured
destination through the same strict parent listing, fails on zero or duplicate
name matches, requires a folder, and compares the UID exactly.
Finally, ensure the rclone remote name matches the name created in step 5:
[dropbox.rclone]
remote = "dropbox-source"
root = ""The optional catch-up section has fail-closed defaults. Include it explicitly when recording an audited client configuration:
[catch_up]
max_changed_objects = 100000
cursor_reset_fallback = true
quiescence_seconds = 60
modified_file_policy = "create_new_revision"
removed_source_policy = "preserve_report"At this point all placeholders required by preflight must be replaced.
Start with account guards, toolchain checks, and independent source inventory:
docker compose -f docker/compose.yaml run --rm migrator preflight
docker compose -f docker/compose.yaml run --rm migrator source-inventoryDownload with no-overwrite semantics, then verify every Dropbox file:
docker compose -f docker/compose.yaml run --rm migrator download --apply
docker compose -f docker/compose.yaml run --rm migrator verify-localIf files are missing, recover first writes a plan. Inspect
40_recovery/recovery_plan.jsonl, then apply only that recovery:
docker compose -f docker/compose.yaml run --rm migrator recover
docker compose -f docker/compose.yaml run --rm migrator recover --applyPlan and apply metadata restoration and conflict normalization separately:
docker compose -f docker/compose.yaml run --rm migrator restore-metadata
docker compose -f docker/compose.yaml run --rm migrator restore-metadata --apply
docker compose -f docker/compose.yaml run --rm migrator normalize
docker compose -f docker/compose.yaml run --rm migrator normalize --apply
docker compose -f docker/compose.yaml run --rm migrator canonicalizeInventory Proton and inspect the read-only upload delta:
docker compose -f docker/compose.yaml run --rm migrator proton-inventory
docker compose -f docker/compose.yaml run --rm migrator planReview 90_delta/delta.jsonl, 90_delta/delta.csv, and 90_delta/SUMMARY.md.
Only then allow destination writes:
docker compose -f docker/compose.yaml run --rm migrator upload --apply
docker compose -f docker/compose.yaml run --rm migrator verify-destination
docker compose -f docker/compose.yaml run --rm migrator verify-round-trip
docker compose -f docker/compose.yaml run --rm migrator finalizefinalize is P120. It writes a verified baseline report with
disconnect_ready=false; it is not final cutover approval and never deletes
Dropbox.
Schedule the final maintenance window and freeze Dropbox before P200. Keep it frozen through P250. Run each phase separately so both mutation plans can be reviewed:
docker compose -f docker/compose.yaml run --rm migrator catchup-source
docker compose -f docker/compose.yaml run --rm migrator catchup-local
docker compose -f docker/compose.yaml run --rm migrator catchup-local --apply
docker compose -f docker/compose.yaml run --rm migrator catchup-plan
docker compose -f docker/compose.yaml run --rm migrator catchup-upload
docker compose -f docker/compose.yaml run --rm migrator catchup-upload --apply
docker compose -f docker/compose.yaml run --rm migrator cutover-verify
docker compose -f docker/compose.yaml run --rm migrator cutover-finalizeReview 200_catchup_source/changes.jsonl,
210_catchup_local/actions.jsonl, and
220_catchup_plan/delta/delta.jsonl before either apply. P200 uses the stable
post-download cursor or the latest applied catch-up cursor. If Dropbox reports
that cursor reset and cursor_reset_fallback=true, it rebuilds a full metadata
inventory and classifies that snapshot against the persisted source projection.
P210 verifies downloaded changes, retains superseded local bytes, reruns local
verification/normalization, and emits a new canonical generation. P220 takes a
fresh Proton inventory. P230 revalidates that inventory immediately before
upload and uses create-new-revision only for reviewed same-path file changes;
it never uses replace, trash, or delete. Deleted and renamed old Proton nodes
remain present and are reported as SOURCE_SUPERSEDED.
P240 takes another full Proton inventory, downloads changed active revisions
for byte verification, safely carries unchanged byte evidence only when
UID/path/revision/hash identity is stable, and probes Dropbox again after the
configured quiescence interval. Source drift requires another P200 generation.
Review 240_cutover_verification/byte_evidence.jsonl,
source_quiescence.json, and summary.json.
P250 writes FINAL_CUTOVER_REPORT.{md,json} and is the only phase that can set
disconnect_ready=true.
cutover orchestrates the same sequence:
docker compose -f docker/compose.yaml run --rm migrator cutover
# Review the recorded P210 plan.
docker compose -f docker/compose.yaml run --rm migrator cutover --apply
# Review the newly recorded P230 plan.
docker compose -f docker/compose.yaml run --rm migrator cutover --applyOrchestration never applies a mutation plan created by the same invocation,
even when --apply is present. Each unreviewed mutation is a distinct stop:
- The first
cutovercreates/reaches the P210 local-action plan and stops if local mutations are required. - After reviewing P210,
cutover --applymay apply that existing plan. It then creates/reaches the P230 upload plan and stops if uploads are required. - After reviewing P230, another
cutover --applymay apply that existing plan and continue through P240 and P250.
If either phase is a no-op, orchestration can pass it without an apply review
stop. --apply authorizes only a latest P210 or P230 attempt already recorded
as PLANNED; it does not pre-authorize a later plan. Individual phase commands
remain the conservative client-run procedure.
docker compose -f docker/compose.yaml run --rm migrator status
docker compose -f docker/compose.yaml run --rm migrator resumeInventories resume from SQLite checkpoints. Completed phases are reused only
while their recorded output files still match their stored SHA-256 hashes.
Only one process can operate on a migration ID at a time. Avoid broad
resume --apply: inspect status, identify the exact planned mutation, and
apply only that phase. P20 source drift is terminal for the migration ID and
cannot be resumed.
resume runs only through the P120 baseline. Use the individual P200-P250
commands or cutover for final catch-up. If P240 finds source drift, keep
Dropbox frozen and start a fresh catchup-source generation.
If phase 100 was interrupted, keep both the partial Proton destination and the state database unchanged. Rerun the exact reviewed mutation:
docker compose -f docker/compose.yaml run --rm migrator upload --applyA retry is enabled only by a prior applied phase-100 attempt. It rechecks the
destination identity, finishes a pending upload_apply folder queue or starts
a new complete inventory, and rebuilds the delta from current Proton state.
Existing objects are skipped only when they reconcile safely and their paths
were upload-authorized by the passed phase-90 plan. Only still-missing approved
objects are submitted. Unexpected, ambiguous, unapproved, wrong-type, or
wrong-content destination objects fail before mutation. Unavailable hashes and
mtime differences remain explicit policy-controlled review evidence, never
verified matches. Resume never deletes or overwrites destination content.
Uploads request the official CLI's JSON transfer summary so per-item failures
are retained instead of only the final N item(s) failed message. Literal
archive paths are escaped from the CLI's local glob expansion. When the CLI
explicitly reports thumbnail-generation failure and thumbnails were enabled,
the same file is retried once with the official --skip-thumbnails flag. No
other provider error receives that fallback. Both CLI attempts remain command
evidence, and destination verification still checks the resulting bytes.
Run verify-destination and then verify-round-trip only after the resumed
upload reports PASS.
migrate preflight
migrate source-inventory
migrate download [--apply]
migrate verify-local
migrate recover [--apply]
migrate restore-metadata [--apply]
migrate normalize [--apply]
migrate canonicalize
migrate proton-inventory
migrate plan
migrate upload [--apply]
migrate verify-destination
migrate verify-round-trip
migrate finalize
migrate catchup-source
migrate catchup-local [--apply]
migrate catchup-plan
migrate catchup-upload [--apply]
migrate cutover-verify
migrate cutover-finalize
migrate cutover [--apply]
migrate status
migrate resume [--apply]
migrate run --until <phase> [--apply]
Unmet dependencies run automatically. PLANNED is an expected non-success
terminal state when a phase requires operator review before mutation.
For the most conservative operation, run mutation phases individually instead
of giving resume --apply permission to apply every unmet mutation phase.
Dropbox's direct API is authoritative for exact source metadata: original path,
lowercase path, object ID, revision, size, client_modified,
server_modified, Dropbox content hash, symlink metadata, downloadability, and
raw JSON.
rclone independently inventories the same source. A missing rclone hash, partial folder queue, path mismatch, size mismatch, hash mismatch, or ambiguous case/Unicode collision blocks the source gate.
Non-downloadable Dropbox objects fail by default. They can become explicit review items only through configuration; they are never silently reported as migrated.
For ordinary files, a valid local copy requires:
- exact canonical path identity
- exact byte size
- independently calculated Dropbox content hash
- successful SHA-256 and SHA-1 calculation
Symlinks are compared using their stored Dropbox target and readlink(2).
Broken symlinks are recorded without being followed.
Recovery is missing-only. It never responds to a small discrepancy by blindly downloading the whole account again.
Conflict normalization preserves every distinct object:
MOVE_UNIQUE— safely moves into an absent canonical pathIDENTICAL_DUPLICATE— moves the redundant source into audit quarantineDIFFERENT_CONTENT_COLLISION— retains both with a deterministic[Dropbox conflict]suffixTYPE_COLLISION— retains both objects without overwrite
The canonical local manifest, not the upload process, is the source of truth. The Proton delta classifies every item as:
MATCHMISSING_IN_PROTONCONTENT_MISMATCHMTIME_MISMATCHPROTON_HASH_UNAVAILABLEPROTON_ONLYLOCAL_SYMLINK
Catch-up planning additionally uses:
CONTENT_REVISION_ELIGIBLE— a reviewed same-path file modification that P230 may upload only withcreate-new-revisionSOURCE_SUPERSEDED— an old Proton node retained because the source path was deleted or renamed
Immediately before an applied upload, the tool re-inventories Proton so a stale reviewed delta cannot silently drive new mutations. After upload it takes another completely fresh recursive snapshot. Missing files, unexpected sizes, or verified digest mismatches fail destination verification.
Phase 115 then downloads every destination file through the official Proton CLI and hashes the resulting bytes independently. A byte mismatch, incomplete queue, ambiguous CLI path, or download failure prevents a successful final baseline report.
P240 repeats a complete Proton comparison for the catch-up canonical
generation. It downloads every destination file whose UID/path/active-revision
fingerprint/expected hash lacks reusable byte evidence, verifies canonical
files against size, SHA-256, SHA-1, and Dropbox hash, and records retained old
nodes without deleting them. It then requires an empty Dropbox change probe
after catch_up.quiescence_seconds.
This release contains no command that deletes Dropbox data, Proton-only data, or obsolete destination paths. P210 may move superseded bytes inside the local archive/evidence boundary, but no provider object is deleted. P230 creates a new revision for an approved same-path modification and never uses replace. P120 is baseline evidence only. A passing P250 report establishes disconnect readiness; source-account deletion remains a separate manual administrative act.
Each migration writes:
runs/<migration-id>/
├── 00_preflight/
├── 10_source/
├── 20_download/
├── 30_local_verification/
├── 40_recovery/
├── 50_metadata/
├── 60_normalization/
├── 70_canonical/
├── 80_proton_inventory/
├── 90_delta/
├── 100_upload/
├── 110_destination_verification/
├── 115_round_trip/
├── 120_final/
│ ├── FINAL_MIGRATION_REPORT.md
│ └── FINAL_MIGRATION_REPORT.json
├── 200_catchup_source/
├── 210_catchup_local/
│ ├── staging/
│ └── superseded/
├── 220_catchup_plan/
├── 230_catchup_upload/
├── 240_cutover_verification/
│ └── staging/
├── 250_cutover_final/
│ ├── FINAL_CUTOVER_REPORT.md
│ └── FINAL_CUTOVER_REPORT.json
├── logs/
├── state.sqlite
└── run.json
Baseline and final cutover reports each use one of:
PASS— all required verification gates passedPASS_WITH_REVIEW_ITEMS— required gates passed, but documented policy items still need human reviewFAIL— one or more required verification gates failed
A failed destination or round-trip verification can still produce an auditable
FAIL baseline report. P250 additionally records review items and
disconnect_ready; reporting never converts failure into success.
- Dropbox
client_modifiedis client-supplied and is the configured source for regular-file filesystem mtime. - Dropbox
server_modifiedis provider metadata retained in manifests. - Filesystem mtime is not a creation time.
- Folder timestamps are not treated as original content timestamps.
- Dropbox does not provide a universal trustworthy creation/birth time for every object, so this tool does not invent one.
Every runtime and test dependency version lives in one file:
config/toolchain.lock.toml. This includes the Python image, production Python
packages, test tools, rclone, and Proton Drive CLI. docker/build.sh reads that
lock and passes the selected Python image into Docker; do not duplicate versions
in the Dockerfile or Compose file.
To test an upgrade:
- Update the version, official download URL, and checksum for every supported
architecture in
toolchain.lock.toml. - Run
sh docker/build.sh test. - Run
sh docker/build.sh final. - Confirm the checksum, version, deterministic test, and static assertions pass.
- Run preflight and a complete non-production migration.
- Inspect raw JSON compatibility, duplicate-name traversal, upload conflict behavior, and destination verification before release.
Pinned versions are intentionally changeable, but a future CLI may alter commands or JSON fields. Changing the lock is not proof of compatibility; the test and live-validation steps are mandatory.
All migration-specific behavior lives separately in one migration TOML. An operator should never need to edit Python, the Dockerfile, or Compose to change an account, path, policy, or supported tool version.
This is expected when recovery, metadata restoration, normalization, upload,
local catch-up, or catch-up upload would mutate data. Review that phase's
artifacts and rerun only that command with --apply.
Do not continue by disabling checks. Confirm both observers target the same
Dropbox account and root, then inspect 10_source/discrepancies.jsonl. A failed
or partial rclone listing is not valid inventory evidence.
If nearly all API objects are MISSING_IN_RCLONE on a Dropbox team account,
confirm the image uses root-namespace rclone paths (remote:/...), not
member-home paths (remote:...). Start a new migration ID after correcting an
already completed rclone inventory so stale scope data cannot be reused.
The official CLI did not publish a usable verified SHA-1 for that object. Depending on configuration, this is a failure or an explicit review item. Size alone is not promoted to a cryptographic match.
Inspect phase 100's upload_results.jsonl, upload_results.csv, and
summary.json. The migrator reads the official CLI's JSON stdout and records
the specific redacted item error and response category. An explicit
THUMBNAIL_GENERATION error is retried once without thumbnails; MIME,
authentication, timeout, conflict, and unknown failures are not blindly
retried.
UNSUPPORTED_MIME means the pinned official CLI or Proton API rejected the
media type. Proton Drive CLI 0.8.0 has no upload MIME-override option. Do not
rename the source, upload under a temporary destination name, patch the
official binary, or bypass phase 100. Keep the failed object and evidence
unchanged until an official CLI release supports it, then qualify and pin that
release before resuming.
Confirm the Proton state, password-store, and GPG directories are writable by
MIGRATOR_UID:MIGRATOR_GID and remain mounted between Compose runs.
Set MIGRATOR_UID and MIGRATOR_GID to the owner of the host directories, then
verify every bind mount's ownership and mode.
Use migrate status to identify the interrupted phase. Do not manually remove
SQLite rows, rename .partial artifacts to final names, or remove a partial
Proton destination. For an interrupted upload, rerun:
docker compose -f docker/compose.yaml run --rm migrator upload --applyThe command inventories Proton again and uploads only remaining
phase-90-approved objects after the resume safety gate passes. Do not run
verify-destination until upload reports PASS.
No local Python environment is needed. The Docker test target installs the
locked test tools in an isolated image stage, then runs pytest, Ruff linting,
and Ruff formatting checks:
sh docker/build.sh testValidate and build the production image:
sh docker/build.sh finalNo live destructive tests are included.
The deterministic unit and runner-level integration suite covers exact account guards, source drift, Dropbox content hashing, empty and multi-block files, Unicode and case collisions, broken and directory symlinks, missing and wrong-content files, API retries and interruption, partial rclone failures, recovery no-overwrite behavior, nested conflict normalization, duplicate Proton names, destination revalidation, full round-trip mismatch/resume, final statuses, catch-up generations, revision-preserving uploads, quiescence, state locking, and stale artifacts.
Read SECURITY.md before using real credentials or customer data. It documents secret-file handling, external credential stores, sensitive log metadata, container permissions, mutation controls, and safe vulnerability reporting.
Before any client run, follow the client-pilot checklist, the source-freeze procedure, and the signed live-qualification runbook.
Never attach real migration logs, manifests, tokens, or credential stores to a public issue. Reproduce defects with synthetic names and files.
Contributions are welcome when they preserve the core invariants:
- one implementation never verifies itself
- failed or incomplete evidence never passes a gate
- differing content is never silently overwritten or collapsed
- symlinks are never discarded or followed accidentally
- destination upload status is never treated as destination verification
- no source or destination deletion is introduced implicitly
- new provider behavior includes deterministic fixtures and regression tests
Architecture, state schema, phase gates, and design reasoning are documented in MIGRATION_SPEC.md.
Released under the MIT License.
This project is not affiliated with or endorsed by Dropbox, Proton, or rclone. Dropbox and Proton are trademarks of their respective owners. Verify current service terms, encryption claims, CLI behavior, account limits, and retention policies before relying on any migration workflow.