Skip to content

Repository files navigation

Dropbox to Proton Drive Migrator

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.

Why this exists

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.

Features

  • 🔎 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_modified while 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-revision strategy; 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.

Architecture

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:

  1. Dropbox API and rclone independently observe the source.
  2. Custom Python hashing independently verifies the local copy.
  3. A new official Proton CLI inventory verifies the destination after upload.
  4. Official-CLI downloads are independently hashed against the canonical archive.

Project structure

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.

Quick start

Single sources of configuration

Operators edit only two non-secret files:

  • one migration TOML for all account, path, transfer, policy, and safety behavior
  • config/toolchain.lock.toml for 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.

Prerequisites

  • 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.

1. Choose host locations and export them

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_proton

Replace 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_ROOT

The 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.

2. Create storage and the initial configuration

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}"
fi

sudo 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.id to a permanent unique name such as dropbox-to-proton-2026. Use only letters, numbers, ., _, and -.
  • Leave the [paths] values unchanged. They are container paths wired by docker/compose.yaml, not the host paths exported above.
  • Set dropbox.rclone.root to "" 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.

3. Store the Dropbox API token

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_TOKEN

Confirm 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.

4. Build and pin the migration image

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.

5. Authenticate rclone and Proton

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.conf

Follow 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.conf

The 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.sh

This 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 login

Authentication 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 Dropbox

This 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-files

If 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 Dropbox

Destination 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.

6. Retrieve IDs and finish the configuration

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-files

For 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.

7. Run the migration safely

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-inventory

Download 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-local

If 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 --apply

Plan 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 canonicalize

Inventory 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 plan

Review 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 finalize

finalize is P120. It writes a verified baseline report with disconnect_ready=false; it is not final cutover approval and never deletes Dropbox.

8. Catch up and finalize cutover

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-finalize

Review 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 --apply

Orchestration never applies a mutation plan created by the same invocation, even when --apply is present. Each unreviewed mutation is a distinct stop:

  1. The first cutover creates/reaches the P210 local-action plan and stops if local mutations are required.
  2. After reviewing P210, cutover --apply may apply that existing plan. It then creates/reaches the P230 upload plan and stops if uploads are required.
  3. After reviewing P230, another cutover --apply may 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.

Resume after interruption

docker compose -f docker/compose.yaml run --rm migrator status
docker compose -f docker/compose.yaml run --rm migrator resume

Inventories 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 --apply

A 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.

CLI

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.

Verification and safety model

Source integrity

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.

Local integrity

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 and conflicts

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 path
  • IDENTICAL_DUPLICATE — moves the redundant source into audit quarantine
  • DIFFERENT_CONTENT_COLLISION — retains both with a deterministic [Dropbox conflict] suffix
  • TYPE_COLLISION — retains both objects without overwrite

Destination integrity

The canonical local manifest, not the upload process, is the source of truth. The Proton delta classifies every item as:

  • MATCH
  • MISSING_IN_PROTON
  • CONTENT_MISMATCH
  • MTIME_MISMATCH
  • PROTON_HASH_UNAVAILABLE
  • PROTON_ONLY
  • LOCAL_SYMLINK

Catch-up planning additionally uses:

  • CONTENT_REVISION_ELIGIBLE — a reviewed same-path file modification that P230 may upload only with create-new-revision
  • SOURCE_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.

Deletion safety

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.

Outputs

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 passed
  • PASS_WITH_REVIEW_ITEMS — required gates passed, but documented policy items still need human review
  • FAIL — 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.

Timestamp semantics

  • Dropbox client_modified is client-supplied and is the configured source for regular-file filesystem mtime.
  • Dropbox server_modified is 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.

Updating dependency versions

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:

  1. Update the version, official download URL, and checksum for every supported architecture in toolchain.lock.toml.
  2. Run sh docker/build.sh test.
  3. Run sh docker/build.sh final.
  4. Confirm the checksum, version, deterministic test, and static assertions pass.
  5. Run preflight and a complete non-production migration.
  6. 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.

Troubleshooting

A command ends with PLANNED

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.

Source inventory reports discrepancies

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.

PROTON_HASH_UNAVAILABLE

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.

Proton upload reports a provider item failure

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.

Proton login does not persist

Confirm the Proton state, password-store, and GPG directories are writable by MIGRATOR_UID:MIGRATOR_GID and remain mounted between Compose runs.

Permission denied on archive or run paths

Set MIGRATOR_UID and MIGRATOR_GID to the owner of the host directories, then verify every bind mount's ownership and mode.

A run was interrupted

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 --apply

The 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.

Development and tests

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 test

Validate and build the production image:

sh docker/build.sh final

No 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.

Security

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.

Contributing

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.

License

Released under the MIT License.

Disclaimer

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.

About

Move Dropbox to Proton

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages