From e7583b007ccd8d50752b052e8964a424a147c42a Mon Sep 17 00:00:00 2001 From: 7678837-glitch <318980587+7678837-glitch@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:06:54 +0800 Subject: [PATCH 1/2] feat: add encrypted export bundles and bundle import Add `codex-switch export [FILE]`, which writes every saved profile, custom provider (API key and model catalog) and config.toml into one .csbundle encrypted with AES-256-GCM under an Argon2id key (ring + argon2), and teach `codex-switch import` to detect such bundles by content. Bundle import is offline, validates every entry before writing, classifies conflicts (skip/overwrite/merge, default skip), always renames a different account under a taken alias, backs up replaced files with a journal and rolls back on error. The legacy auth.json import is unchanged; bundle-only flags on it are usage errors. Both commands warn that refresh tokens are single-use. --- Cargo.lock | 48 +- Cargo.toml | 12 + README.md | 1 + README_CN.md | 1 + docs/CHANGELOG.md | 4 + docs/wiki/Architecture-Overview.md | 1 + docs/wiki/Chinese-Guide.md | 24 + docs/wiki/Command-Reference.md | 4 +- docs/wiki/Feature-Guide.md | 18 + src/cli.rs | 52 +- src/commands/mod.rs | 2 + src/commands/transfer.rs | 338 +++++++++++ src/config.rs | 2 +- src/entry.rs | 79 ++- src/error.rs | 40 ++ src/lib.rs | 2 + src/output.rs | 7 + src/profile.rs | 8 +- src/provider.rs | 31 + src/transfer/apply.rs | 233 ++++++++ src/transfer/crypto.rs | 260 ++++++++ src/transfer/mod.rs | 430 ++++++++++++++ src/transfer/plan.rs | 710 ++++++++++++++++++++++ tests/fixtures/export-v1.csbundle | 22 + tests/test_transfer.rs | 917 +++++++++++++++++++++++++++++ 25 files changed, 3231 insertions(+), 15 deletions(-) create mode 100644 src/commands/transfer.rs create mode 100644 src/transfer/apply.rs create mode 100644 src/transfer/crypto.rs create mode 100644 src/transfer/mod.rs create mode 100644 src/transfer/plan.rs create mode 100644 tests/fixtures/export-v1.csbundle create mode 100644 tests/test_transfer.rs diff --git a/Cargo.lock b/Cargo.lock index 5a75ae9..7b23595 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -97,6 +97,18 @@ dependencies = [ "num-traits", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + [[package]] name = "atomic" version = "0.6.1" @@ -176,6 +188,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bit-set" version = "0.5.3" @@ -203,6 +221,15 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -345,6 +372,7 @@ name = "codex-switch" version = "20261015.2.0" dependencies = [ "anyhow", + "argon2", "axum", "base64", "chrono", @@ -359,6 +387,7 @@ dependencies = [ "rand 0.10.3", "ratatui", "reqwest", + "ring", "rpassword", "self-replace", "semver", @@ -377,6 +406,7 @@ dependencies = [ "urlencoding", "webbrowser", "windows-sys 0.61.2", + "zeroize", "zip", ] @@ -631,6 +661,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", "crypto-common 0.1.7", + "subtle", ] [[package]] @@ -1724,6 +1755,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2698,10 +2740,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3433,7 +3475,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index fd5f24b..86e2c9e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,6 +64,11 @@ tar = "0.4" zip = { version = "9", default-features = false, features = ["deflate"] } libc = "0.2" rpassword = "7" +# Export bundles: AES-256-GCM/HMAC/CSPRNG from ring (already in the lockfile via +# rustls) plus Argon2id for the passphrase KDF. +ring = "0.17" +argon2 = { version = "0.5", default-features = false, features = ["alloc"] } +zeroize = "1" [target.'cfg(windows)'.dependencies] windows-sys = { version = "=0.61.2", features = [ @@ -81,6 +86,13 @@ windows-sys = { version = "=0.61.2", features = [ axum = "0.8" chrono = { version = "0.4", features = ["clock"] } +# The 64 MiB Argon2id KDF is unbearably slow unoptimised; keep debug tests fast. +[profile.dev.package.argon2] +opt-level = 3 + +[profile.dev.package.blake2] +opt-level = 3 + [profile.release] strip = true lto = true diff --git a/README.md b/README.md index 758265e..baea072 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ Run `codex-switch doctor` to inspect engine selection; `--desktop-codex ` ## What it does - Saves, imports, renames, switches, and recoverably deletes Codex profiles. +- Moves everything to another machine: `codex-switch export` writes all profiles, providers and `config.toml` into one passphrase-encrypted bundle, and `codex-switch import ` restores it offline with a dry-run preview and `skip` / `overwrite` / `merge` conflict strategies. Refresh tokens are single-use, so use each account on one machine only. - **Beta — custom API providers:** save a Responses-compatible endpoint and API key, configure multiple models with per-model reasoning and `web_search`, fetch the gateway's model catalog, and launch Codex through native per-run profiles. Compatibility depends on the gateway and model. Providers do not show ChatGPT quota or participate in ChatGPT automatic account selection: ```bash diff --git a/README_CN.md b/README_CN.md index 37eea49..7adb66b 100644 --- a/README_CN.md +++ b/README_CN.md @@ -48,6 +48,7 @@ codex-switch launch # 用最佳账号启动 Codex ## 功能一览 - 保存、导入、重命名、切换和可恢复地删除 Codex 账号。 +- 整体迁移到另一台机器:`codex-switch export` 把全部账号、提供方和 `config.toml` 打包成一个用口令加密的文件,`codex-switch import <导出包>` 离线导入,支持 `--dry-run` 预览和 `skip` / `overwrite` / `merge` 冲突策略。refresh token 只能使用一次,同一账号请只在一台机器上使用。 - **Beta:自定义 API 提供方。** 保存兼容 Responses 协议的端点和 API 密钥,为每个端点配置多个模型及各自的思考等级、`web_search`,获取网关模型目录并启动 Codex。兼容性取决于网关和具体模型;提供方不展示 ChatGPT 额度,也不参与自动选号。TUI Providers 页可用同一张表单新增/编辑(`a` / `e`),按 `Enter` / `o` 启动 Codex;启动使用 Codex 原生的每次运行独立 profile(`$CODEX_HOME` 中的 `cs-*.config.toml`),共享认证与 ChatGPT 配置保持不变: ```bash diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index f638e6f..d8a6b33 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- **Export / import to another machine.** `codex-switch export [FILE]` writes all profiles, providers (with API keys and model catalogs) and `config.toml` into one `.csbundle`, encrypted with AES-256-GCM under an Argon2id key (64 MiB, t=3, p=1) and written `0600`; `--plaintext` requires confirmation. `codex-switch import ` detects bundles by content, imports offline (managed policy and identity checks still apply), offers `--dry-run`, `--strategy skip|overwrite|merge`, `--activate`, backs up replaced files to `import-backups/` and rolls back on error. Existing `auth.json` imports are unchanged. Refresh tokens are single-use: both commands warn that using one account on two machines forces a re-login. + ## v20261015.2.0 — 2026-10-15 - TUI 主面板和账号菜单隐藏已确认无 Daybreak Blue/Red 资格的详情行;未知状态、查询失败和已有资格仍保留展示。 diff --git a/docs/wiki/Architecture-Overview.md b/docs/wiki/Architecture-Overview.md index ea4a64b..aceaac8 100644 --- a/docs/wiki/Architecture-Overview.md +++ b/docs/wiki/Architecture-Overview.md @@ -98,6 +98,7 @@ If periodic refresh or warmup is desired, a user-managed cron, systemd user time | `$CODEX_SWITCH_HOME/providers//models.json` | Generated Codex model catalog for `/model` | | `$CODEX_SWITCH_HOME/current` | Current alias marker | | `$CODEX_SWITCH_HOME/deleted-profiles/` | Recoverable profile archives | +| `$CODEX_SWITCH_HOME/import-backups//` | Bundle-import backups of replaced files plus `journal.json` (contains secrets; `0700`/`0600`) | | `$CODEX_SWITCH_HOME/cache.json` | Usage, workspace metadata, and rejected-credential cache | | `$CODEX_SWITCH_HOME/config.toml` | Application configuration | | `$CODEX_SWITCH_HOME/logs/` | Rotated diagnostic logs | diff --git a/docs/wiki/Chinese-Guide.md b/docs/wiki/Chinese-Guide.md index 7353fad..72afc3b 100644 --- a/docs/wiki/Chinese-Guide.md +++ b/docs/wiki/Chinese-Guide.md @@ -45,6 +45,30 @@ ChatGPT 文件登录需要 `cli_auth_credentials_store = "file"`。登录、导 codex-switch import ~/auth-backups ``` +### 迁移到另一台机器(导出 / 导入) + +```bash +# 机器 A:导出全部账号、提供方(含 API 密钥和模型目录)与 config.toml +codex-switch export ~/codex-switch.csbundle # 交互输入两次口令 +codex-switch export out.csbundle --no-config # 不带 config.toml +codex-switch export out.csbundle --no-providers # 不带提供方 + +# 机器 B:先预览,再导入 +codex-switch import ~/codex-switch.csbundle --dry-run +codex-switch import ~/codex-switch.csbundle # 默认 --strategy skip +codex-switch import ~/codex-switch.csbundle --strategy merge --yes --activate +``` + +- 导出包默认用 Argon2id(64 MiB、t=3、p=1)派生密钥,AES-256-GCM 加密,整个文件头参与认证;文件权限 0600,已存在的目标文件需 `--force` 才会覆盖。脚本中可用 `--passphrase-stdin` 从标准输入第一行读取口令,口令不接受命令行参数或环境变量。 +- `--plaintext` 写出未加密的导出包,需要交互确认或 `--yes`;文件里是可直接使用的 token 和 API 密钥,导入后请立即删除。 +- **refresh token 只能使用一次。** 导出不会让源机器上的账号失效;两台机器同时使用同一账号时,先刷新的一方会让另一方报 `refresh_token_reused`,需要重新 `codex-switch login <别名>`。 +- 导入离线进行,不调用 Usage API,但仍执行管理员工作区策略和身份检查;任何条目校验失败都会在写入前整体中止。口令错误、文件被改动、版本过新分别给出不同错误(`--json` 下的 `code` 为 `wrong_passphrase`、`bundle_tampered`、`unsupported_bundle_version`)。 +- 冲突策略:`skip`(默认,保留本地)、`overwrite`(用导入内容替换,旧文件备份到 `import-backups/`)、`merge`(账号保留 `last_refresh` 更新的一份;`config.toml` 只补充本地缺少的键;提供方暂按 `skip` 处理)。别名被其他账号或提供方占用时(大小写不敏感)总是改名导入为 `<别名>_2`,绝不覆盖其他账号。`overwrite` / `merge` 需要确认,非交互或 `--json` 时需要 `--yes`。 +- `--dry-run` 会把 `config.toml` 中的 `[proxy]` 标为“machine-specific, review”,代理通常与机器相关,导入后请检查。 +- 导入默认不切换当前账号,只提示源机器当前账号;加 `--activate[=别名]` 才会切换。 +- 写入中途出错会按日志反向回滚;进程崩溃留下 `in-progress` 日志时,下次导入会拒绝执行并打印手动恢复步骤。 +- 缓存、日志、已删除账号、`recovery/` 和 Codex 自身文件不导出。 + ## 日常操作(ChatGPT 账号) | 目的 | 命令 / 操作 | diff --git a/docs/wiki/Command-Reference.md b/docs/wiki/Command-Reference.md index 5340321..0f3d2f8 100644 --- a/docs/wiki/Command-Reference.md +++ b/docs/wiki/Command-Reference.md @@ -10,6 +10,8 @@ The installed binary remains authoritative: use `codex-switch --help` and `codex |---|---| | `login [--device] [alias]` | Add or reauthorize a profile through browser PKCE or device-code login. If the alias already exists, it is reauthorized; otherwise a new profile is created. | | `import [alias]` | Validate and import one `auth.json`, or recursively scan a directory for JSON files. The alias applies to single-file imports only; directories auto-assign aliases. An account that is already saved (same file, or same `account_id` and email) is skipped instead of duplicated, so its single-use refresh token is not spent. | +| `export [FILE] [--plaintext [--yes]] [--force] [--passphrase-stdin] [--no-providers] [--no-config]` | Write every saved profile, custom provider (API key and `models.json` included) and `config.toml` into one bundle, default `./codex-switch-.csbundle`, mode `0600`. The bundle is encrypted with AES-256-GCM under an Argon2id key (m = 64 MiB, t = 3, p = 1); the whole header is authenticated. The passphrase is prompted twice, or read from the first stdin line with `--passphrase-stdin`; it is never taken from argv or the environment. An existing file is refused unless `--force`. `--plaintext` writes an unencrypted bundle and needs interactive confirmation or `--yes` (always `--yes` under `--json` or without a TTY). Caches, logs, deleted profiles, `recovery/` and Codex-owned files are not exported. Refresh tokens are single-use: the exported accounts stay valid on the source, and using one account on both machines makes one of them log in again. | +| `import [--strategy skip\|overwrite\|merge] [--dry-run] [--yes] [--activate[=ALIAS]] [--passphrase-stdin]` | Import an export bundle, detected by content (`"format": "codex-switch-export"`), not extension. Offline: no Usage API call, but the managed workspace policy, the `account_id` requirement and the identity rules still apply, and any invalid entry aborts before a write. Same account (`account_id` + email) or same provider (alias + `identity_id`) with different content, or a different `config.toml`, is a conflict handled by `--strategy` (default `skip`; `overwrite` replaces after a backup; `merge` keeps the newer credential by `last_refresh` and adds only missing `config.toml` keys; provider merge is treated as `skip`). A different account under a taken alias (case-insensitive) is always imported under `_2`. Email-only matches are skipped as `ambiguous`. `overwrite`/`merge` need confirmation or `--yes`. `--dry-run` prints the plan and writes nothing; `[proxy]` in `config.toml` is flagged "machine-specific, review". Replaced files are backed up under `import-backups//` with a `journal.json`; an error rolls back. `current` and the live `auth.json` change only with `--activate` (default alias: the source machine's current account), except that overwriting the local current profile also updates the live file. Bundle-only flags on an `auth.json` path, or an alias with a bundle, are usage errors (exit 2). | | `doctor [--desktop-codex ]` | Check the selected Codex runtime and, optionally, a desktop app's explicitly supplied bundled engine. Runtime selection uses `CODEX_SWITCH_CODEX`, then PATH, then known macOS desktop installations. A missing PATH CLI alone does not fail a healthy desktop-only setup. Invalid selected or explicitly supplied engines produce a nonzero exit. | | `list [-f]` | Show profiles, usage, and availability; `-f` / `--force` bypasses the cache. | | `use [alias] [--consume-card]` | Switch explicitly, or omit the alias to auto-select with the unified scoring algorithm. When the pool is exhausted, `--consume-card` consumes the earliest-expiring reset card to revive an account (auto-select only; ignored when an alias is given). | @@ -33,7 +35,7 @@ The installed binary remains authoritative: use `codex-switch --help` and `codex | Option | Environment variable | Behavior | |---|---|---| -| `--json` | — | Compact structured output (supported by `list`, `use`, `launch`, `warmup`, `reset-card`, `rename`, `delete`, `login`, `import`, `self-update`, `doctor`, `provider add`, `provider list`, `provider show`, `provider rename`, `provider remove`, `provider fetch-models`, `provider probe`). `doctor --json` reports `ok`, `minimum_version`, `aligned_version`, required `runtime_note`, `core_account_commands_available`, `launch_runtime_available`, `path_cli`, `selected_runtime`, `selected_runtime_source`, `desktop_codex`, and (when both versions parse) `versions_match` plus `version_relation`; each executable report has `executable`, `version`, `status`, and optional `note`. `launch --json` prints one envelope after Codex exits; each captured Codex stream is limited to 1 MiB and has a `*_truncated` flag. | +| `--json` | — | Compact structured output (supported by `list`, `use`, `launch`, `warmup`, `reset-card`, `rename`, `delete`, `login`, `import`, `export`, `self-update`, `doctor`, `provider add`, `provider list`, `provider show`, `provider rename`, `provider remove`, `provider fetch-models`, `provider probe`). `doctor --json` reports `ok`, `minimum_version`, `aligned_version`, required `runtime_note`, `core_account_commands_available`, `launch_runtime_available`, `path_cli`, `selected_runtime`, `selected_runtime_source`, `desktop_codex`, and (when both versions parse) `versions_match` plus `version_relation`; each executable report has `executable`, `version`, `status`, and optional `note`. `launch --json` prints one envelope after Codex exits; each captured Codex stream is limited to 1 MiB and has a `*_truncated` flag. | | `--json-pretty` | — | Indented structured output. | | `--proxy ` | `CS_PROXY` | Override proxy configuration for this process; supports `http(s)://`, `socks4://`, `socks5://`, and `socks5h://` (remote DNS). | | `--color ` | `CS_COLOR` | Control CLI terminal color. `NO_COLOR` disables CLI color regardless of this option. The TUI still paints its designed palette. | diff --git a/docs/wiki/Feature-Guide.md b/docs/wiki/Feature-Guide.md index 947a3f2..cbd0b31 100644 --- a/docs/wiki/Feature-Guide.md +++ b/docs/wiki/Feature-Guide.md @@ -23,6 +23,24 @@ codex-switch import ~/auth-backups Interactive login deduplicates local profiles by `account_id` first and falls back to email when safe. Import is deliberately create-only and never updates an existing profile: Usage API validation proves that the bearer can access a workspace, but a Team workspace ID can be shared by several users and cannot authorize overwriting another saved credential. For the same reason, import will not write a *second* profile for an account you already have: when the incoming file is byte-identical to a saved profile, or carries the same `account_id` **and** email, the import is skipped before validation so its single-use refresh token is never spent. Use `login ` to refresh an existing profile. +### Move to another machine + +`export` packs every saved profile, custom provider (API key and model catalog included) and `config.toml` into one passphrase-encrypted bundle; `import` restores it on the other machine: + +```bash +# machine A +codex-switch export ~/codex-switch.csbundle +# machine B +codex-switch import ~/codex-switch.csbundle --dry-run +codex-switch import ~/codex-switch.csbundle --strategy merge --yes --activate +``` + +The bundle is AES-256-GCM encrypted under an Argon2id key and written with mode `0600`. A wrong passphrase, a modified file and a bundle from a newer codex-switch fail with distinct errors before anything is written. Import is offline — tokens are not sent to the Usage API — but the managed workspace policy and identity checks still run, and one invalid entry aborts the whole import. + +Conflicts follow `--strategy`: `skip` (default) keeps the local copy, `overwrite` replaces it after a backup to `import-backups/`, and `merge` keeps the credential with the newer `last_refresh` and adds only missing `config.toml` keys (providers are kept as with `skip`). A different account under a taken alias is always imported under a new alias, never over the other account. The dry-run flags `[proxy]` as machine-specific. The current account is switched only with `--activate`. + +Refresh tokens are single-use. Export does not retire the accounts on the source machine; if both machines keep using the same account, one of them will fail with `refresh_token_reused` and need `codex-switch login `. + Profile deletion is recoverable. An inactive profile is moved under `deleted-profiles/` after confirmation; the active profile cannot be deleted. See [recovery instructions](Troubleshooting#recover-a-deleted-profile). ## External login detection diff --git a/src/cli.rs b/src/cli.rs index bad802d..32164f0 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -127,7 +127,7 @@ pub enum ProviderCommand { after_help = "Examples:\n codex-switch list\n codex-switch use\n codex-switch rename old-alias new-alias\n codex-switch import ./auth-backups\n codex-switch self-update --check\n\nRun `codex-switch --help` for command-specific options." )] pub struct Cli { - /// Output as compact JSON (supported by list, use, launch, warmup, reset-card, rename, delete, login, import, self-update, doctor, provider add/list/show/rename/remove/fetch-models/probe/diagnose) + /// Output as compact JSON (supported by list, use, launch, warmup, reset-card, rename, delete, login, import, export, self-update, doctor, provider add/list/show/rename/remove/fetch-models/probe/diagnose) #[arg(long, global = true)] pub json: bool, @@ -214,12 +214,56 @@ pub enum Commands { #[arg(long)] device: bool, }, - /// Import an auth.json file, or recursively scan a directory for JSON files to validate and import + /// Import an auth.json file, a directory of JSON files, or an export bundle + #[command( + after_help = "A file whose JSON has \"format\": \"codex-switch-export\" is imported as an export bundle (see `export --help`); anything else uses the auth.json import, which validates each token through the Usage API.\nBundle import is offline: tokens are not sent anywhere, but the managed workspace policy and identity checks still apply. Conflicts (same account or provider with different content, or a different config.toml) follow --strategy; a different account under a taken alias is always imported under a new alias. Replaced files are backed up under import-backups/.\nThe current account is never switched unless --activate is given.\n\nExamples:\n codex-switch import ./auth.json work\n codex-switch import backup.csbundle --dry-run\n codex-switch import backup.csbundle --strategy merge --yes --activate" + )] Import { - /// Path to an auth.json file or a directory containing JSON files + /// Path to an auth.json file, a directory containing JSON files, or a .csbundle export path: String, - /// Optional profile alias (single-file import only; directories auto-assign aliases) + /// Optional profile alias (single auth.json import only; not for bundles) alias: Option, + /// [bundle] What to do when an item conflicts with local data + #[arg(long, value_enum)] + strategy: Option, + /// [bundle] Show what would happen without writing anything + #[arg(long)] + dry_run: bool, + /// [bundle] Confirm overwrite/merge without prompting (required with --json or non-TTY) + #[arg(long, short)] + yes: bool, + /// [bundle] After importing, switch to ALIAS (default: the source machine's current account) + #[arg(long, value_name = "ALIAS", num_args = 0..=1, require_equals = true)] + activate: Option>, + /// [bundle] Read the passphrase from the first line of stdin + #[arg(long)] + passphrase_stdin: bool, + }, + /// Export all profiles, providers and config.toml into one passphrase-encrypted bundle + #[command( + after_help = "The bundle (default name codex-switch-YYYYMMDD-HHMMSS.csbundle, mode 0600) holds every saved ChatGPT profile, custom provider (with its API key and model catalog) and config.toml, encrypted with AES-256-GCM under an Argon2id-derived key. Caches, logs, deleted profiles and Codex's own files are not exported.\n\nRefresh tokens are single-use: the exported accounts stay usable here too, and if both machines keep using the same account one of them will have to log in again.\n\nExamples:\n codex-switch export\n codex-switch export ~/backup.csbundle --no-config\n printf '%s\\n' \"$PASS\" | codex-switch export out.csbundle --passphrase-stdin" + )] + Export { + /// Destination file (default: ./codex-switch-.csbundle) + file: Option, + /// Write an UNENCRYPTED bundle (needs confirmation, or --yes) + #[arg(long)] + plaintext: bool, + /// Confirm --plaintext without prompting + #[arg(long, short)] + yes: bool, + /// Overwrite an existing destination file + #[arg(long)] + force: bool, + /// Read the passphrase from the first line of stdin + #[arg(long)] + passphrase_stdin: bool, + /// Do not include custom API providers + #[arg(long)] + no_providers: bool, + /// Do not include config.toml + #[arg(long)] + no_config: bool, }, /// Manually check GitHub Releases (`--check`) or update this binary #[command( diff --git a/src/commands/mod.rs b/src/commands/mod.rs index fe245eb..d9b617c 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -7,6 +7,7 @@ mod misc; pub(crate) mod profile; mod provider; mod render; +mod transfer; mod update; pub(crate) use crate::launch::{launch_cmd, launch_for_tui}; @@ -17,4 +18,5 @@ pub(crate) use misc::{format_resync_confirm_prompt, open_cmd, reset_card_cmd, wa pub(crate) use profile::{delete_cmd, list_cmd, rename_cmd, use_cmd}; pub(crate) use provider::provider_cmd; pub(crate) use render::confirm; +pub(crate) use transfer::{ImportBundleArgs, export_cmd, import_bundle_cmd}; pub(crate) use update::self_update_cmd; diff --git a/src/commands/transfer.rs b/src/commands/transfer.rs new file mode 100644 index 0000000..05c2e8a --- /dev/null +++ b/src/commands/transfer.rs @@ -0,0 +1,338 @@ +use std::io::{IsTerminal, Read}; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use serde::Serialize; +use zeroize::Zeroizing; + +use super::render::confirm_default_no; +use crate::output::{print_json, user_println}; +use crate::transfer::plan::{self, ExportOptions, Strategy}; +use crate::transfer::{self, PLAINTEXT_WARNING, SINGLE_USE_WARNING}; +use crate::{auth, color, profile}; + +fn warn(msg: &str) { + eprintln!("{}", color::warn(&format!("Warning: {msg}"))); +} + +/// One line from stdin; only the trailing newline is removed so a passphrase +/// may contain spaces. +fn passphrase_from_stdin() -> Result> { + let mut raw = Zeroizing::new(String::new()); + std::io::stdin() + .read_to_string(&mut raw) + .context("reading passphrase from stdin")?; + let line = raw.lines().next().unwrap_or_default().to_string(); + Ok(Zeroizing::new(line)) +} + +fn read_passphrase(stdin_mode: bool, confirm: bool) -> Result> { + let pass = if stdin_mode { + passphrase_from_stdin()? + } else if std::io::stdin().is_terminal() { + let first = Zeroizing::new( + rpassword::prompt_password("Bundle passphrase: ").context("reading passphrase")?, + ); + if confirm { + let second = Zeroizing::new( + rpassword::prompt_password("Repeat passphrase: ").context("reading passphrase")?, + ); + if *first != *second { + anyhow::bail!("passphrases do not match"); + } + } + first + } else { + anyhow::bail!( + "no interactive terminal for a hidden prompt; pass the passphrase on stdin with --passphrase-stdin" + ); + }; + if pass.is_empty() { + anyhow::bail!("passphrase cannot be empty"); + } + Ok(pass) +} + +#[derive(Serialize)] +struct JsonExport { + ok: bool, + path: String, + encrypted: bool, + profiles: Vec, + providers: Vec, + config: bool, + source_current: Option, + warning: &'static str, +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn export_cmd( + file: Option<&Path>, + plaintext: bool, + yes: bool, + force: bool, + passphrase_stdin: bool, + no_providers: bool, + no_config: bool, + json: bool, +) -> Result<()> { + let path: PathBuf = file + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(transfer::default_export_name())); + if !force && path.exists() { + anyhow::bail!( + "{} already exists; choose another path or pass --force", + path.display() + ); + } + let (payload, summary) = plan::collect_export(ExportOptions { + no_providers, + no_config, + })?; + + let passphrase = if plaintext { + if passphrase_stdin { + anyhow::bail!("--plaintext and --passphrase-stdin cannot be combined"); + } + if !yes { + if json || !std::io::stdin().is_terminal() { + anyhow::bail!( + "a plaintext export contains live tokens and API keys; rerun with --plaintext --yes to confirm" + ); + } + warn(PLAINTEXT_WARNING); + if !confirm_default_no("Write an unencrypted bundle? [y/N] ") { + return Err(crate::error::CsError::Aborted.into()); + } + } + None + } else { + Some(read_passphrase(passphrase_stdin, true)?) + }; + + let bytes = Zeroizing::new(transfer::encode_bundle( + &payload, + passphrase.as_ref().map(|p| p.as_str()), + )?); + transfer::write_private_file(&path, &bytes, force)?; + + warn(SINGLE_USE_WARNING); + if plaintext { + warn(PLAINTEXT_WARNING); + } + if json { + print_json(&JsonExport { + ok: true, + path: path.display().to_string(), + encrypted: !plaintext, + profiles: summary.profiles, + providers: summary.providers, + config: summary.config, + source_current: payload.source_current, + warning: SINGLE_USE_WARNING, + }); + } else { + println!( + "{}", + color::success(&format!( + "Exported {} profile(s), {} provider(s){} to {} ({})", + summary.profiles.len(), + summary.providers.len(), + if summary.config { + " and config.toml" + } else { + "" + }, + path.display(), + if plaintext { + "UNENCRYPTED" + } else { + "encrypted" + } + )) + ); + println!( + "Import it on the other machine with `codex-switch import {}`.", + path.display() + ); + } + Ok(()) +} + +pub(crate) struct ImportBundleArgs<'a> { + pub path: &'a Path, + pub strategy: Strategy, + pub dry_run: bool, + pub yes: bool, + pub activate: Option>, + pub passphrase_stdin: bool, + pub json: bool, +} + +#[derive(Serialize)] +struct JsonImportBundle<'a> { + ok: bool, + dry_run: bool, + strategy: &'static str, + encrypted: bool, + items: &'a [plan::PlanItem], + notices: &'a [String], + source_current: Option<&'a str>, + source_current_target: Option<&'a str>, + backup_dir: Option, + activated: Option, + warning: &'static str, +} + +fn print_plan(plan: &plan::ImportPlan) { + println!( + "{:<44} {:<16} {:<10} {:<20} REASON", + "KEY", "KIND", "ACTION", "TARGET" + ); + for item in &plan.items { + println!( + "{:<44} {:<16} {:<10} {:<20} {}", + item.key, + item.kind, + item.action.as_str(), + item.target.as_deref().unwrap_or("-"), + item.reason + ); + } + for notice in &plan.notices { + warn(notice); + } +} + +pub(crate) fn import_bundle_cmd(args: ImportBundleArgs<'_>) -> Result<()> { + auth::ensure_file_credentials_store()?; + let bundle = transfer::read_bundle(args.path)?; + let encrypted = bundle.is_encrypted(); + let passphrase = if encrypted { + Some(read_passphrase(args.passphrase_stdin, false)?) + } else { + warn(PLAINTEXT_WARNING); + None + }; + let payload = transfer::open_payload(&bundle, passphrase.as_ref().map(|p| p.as_str()))?; + drop(passphrase); + let preview = plan::classify(&payload, args.strategy)?; + + if args.dry_run { + if args.json { + print_json(&JsonImportBundle { + ok: true, + dry_run: true, + strategy: args.strategy.as_str(), + encrypted, + items: &preview.items, + notices: &preview.notices, + source_current: preview.source_current.as_deref(), + source_current_target: preview.source_current_target.as_deref(), + backup_dir: None, + activated: None, + warning: SINGLE_USE_WARNING, + }); + } else { + print_plan(&preview); + println!("Dry run: nothing was written."); + } + warn(SINGLE_USE_WARNING); + return Ok(()); + } + + transfer::apply::ensure_no_unfinished_import()?; + if preview.needs_confirmation() && !args.yes { + if args.json || !std::io::stdin().is_terminal() || args.passphrase_stdin { + anyhow::bail!( + "this import would overwrite or merge existing data; review it with --dry-run and rerun with --yes" + ); + } + print_plan(&preview); + if !confirm_default_no("Apply these changes? [y/N] ") { + return Err(crate::error::CsError::Aborted.into()); + } + } + + let result = { + let _transaction = profile::lock_auth_transaction()?; + let locked = plan::classify(&payload, args.strategy)?; + if locked.decisions() != preview.decisions() { + anyhow::bail!( + "the local store changed while the import was being prepared; rerun the import" + ); + } + let applied = transfer::apply::apply(&locked)?; + (locked, applied) + }; + let (final_plan, applied) = result; + + let activated = match &args.activate { + None => None, + Some(requested) => { + let alias = requested + .clone() + .or_else(|| final_plan.source_current_target.clone()) + .ok_or_else(|| { + anyhow::anyhow!( + "--activate has no alias: the bundle records no current account" + ) + })?; + profile::validate_alias(&alias)?; + if !profile::profile_auth_path(&alias)?.exists() { + anyhow::bail!("cannot activate '{alias}': no such profile after import"); + } + profile::switch_profile(&alias)?; + Some(alias) + } + }; + + if args.json { + print_json(&JsonImportBundle { + ok: true, + dry_run: false, + strategy: args.strategy.as_str(), + encrypted, + items: &final_plan.items, + notices: &final_plan.notices, + source_current: final_plan.source_current.as_deref(), + source_current_target: final_plan.source_current_target.as_deref(), + backup_dir: applied.backup_dir.as_ref().map(|p| p.display().to_string()), + activated: activated.clone(), + warning: SINGLE_USE_WARNING, + }); + } else { + print_plan(&final_plan); + let written = final_plan + .items + .iter() + .filter(|i| i.action.writes()) + .count(); + println!( + "{}", + color::success(&format!("Import finished: {written} item(s) written.")) + ); + if let Some(dir) = &applied.backup_dir { + println!( + "Backup and journal (contains secrets, keep private): {}", + dir.display() + ); + } + match (&activated, &final_plan.source_current_target) { + (Some(alias), _) => user_println(&format!("Switched to profile: {alias}")), + (None, Some(target)) => println!( + "Source machine's current account: {} → run `codex-switch use {target}`", + final_plan.source_current.as_deref().unwrap_or(target) + ), + _ => {} + } + if !encrypted { + println!( + "Delete {} now that it has been imported.", + args.path.display() + ); + } + } + warn(SINGLE_USE_WARNING); + Ok(()) +} diff --git a/src/config.rs b/src/config.rs index 3835eb3..dafbe4d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -181,7 +181,7 @@ fn deprecated_key_warnings(raw: &str) -> Vec { warnings } -fn load_from_str_with_warnings( +pub(crate) fn load_from_str_with_warnings( raw: &str, ) -> std::result::Result<(AppConfig, Vec), toml::de::Error> { let config = toml::from_str::(raw)?; diff --git a/src/entry.rs b/src/entry.rs index 4aab4d2..71dd32c 100644 --- a/src/entry.rs +++ b/src/entry.rs @@ -145,7 +145,10 @@ pub async fn run_cli() { if should_report_error(&e) { tracing::error!(target: REPORTED_ERROR_TARGET, error = %format!("{e:#}"), "command failed"); if use_json { - print_error(&format!("{e:#}")); + let code = e + .downcast_ref::() + .and_then(|err| err.code()); + crate::output::print_error_with_code(&format!("{e:#}"), code); } else { eprintln!("{}", color::error(&format!("Error: {e:#}"))); } @@ -154,6 +157,15 @@ pub async fn run_cli() { } } +/// Report a bundle-only flag misuse the way clap reports a usage error +/// (exit code 2), since it can only be detected after reading the file. +fn bundle_usage_error(message: &str) -> ! { + use clap::CommandFactory; + crate::cli::Cli::command() + .error(clap::error::ErrorKind::ArgumentConflict, message) + .exit() +} + fn command_name(cmd: &Commands) -> &'static str { match cmd { Commands::Use { .. } => "use", @@ -163,6 +175,7 @@ fn command_name(cmd: &Commands) -> &'static str { Commands::Delete { .. } => "delete", Commands::Login { .. } => "login", Commands::Import { .. } => "import", + Commands::Export { .. } => "export", Commands::SelfUpdate { .. } => "self-update", Commands::Doctor { .. } => "doctor", Commands::Warmup { .. } => "warmup", @@ -215,9 +228,69 @@ async fn dispatch( Commands::Login { alias, device } => { commands::login_cmd(alias.as_deref(), device, json).await? } - Commands::Import { path, alias } => { - commands::import_cmd(&path, alias.as_deref(), json).await? + Commands::Import { + path, + alias, + strategy, + dry_run, + yes, + activate, + passphrase_stdin, + } => { + let bundle_flags: Vec<&str> = [ + (strategy.is_some(), "--strategy"), + (dry_run, "--dry-run"), + (yes, "--yes"), + (activate.is_some(), "--activate"), + (passphrase_stdin, "--passphrase-stdin"), + ] + .into_iter() + .filter_map(|(set, name)| set.then_some(name)) + .collect(); + let is_bundle = crate::transfer::looks_like_bundle(std::path::Path::new(&path)); + if is_bundle { + if alias.is_some() { + bundle_usage_error( + "an alias cannot be given when importing an export bundle; aliases come from the bundle", + ); + } + commands::import_bundle_cmd(commands::ImportBundleArgs { + path: std::path::Path::new(&path), + strategy: strategy.unwrap_or_default(), + dry_run, + yes, + activate, + passphrase_stdin, + json, + })? + } else { + if !bundle_flags.is_empty() { + bundle_usage_error(&format!( + "{} can only be used with an export bundle, and '{path}' is not one", + bundle_flags.join(", ") + )); + } + commands::import_cmd(&path, alias.as_deref(), json).await? + } } + Commands::Export { + file, + plaintext, + yes, + force, + passphrase_stdin, + no_providers, + no_config, + } => commands::export_cmd( + file.as_deref(), + plaintext, + yes, + force, + passphrase_stdin, + no_providers, + no_config, + json, + )?, Commands::SelfUpdate { check, version, diff --git a/src/error.rs b/src/error.rs index d34fbdf..4283ccb 100644 --- a/src/error.rs +++ b/src/error.rs @@ -19,4 +19,44 @@ pub enum CsError { #[error("JSON error: {0}")] Json(#[from] serde_json::Error), + + #[error("not a codex-switch export bundle: {0}")] + NotABundle(String), + + #[error( + "this bundle was created by codex-switch {app_version} (schema {schema}); this build reads \ + schema <= {supported}. Run `codex-switch self-update` on this machine." + )] + UnsupportedBundleVersion { + app_version: String, + schema: u64, + supported: u32, + }, + + #[error("unsupported or corrupted encryption parameters: {0}")] + UnsupportedEncryption(String), + + #[error("wrong passphrase (or the file header was modified)")] + WrongPassphrase, + + #[error("bundle was modified or corrupted after it was created")] + BundleTampered, + + #[error("invalid bundle: {0}")] + InvalidBundle(String), +} + +impl CsError { + /// Stable machine-readable code for `--json` error output. + pub fn code(&self) -> Option<&'static str> { + match self { + CsError::NotABundle(_) => Some("not_a_bundle"), + CsError::UnsupportedBundleVersion { .. } => Some("unsupported_bundle_version"), + CsError::UnsupportedEncryption(_) => Some("unsupported_encryption"), + CsError::WrongPassphrase => Some("wrong_passphrase"), + CsError::BundleTampered => Some("bundle_tampered"), + CsError::InvalidBundle(_) => Some("invalid_bundle"), + _ => None, + } + } } diff --git a/src/lib.rs b/src/lib.rs index c1110c9..03a9d27 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -42,6 +42,8 @@ mod provider; #[allow(dead_code)] mod signals; #[allow(dead_code)] +mod transfer; +#[allow(dead_code)] mod tui; #[allow(dead_code)] mod update; diff --git a/src/output.rs b/src/output.rs index aadf839..e790168 100644 --- a/src/output.rs +++ b/src/output.rs @@ -152,6 +152,8 @@ pub struct JsonOk { pub struct JsonError { pub ok: bool, pub error: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub code: Option<&'static str>, } #[derive(Serialize)] @@ -519,9 +521,14 @@ pub fn print_json(val: &T) { } pub fn print_error(msg: &str) { + print_error_with_code(msg, None); +} + +pub fn print_error_with_code(msg: &str, code: Option<&'static str>) { let e = JsonError { ok: false, error: msg.to_string(), + code, }; println!("{}", serialize(&e)); } diff --git a/src/profile.rs b/src/profile.rs index 5b188f4..19350b6 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -142,12 +142,12 @@ pub fn lock_reset_card_consume(profile_path: &Path) -> Result { ) } -struct AuthTransaction { +pub(crate) struct AuthTransaction { _launch: File, _auth: File, } -fn lock_auth_transaction() -> Result { +pub(crate) fn lock_auth_transaction() -> Result { lock_auth_transaction_after_launch(|| {}) } @@ -900,7 +900,9 @@ pub fn detect_auth_change() -> AuthChange { /// `last_refresh` as written by `auth::apply_tokens` and `login`: an RFC3339 /// string at the auth.json root. Absent or malformed values yield `None`. -fn parse_last_refresh(val: &serde_json::Value) -> Option> { +pub(crate) fn parse_last_refresh( + val: &serde_json::Value, +) -> Option> { let raw = val.get("last_refresh")?.as_str()?; chrono::DateTime::parse_from_rfc3339(raw).ok() } diff --git a/src/provider.rs b/src/provider.rs index 03705d2..7fb5b8f 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -4566,6 +4566,37 @@ pub fn list_providers() -> Result> { Ok(names) } +/// Parse and validate `provider.toml` text the way `load` does, without +/// touching disk. Used to reject a bad import before anything is written. +pub(crate) fn parse_and_validate(alias: &str, text: &str) -> Result { + crate::profile::validate_alias(alias)?; + let mut profile: ProviderProfile = + toml::from_str(text).with_context(|| format!("parsing provider profile '{alias}'"))?; + profile.alias = alias.to_string(); + profile.normalize(); + profile + .validate_inner(true) + .with_context(|| format!("validating provider profile '{alias}'"))?; + Ok(profile) +} + +/// Rewrite imported `provider.toml` text for a different alias, applying the +/// same alias-derived field updates as [`rename`]. Returns the new TOML text. +pub(crate) fn retarget_provider_text(old: &str, new: &str, text: &str) -> Result { + let mut profile = parse_and_validate(old, text)?; + crate::profile::validate_alias(new)?; + validate_provider_auth_overrides(&profile.provider_id, &profile.codex_config)?; + profile.alias = new.to_string(); + profile.provider_id = sanitize_provider_id(new); + if profile.env_key == derive_env_key(old) { + profile.env_key = derive_env_key(new); + } + profile.name = new.to_string(); + profile.normalize(); + profile.validate()?; + toml::to_string_pretty(&profile).context("serializing imported provider profile") +} + /// Load a provider profile by alias. pub fn load(alias: &str) -> Result { load_inner(alias, true) diff --git a/src/transfer/apply.rs b/src/transfer/apply.rs new file mode 100644 index 0000000..82ac010 --- /dev/null +++ b/src/transfer/apply.rs @@ -0,0 +1,233 @@ +//! Apply an import plan: one auth transaction, a backup of everything that is +//! replaced, a journal, and a reverse-order rollback on any error. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +use super::LogicalKey; +use super::plan::{ImportPlan, PlanItem}; + +#[derive(Debug, Serialize, Deserialize)] +pub(crate) struct Journal { + pub state: String, + /// Absolute paths replaced; their previous content is under `backup/`. + pub replaced: Vec, + /// Absolute paths created by this import. + pub created: Vec, + /// Directories created by this import (removed on rollback when empty). + pub created_dirs: Vec, +} + +pub(crate) fn backups_root() -> Result { + Ok(crate::auth::app_home()?.join("import-backups")) +} + +/// Refuse to start while an earlier import never finished: its journal and +/// backup are the only record of what to restore. +pub(crate) fn ensure_no_unfinished_import() -> Result<()> { + let root = backups_root()?; + let Ok(read) = std::fs::read_dir(&root) else { + return Ok(()); + }; + for entry in read.flatten() { + let journal_path = entry.path().join("journal.json"); + let Ok(raw) = std::fs::read(&journal_path) else { + continue; + }; + let Ok(journal) = serde_json::from_slice::(&raw) else { + continue; + }; + if journal.state == "in-progress" { + anyhow::bail!( + "an earlier import did not finish (journal {}).\nRestore it manually before \ + importing again:\n 1. copy each file under {} back to the path listed in \ + `replaced`\n 2. delete each path listed in `created`\n 3. set \"state\" in the \ + journal to \"rolled-back\"", + journal_path.display(), + entry.path().join("backup").display() + ); + } + } + Ok(()) +} + +fn ensure_private_dir_tracked(dir: &Path, created: &mut Vec) -> Result<()> { + let mut missing = Vec::new(); + let mut cursor = Some(dir); + while let Some(d) = cursor { + if d.exists() { + break; + } + missing.push(d.to_path_buf()); + cursor = d.parent(); + } + for d in missing.into_iter().rev() { + std::fs::create_dir(&d).with_context(|| format!("creating {}", d.display()))?; + created.push(d.display().to_string()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&d, std::fs::Permissions::from_mode(0o700))?; + } + #[cfg(windows)] + crate::auth::harden_windows_private_directory(&d)?; + } + Ok(()) +} + +fn write_journal(dir: &Path, journal: &Journal) -> Result<()> { + crate::auth::atomic_write_private( + &dir.join("journal.json"), + &serde_json::to_vec_pretty(journal)?, + ) +} + +fn backup_relative(app: &Path, path: &Path) -> PathBuf { + match path.strip_prefix(app) { + Ok(rel) => PathBuf::from("app").join(rel), + Err(_) => PathBuf::from("live").join(path.file_name().unwrap_or_default()), + } +} + +#[cfg(debug_assertions)] +fn injected_failure(write_index: usize) -> Result<()> { + // Test hook (debug builds only): fail before the Nth write. + if let Ok(n) = std::env::var("CODEX_SWITCH_TEST_IMPORT_FAIL_AT") + && n.parse::().ok() == Some(write_index) + { + anyhow::bail!("injected import failure at write {write_index}"); + } + Ok(()) +} +#[cfg(not(debug_assertions))] +fn injected_failure(_write_index: usize) -> Result<()> { + Ok(()) +} + +pub(crate) struct ApplyResult { + pub backup_dir: Option, +} + +struct Writes<'a> { + item: &'a PlanItem, + key: &'a LogicalKey, + text: &'a str, +} + +/// Execute every writing item. The caller holds the auth transaction. +pub(crate) fn apply(plan: &ImportPlan) -> Result { + let writes: Vec = plan + .items + .iter() + .filter(|i| i.action.writes()) + .filter_map(|item| { + item.write.as_ref().map(|(key, text)| Writes { + item, + key, + text: text.as_str(), + }) + }) + .collect(); + if writes.is_empty() { + return Ok(ApplyResult { backup_dir: None }); + } + + let app = crate::auth::app_home()?; + let root = backups_root()?; + let stamp = chrono::Utc::now().format("%Y%m%dT%H%M%S%.9fZ").to_string(); + let backup_dir = root.join(stamp); + let mut scratch = Vec::new(); + ensure_private_dir_tracked(&backup_dir, &mut scratch)?; + + // Targets: each write, plus the live auth.json when a current profile changes. + let live = crate::auth::codex_auth_path()?; + let mut targets: Vec<(PathBuf, &str)> = Vec::new(); + for w in &writes { + targets.push((w.key.path_in(&app), w.text)); + if w.item.updates_live { + targets.push((live.clone(), w.text)); + } + } + + let mut journal = Journal { + state: "in-progress".into(), + replaced: Vec::new(), + created: Vec::new(), + created_dirs: Vec::new(), + }; + for (path, _) in &targets { + if path.exists() { + let rel = backup_relative(&app, path); + let dst = backup_dir.join("backup").join(rel); + let raw = + std::fs::read(path).with_context(|| format!("backing up {}", path.display()))?; + if let Some(parent) = dst.parent() { + ensure_private_dir_tracked(parent, &mut scratch)?; + } + crate::auth::atomic_write_private(&dst, &raw)?; + journal.replaced.push(path.display().to_string()); + } + } + write_journal(&backup_dir, &journal)?; + + let result = (|| -> Result<()> { + for (idx, (path, text)) in targets.iter().enumerate() { + injected_failure(idx + 1)?; + let existed = path.exists(); + if let Some(parent) = path.parent() { + ensure_private_dir_tracked(parent, &mut journal.created_dirs)?; + } + if !existed { + journal.created.push(path.display().to_string()); + write_journal(&backup_dir, &journal)?; + } + crate::auth::atomic_write_private(path, text.as_bytes())?; + } + Ok(()) + })(); + + match result { + Ok(()) => { + journal.state = "committed".into(); + write_journal(&backup_dir, &journal)?; + Ok(ApplyResult { + backup_dir: Some(backup_dir), + }) + } + Err(err) => { + let rollback = rollback(&app, &backup_dir, &mut journal); + match rollback { + Ok(()) => Err(err.context(format!( + "import failed and was rolled back; nothing was changed (journal {})", + backup_dir.join("journal.json").display() + ))), + Err(rb) => Err(err.context(format!( + "import failed AND rollback failed ({rb:#}); restore manually from {}", + backup_dir.display() + ))), + } + } + } +} + +fn rollback(app: &Path, backup_dir: &Path, journal: &mut Journal) -> Result<()> { + for path in journal.created.iter().rev() { + let p = Path::new(path); + if p.exists() { + std::fs::remove_file(p).with_context(|| format!("removing {path}"))?; + } + } + for path in &journal.replaced { + let p = Path::new(path); + let src = backup_dir.join("backup").join(backup_relative(app, p)); + let raw = std::fs::read(&src).with_context(|| format!("reading {}", src.display()))?; + crate::auth::atomic_write_private(p, &raw)?; + } + for dir in journal.created_dirs.iter().rev() { + let _ = std::fs::remove_dir(dir); + } + journal.state = "rolled-back".into(); + write_journal(backup_dir, journal) +} diff --git a/src/transfer/crypto.rs b/src/transfer/crypto.rs new file mode 100644 index 0000000..caf945b --- /dev/null +++ b/src/transfer/crypto.rs @@ -0,0 +1,260 @@ +//! Passphrase encryption for export bundles: Argon2id derives 64 bytes, the +//! first half keys AES-256-GCM and the second half keys an HMAC-SHA256 check +//! value that tells a wrong passphrase apart from a modified ciphertext. + +use anyhow::Result; +use base64::Engine; +use base64::engine::general_purpose::STANDARD as B64; +use ring::aead::{AES_256_GCM, Aad, LessSafeKey, Nonce, UnboundKey}; +use ring::hmac; +use ring::rand::{SecureRandom, SystemRandom}; +use serde::{Deserialize, Serialize}; +use zeroize::Zeroizing; + +use crate::error::CsError; + +pub(crate) const SCHEME: &str = "argon2id-aes256gcm"; +const KEY_CHECK_LABEL: &[u8] = b"codex-switch-export key check v1"; + +pub(crate) const DEFAULT_M_COST_KIB: u32 = 64 * 1024; +pub(crate) const DEFAULT_T_COST: u32 = 3; +pub(crate) const DEFAULT_P_COST: u32 = 1; + +const MIN_M_COST_KIB: u32 = 8 * 1024; +const MAX_M_COST_KIB: u32 = 1024 * 1024; +const SALT_LEN: usize = 16; +const NONCE_LEN: usize = 12; +const KEY_CHECK_LEN: usize = 32; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct KdfParams { + pub m_cost_kib: u32, + pub t_cost: u32, + pub p_cost: u32, + pub salt: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Encryption { + pub scheme: String, + pub kdf: KdfParams, + pub nonce: String, + pub key_check: String, +} + +/// Keys derived from one passphrase + salt. +struct DerivedKeys { + okm: Zeroizing<[u8; 64]>, +} + +impl DerivedKeys { + fn enc_key(&self) -> &[u8] { + &self.okm[..32] + } + fn check_key(&self) -> &[u8] { + &self.okm[32..] + } + fn key_check(&self) -> [u8; KEY_CHECK_LEN] { + let key = hmac::Key::new(hmac::HMAC_SHA256, self.check_key()); + let tag = hmac::sign(&key, KEY_CHECK_LABEL); + let mut out = [0u8; KEY_CHECK_LEN]; + out.copy_from_slice(tag.as_ref()); + out + } +} + +fn derive(passphrase: &str, salt: &[u8], m: u32, t: u32, p: u32) -> Result { + let params = argon2::Params::new(m, t, p, Some(64)) + .map_err(|e| CsError::UnsupportedEncryption(format!("argon2 parameters: {e}")))?; + let argon = argon2::Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params); + let mut okm = Zeroizing::new([0u8; 64]); + argon + .hash_password_into(passphrase.as_bytes(), salt, okm.as_mut()) + .map_err(|e| CsError::UnsupportedEncryption(format!("argon2: {e}")))?; + Ok(DerivedKeys { okm }) +} + +fn random_bytes(rng: &SystemRandom) -> Result<[u8; N]> { + let mut buf = [0u8; N]; + rng.fill(&mut buf) + .map_err(|_| anyhow::anyhow!("system random number generator failed"))?; + Ok(buf) +} + +/// Fresh salt/nonce plus the derived key check. The caller serialises the +/// header (which includes this) as AAD and then calls [`seal`]. +pub(crate) struct Sealer { + keys: DerivedKeys, + nonce: [u8; NONCE_LEN], + pub encryption: Encryption, +} + +pub(crate) fn new_sealer(passphrase: &str) -> Result { + new_sealer_with_params( + passphrase, + DEFAULT_M_COST_KIB, + DEFAULT_T_COST, + DEFAULT_P_COST, + ) +} + +pub(crate) fn new_sealer_with_params(passphrase: &str, m: u32, t: u32, p: u32) -> Result { + let rng = SystemRandom::new(); + let salt: [u8; SALT_LEN] = random_bytes(&rng)?; + let nonce: [u8; NONCE_LEN] = random_bytes(&rng)?; + let keys = derive(passphrase, &salt, m, t, p)?; + let encryption = Encryption { + scheme: SCHEME.to_string(), + kdf: KdfParams { + m_cost_kib: m, + t_cost: t, + p_cost: p, + salt: B64.encode(salt), + }, + nonce: B64.encode(nonce), + key_check: B64.encode(keys.key_check()), + }; + Ok(Sealer { + keys, + nonce, + encryption, + }) +} + +impl Sealer { + pub(crate) fn seal(&self, aad: &[u8], plaintext: &[u8]) -> Result> { + let key = LessSafeKey::new( + UnboundKey::new(&AES_256_GCM, self.keys.enc_key()) + .map_err(|_| anyhow::anyhow!("invalid AES-256-GCM key"))?, + ); + let mut buf = plaintext.to_vec(); + key.seal_in_place_append_tag( + Nonce::assume_unique_for_key(self.nonce), + Aad::from(aad), + &mut buf, + ) + .map_err(|_| anyhow::anyhow!("encryption failed"))?; + Ok(buf) + } +} + +fn decode_fixed(field: &str, value: &str) -> Result<[u8; N]> { + let raw = B64 + .decode(value) + .map_err(|_| CsError::UnsupportedEncryption(format!("{field} is not valid base64")))?; + raw.try_into() + .map_err(|_| CsError::UnsupportedEncryption(format!("{field} must be {N} bytes")).into()) +} + +/// Reject a crafted header before running an expensive KDF on it. +pub(crate) fn check_bounds(enc: &Encryption) -> Result<()> { + if enc.scheme != SCHEME { + return Err(CsError::UnsupportedEncryption(format!("scheme '{}'", enc.scheme)).into()); + } + let k = &enc.kdf; + if !(MIN_M_COST_KIB..=MAX_M_COST_KIB).contains(&k.m_cost_kib) { + return Err(CsError::UnsupportedEncryption(format!( + "m_cost_kib {} outside {MIN_M_COST_KIB}..={MAX_M_COST_KIB}", + k.m_cost_kib + )) + .into()); + } + if !(1..=10).contains(&k.t_cost) { + return Err( + CsError::UnsupportedEncryption(format!("t_cost {} outside 1..=10", k.t_cost)).into(), + ); + } + if !(1..=4).contains(&k.p_cost) { + return Err( + CsError::UnsupportedEncryption(format!("p_cost {} outside 1..=4", k.p_cost)).into(), + ); + } + decode_fixed::("salt", &k.salt)?; + decode_fixed::("nonce", &enc.nonce)?; + decode_fixed::("key_check", &enc.key_check)?; + Ok(()) +} + +/// Derive, verify the key check (wrong passphrase) and then open the AEAD +/// (tampering). Bounds must already have been checked. +pub(crate) fn open( + enc: &Encryption, + passphrase: &str, + aad: &[u8], + ciphertext: &[u8], +) -> Result>> { + check_bounds(enc)?; + let salt = decode_fixed::("salt", &enc.kdf.salt)?; + let nonce = decode_fixed::("nonce", &enc.nonce)?; + let expected = decode_fixed::("key_check", &enc.key_check)?; + let keys = derive( + passphrase, + &salt, + enc.kdf.m_cost_kib, + enc.kdf.t_cost, + enc.kdf.p_cost, + )?; + let check = hmac::Key::new(hmac::HMAC_SHA256, keys.check_key()); + if hmac::verify(&check, KEY_CHECK_LABEL, &expected).is_err() { + return Err(CsError::WrongPassphrase.into()); + } + let key = LessSafeKey::new( + UnboundKey::new(&AES_256_GCM, keys.enc_key()) + .map_err(|_| anyhow::anyhow!("invalid AES-256-GCM key"))?, + ); + let mut buf = Zeroizing::new(ciphertext.to_vec()); + let len = key + .open_in_place( + Nonce::assume_unique_for_key(nonce), + Aad::from(aad), + &mut buf, + ) + .map_err(|_| CsError::BundleTampered)? + .len(); + buf.truncate(len); + Ok(buf) +} + +#[cfg(test)] +mod tests { + use super::*; + + const M: u32 = MIN_M_COST_KIB; + + #[test] + fn round_trip_and_distinct_errors() { + let sealer = new_sealer_with_params("pw", M, 1, 1).unwrap(); + let ct = sealer.seal(b"aad", b"secret payload").unwrap(); + let enc = sealer.encryption.clone(); + assert_eq!( + &open(&enc, "pw", b"aad", &ct).unwrap()[..], + b"secret payload" + ); + + let err = open(&enc, "nope", b"aad", &ct).unwrap_err(); + assert!(matches!(err.downcast_ref(), Some(CsError::WrongPassphrase))); + + let mut bad = ct.clone(); + bad[0] ^= 1; + let err = open(&enc, "pw", b"aad", &bad).unwrap_err(); + assert!(matches!(err.downcast_ref(), Some(CsError::BundleTampered))); + + let err = open(&enc, "pw", b"other aad", &ct).unwrap_err(); + assert!(matches!(err.downcast_ref(), Some(CsError::BundleTampered))); + } + + #[test] + fn bounds_rejected_before_derivation() { + let mut enc = new_sealer_with_params("pw", M, 1, 1).unwrap().encryption; + enc.kdf.m_cost_kib = 4 * 1024 * 1024; + let started = std::time::Instant::now(); + let err = open(&enc, "pw", b"", b"").unwrap_err(); + assert!(matches!( + err.downcast_ref(), + Some(CsError::UnsupportedEncryption(_)) + )); + assert!(started.elapsed() < std::time::Duration::from_secs(1)); + } +} diff --git a/src/transfer/mod.rs b/src/transfer/mod.rs new file mode 100644 index 0000000..dcf9974 --- /dev/null +++ b/src/transfer/mod.rs @@ -0,0 +1,430 @@ +//! Whole-store export bundles (`codex-switch export`) and their import +//! (`codex-switch import `). +//! +//! A bundle is one UTF-8 JSON document. Its payload carries files keyed by a +//! logical path relative to the codex-switch home, never an absolute path, so +//! it can be applied on another OS. Encrypted bundles authenticate the whole +//! header as AES-GCM associated data. + +pub(crate) mod apply; +pub(crate) mod crypto; +pub(crate) mod plan; + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use base64::Engine; +use base64::engine::general_purpose::STANDARD as B64; +use serde::{Deserialize, Serialize}; +use sha2::Digest; + +use crate::error::CsError; + +pub(crate) const FORMAT: &str = "codex-switch-export"; +pub(crate) const SUPPORTED_SCHEMA: u32 = 1; +pub(crate) const EXTENSION: &str = "csbundle"; +/// Bundles are a few KB per account; anything this large is not one. +const MAX_BUNDLE_BYTES: u64 = 64 * 1024 * 1024; + +pub(crate) const SINGLE_USE_WARNING: &str = "Exported credentials stay valid on the source machine too. OpenAI refresh tokens are single-use: if both machines keep using the same account, one of them will fail with `refresh_token_reused` and need `codex-switch login `. Use each account on one machine only."; +pub(crate) const PLAINTEXT_WARNING: &str = "This bundle is NOT encrypted. It contains live refresh tokens and API keys: anyone who has the file can use these accounts. Delete it as soon as the import is done."; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Platform { + pub os: String, + pub arch: String, +} + +/// The on-disk document. Field order is fixed: the AAD is serialised from it. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Bundle { + pub format: String, + pub schema_version: u32, + pub app_version: String, + pub platform: Platform, + pub created_at: String, + pub encryption: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub payload_sha256: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ciphertext: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub payload: Option, +} + +#[derive(Serialize)] +struct HeaderAad<'a> { + format: &'a str, + schema_version: u32, + app_version: &'a str, + platform: &'a Platform, + created_at: &'a str, + encryption: &'a Option, +} + +impl Bundle { + fn aad(&self) -> Result> { + Ok(serde_json::to_vec(&HeaderAad { + format: &self.format, + schema_version: self.schema_version, + app_version: &self.app_version, + platform: &self.platform, + created_at: &self.created_at, + encryption: &self.encryption, + })?) + } + + pub(crate) fn is_encrypted(&self) -> bool { + self.encryption.is_some() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum EntryKind { + ProfileAuth, + Provider, + ProviderModels, + AppConfig, +} + +impl EntryKind { + pub(crate) fn as_str(self) -> &'static str { + match self { + EntryKind::ProfileAuth => "profile_auth", + EntryKind::Provider => "provider", + EntryKind::ProviderModels => "provider_models", + EntryKind::AppConfig => "app_config", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Entry { + pub key: String, + pub kind: EntryKind, + pub text: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Payload { + pub source_current: Option, + pub entries: Vec, +} + +/// A validated logical key. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum LogicalKey { + ProfileAuth(String), + Provider(String), + ProviderModels(String), + AppConfig, +} + +impl LogicalKey { + pub(crate) fn kind(&self) -> EntryKind { + match self { + LogicalKey::ProfileAuth(_) => EntryKind::ProfileAuth, + LogicalKey::Provider(_) => EntryKind::Provider, + LogicalKey::ProviderModels(_) => EntryKind::ProviderModels, + LogicalKey::AppConfig => EntryKind::AppConfig, + } + } + + pub(crate) fn render(&self) -> String { + match self { + LogicalKey::ProfileAuth(a) => format!("profiles/{a}/auth.json"), + LogicalKey::Provider(a) => format!("providers/{a}/provider.toml"), + LogicalKey::ProviderModels(a) => format!("providers/{a}/models.json"), + LogicalKey::AppConfig => "config.toml".to_string(), + } + } + + /// Path under `app_home`, rebuilt component by component. + pub(crate) fn path_in(&self, app_home: &Path) -> PathBuf { + match self { + LogicalKey::ProfileAuth(a) => app_home.join("profiles").join(a).join("auth.json"), + LogicalKey::Provider(a) => app_home.join("providers").join(a).join("provider.toml"), + LogicalKey::ProviderModels(a) => app_home.join("providers").join(a).join("models.json"), + LogicalKey::AppConfig => app_home.join("config.toml"), + } + } +} + +/// Parse a logical key. Only the four documented shapes are accepted; anything +/// else (`..`, backslashes, drive letters, absolute paths, other file names) +/// is a hard error before anything is written. +pub(crate) fn parse_key(key: &str) -> Result { + let bad = || CsError::InvalidBundle(format!("unsupported entry key '{key}'")); + if key == "config.toml" { + return Ok(LogicalKey::AppConfig); + } + if key.contains('\\') || key.contains(':') || key.starts_with('/') { + return Err(bad().into()); + } + let parts: Vec<&str> = key.split('/').collect(); + let [root, alias, file] = parts.as_slice() else { + return Err(bad().into()); + }; + if crate::profile::validate_alias(alias).is_err() || alias.starts_with('.') { + return Err(bad().into()); + } + let alias = alias.to_string(); + match (*root, *file) { + ("profiles", "auth.json") => Ok(LogicalKey::ProfileAuth(alias)), + ("providers", "provider.toml") => Ok(LogicalKey::Provider(alias)), + ("providers", "models.json") => Ok(LogicalKey::ProviderModels(alias)), + _ => Err(bad().into()), + } +} + +fn payload_digest(payload: &Payload) -> Result { + let bytes = serde_json::to_vec(payload)?; + Ok(hex::encode(sha2::Sha256::digest(&bytes))) +} + +fn now_rfc3339() -> String { + chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true) +} + +fn header(encryption: Option) -> Bundle { + Bundle { + format: FORMAT.to_string(), + schema_version: SUPPORTED_SCHEMA, + app_version: env!("CARGO_PKG_VERSION").to_string(), + platform: Platform { + os: std::env::consts::OS.to_string(), + arch: std::env::consts::ARCH.to_string(), + }, + created_at: now_rfc3339(), + encryption, + payload_sha256: None, + ciphertext: None, + payload: None, + } +} + +/// Serialise a payload into bundle bytes. `passphrase = None` writes plaintext. +pub(crate) fn encode_bundle(payload: &Payload, passphrase: Option<&str>) -> Result> { + let bundle = match passphrase { + Some(passphrase) => { + let sealer = crypto::new_sealer(passphrase)?; + let mut bundle = header(Some(sealer.encryption.clone())); + let plain = zeroize::Zeroizing::new(serde_json::to_vec(payload)?); + let ct = sealer.seal(&bundle.aad()?, &plain)?; + bundle.ciphertext = Some(B64.encode(ct)); + bundle + } + None => { + let mut bundle = header(None); + bundle.payload_sha256 = Some(payload_digest(payload)?); + bundle.payload = Some(payload.clone()); + bundle + } + }; + let mut out = serde_json::to_vec_pretty(&bundle)?; + out.push(b'\n'); + Ok(out) +} + +fn strip_bom(raw: &[u8]) -> &[u8] { + raw.strip_prefix(b"\xEF\xBB\xBF".as_slice()).unwrap_or(raw) +} + +/// Cheap content sniff used by `import` to choose the bundle path. Never +/// errors: anything unreadable is "not a bundle" and goes to the legacy path. +pub fn looks_like_bundle(path: &Path) -> bool { + let Ok(meta) = std::fs::metadata(path) else { + return false; + }; + if !meta.is_file() || meta.len() > MAX_BUNDLE_BYTES { + return false; + } + let Ok(raw) = std::fs::read(path) else { + return false; + }; + serde_json::from_slice::(strip_bom(&raw)) + .ok() + .and_then(|v| { + v.get("format") + .and_then(|f| f.as_str()) + .map(|f| f == FORMAT) + }) + .unwrap_or(false) +} + +/// Read and structurally validate a bundle file. Version problems are +/// reported here, before any passphrase is requested. +pub(crate) fn read_bundle(path: &Path) -> Result { + let meta = std::fs::metadata(path).with_context(|| format!("reading {}", path.display()))?; + if meta.len() > MAX_BUNDLE_BYTES { + return Err(CsError::NotABundle(format!("{} is too large", path.display())).into()); + } + let raw = std::fs::read(path).with_context(|| format!("reading {}", path.display()))?; + let raw = strip_bom(&raw); + let probe: serde_json::Value = serde_json::from_slice(raw) + .map_err(|_| CsError::NotABundle(format!("{} is not JSON", path.display())))?; + if probe.get("format").and_then(|f| f.as_str()) != Some(FORMAT) { + return Err(CsError::NotABundle(format!( + "{} has no \"format\": \"{FORMAT}\"", + path.display() + )) + .into()); + } + let schema = probe + .get("schema_version") + .and_then(|v| v.as_u64()) + .ok_or_else(|| CsError::InvalidBundle("missing schema_version".into()))?; + if schema > u64::from(SUPPORTED_SCHEMA) || schema == 0 { + return Err(CsError::UnsupportedBundleVersion { + app_version: probe + .get("app_version") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(), + schema, + supported: SUPPORTED_SCHEMA, + } + .into()); + } + let bundle: Bundle = serde_json::from_slice(raw) + .map_err(|e| CsError::InvalidBundle(format!("malformed bundle header: {e}")))?; + match (&bundle.encryption, &bundle.ciphertext, &bundle.payload) { + (Some(enc), Some(_), None) => crypto::check_bounds(enc)?, + (None, None, Some(_)) if bundle.payload_sha256.is_some() => {} + _ => { + return Err(CsError::InvalidBundle( + "bundle must carry either encryption+ciphertext or payload+payload_sha256".into(), + ) + .into()); + } + } + Ok(bundle) +} + +/// Decrypt (or checksum-verify) the payload and migrate it to the current +/// schema. +pub(crate) fn open_payload(bundle: &Bundle, passphrase: Option<&str>) -> Result { + let payload = match (&bundle.encryption, &bundle.ciphertext, &bundle.payload) { + (Some(enc), Some(ct), None) => { + let passphrase = passphrase.ok_or_else(|| anyhow::anyhow!("passphrase required"))?; + let ct = B64.decode(ct).map_err(|_| CsError::BundleTampered)?; + let plain = crypto::open(enc, passphrase, &bundle.aad()?, &ct)?; + let value: serde_json::Value = serde_json::from_slice(&plain) + .map_err(|e| CsError::InvalidBundle(format!("payload is not JSON: {e}")))?; + migrate(value, bundle.schema_version)? + } + (None, None, Some(payload)) => { + if Some(payload_digest(payload)?) != bundle.payload_sha256 { + return Err(CsError::BundleTampered.into()); + } + payload.clone() + } + _ => return Err(CsError::InvalidBundle("inconsistent bundle".into()).into()), + }; + Ok(payload) +} + +/// One step per schema version. Schema 1 is the first, so this is identity. +fn migrate(value: serde_json::Value, from: u32) -> Result { + match from { + 1 => serde_json::from_value(value) + .map_err(|e| CsError::InvalidBundle(format!("malformed payload: {e}")).into()), + other => Err(CsError::UnsupportedBundleVersion { + app_version: "unknown".into(), + schema: u64::from(other), + supported: SUPPORTED_SCHEMA, + } + .into()), + } +} + +/// Write a user-chosen destination with 0600 / a file-only DACL, without +/// touching the parent directory's permissions (unlike `atomic_write_private`). +pub(crate) fn write_private_file(path: &Path, contents: &[u8], overwrite: bool) -> Result<()> { + use std::io::Write; + let parent = match path.parent() { + Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(), + _ => PathBuf::from("."), + }; + if !overwrite && path.exists() { + anyhow::bail!( + "{} already exists; choose another path or pass --force", + path.display() + ); + } + let mut tmp = tempfile::NamedTempFile::new_in(&parent) + .with_context(|| format!("creating temporary file in {}", parent.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(tmp.path(), std::fs::Permissions::from_mode(0o600))?; + } + #[cfg(windows)] + crate::auth::harden_windows_private_file(tmp.path())?; + tmp.write_all(contents)?; + tmp.as_file().sync_all()?; + if overwrite { + tmp.persist(path).map_err(|e| e.error) + } else { + tmp.persist_noclobber(path).map_err(|e| e.error) + } + .with_context(|| format!("writing {}", path.display()))?; + Ok(()) +} + +pub(crate) fn default_export_name() -> String { + format!( + "codex-switch-{}.{EXTENSION}", + chrono::Local::now().format("%Y%m%d-%H%M%S") + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn key_validation() { + assert_eq!( + parse_key("profiles/work/auth.json").unwrap(), + LogicalKey::ProfileAuth("work".into()) + ); + assert_eq!(parse_key("config.toml").unwrap(), LogicalKey::AppConfig); + for bad in [ + "../x", + "/etc/x", + "profiles/a/../../x", + "C:\\x", + "profiles/a/other.json", + "profiles/../auth.json", + "profiles/a\\b/auth.json", + "providers/a/auth.json", + "profiles/a/b/auth.json", + "", + ] { + assert!(parse_key(bad).is_err(), "{bad} accepted"); + } + } + + #[test] + fn plaintext_round_trip_detects_corruption() { + let payload = Payload { + source_current: Some("a".into()), + entries: vec![Entry { + key: "config.toml".into(), + kind: EntryKind::AppConfig, + text: "[cache]\nttl = 1\n".into(), + }], + }; + let bytes = encode_bundle(&payload, None).unwrap(); + let mut bundle: Bundle = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(open_payload(&bundle, None).unwrap(), payload); + bundle.payload.as_mut().unwrap().entries[0].text.push('x'); + assert!(open_payload(&bundle, None).is_err()); + } +} diff --git a/src/transfer/plan.rs b/src/transfer/plan.rs new file mode 100644 index 0000000..a6f5685 --- /dev/null +++ b/src/transfer/plan.rs @@ -0,0 +1,710 @@ +//! Export collection and import classification. Classification is pure with +//! respect to disk writes: it reads the local store and returns a plan that +//! `--dry-run` prints and `apply` executes. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; + +use anyhow::{Context, Result}; +use serde::Serialize; + +use super::{Entry, EntryKind, LogicalKey, Payload, parse_key}; +use crate::error::CsError; +use crate::profile; + +// ── export ──────────────────────────────────────────────── + +#[derive(Debug, Clone, Copy, Default)] +pub(crate) struct ExportOptions { + pub no_providers: bool, + pub no_config: bool, +} + +#[derive(Debug, Default, Serialize)] +pub(crate) struct ExportSummary { + pub profiles: Vec, + pub providers: Vec, + pub config: bool, +} + +fn read_utf8(path: &Path) -> Result { + let raw = std::fs::read(path).with_context(|| format!("reading {}", path.display()))?; + String::from_utf8(raw).map_err(|_| { + anyhow::anyhow!( + "{} is not valid UTF-8; fix or remove it before exporting", + path.display() + ) + }) +} + +pub(crate) fn collect_export(opts: ExportOptions) -> Result<(Payload, ExportSummary)> { + let app = crate::auth::app_home()?; + let mut entries = Vec::new(); + let mut summary = ExportSummary::default(); + + for alias in profile::list_profiles()? { + if profile::validate_alias(&alias).is_err() { + continue; + } + let key = LogicalKey::ProfileAuth(alias.clone()); + let path = key.path_in(&app); + if !path.is_file() { + continue; + } + let text = read_utf8(&path)?; + serde_json::from_str::(text.trim_start_matches('\u{feff}')) + .with_context(|| format!("parsing {}", path.display()))?; + entries.push(Entry { + key: key.render(), + kind: EntryKind::ProfileAuth, + text, + }); + summary.profiles.push(alias); + } + + if !opts.no_providers { + for alias in crate::provider::list_providers()? { + if profile::validate_alias(&alias).is_err() { + continue; + } + let key = LogicalKey::Provider(alias.clone()); + let text = read_utf8(&key.path_in(&app))?; + entries.push(Entry { + key: key.render(), + kind: EntryKind::Provider, + text, + }); + let models = LogicalKey::ProviderModels(alias.clone()); + let models_path = models.path_in(&app); + if models_path.is_file() { + entries.push(Entry { + key: models.render(), + kind: EntryKind::ProviderModels, + text: read_utf8(&models_path)?, + }); + } + summary.providers.push(alias); + } + } + + if !opts.no_config { + let path = LogicalKey::AppConfig.path_in(&app); + if path.is_file() { + entries.push(Entry { + key: LogicalKey::AppConfig.render(), + kind: EntryKind::AppConfig, + text: read_utf8(&path)?, + }); + summary.config = true; + } + } + + if entries.is_empty() { + anyhow::bail!("nothing to export: no saved profiles, providers or config.toml"); + } + let current = profile::read_current(); + let source_current = + (!current.is_empty() && summary.profiles.contains(¤t)).then_some(current); + Ok(( + Payload { + source_current, + entries, + }, + summary, + )) +} + +// ── import classification ───────────────────────────────── + +#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum, Serialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum Strategy { + /// Keep the local copy on conflict (default) + #[default] + Skip, + /// Replace the local copy with the imported one (a backup is kept) + Overwrite, + /// Profiles: keep the newer credential; config.toml: add missing keys only + Merge, +} + +impl Strategy { + pub(crate) fn as_str(self) -> &'static str { + match self { + Strategy::Skip => "skip", + Strategy::Overwrite => "overwrite", + Strategy::Merge => "merge", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum Action { + /// New item, written under its own alias. + Create, + /// New item whose alias is taken locally; written under `target`. + Rename, + /// Local content is already identical. + Unchanged, + /// Conflict; local kept. + Skip, + /// Conflict; imported content replaces local. + Overwrite, + /// Conflict; merged content replaces local. + Merge, + /// Identity matches several local profiles or only by email; never touched. + Ambiguous, +} + +impl Action { + pub(crate) fn writes(self) -> bool { + matches!( + self, + Action::Create | Action::Rename | Action::Overwrite | Action::Merge + ) + } + pub(crate) fn as_str(self) -> &'static str { + match self { + Action::Create => "create", + Action::Rename => "rename", + Action::Unchanged => "unchanged", + Action::Skip => "skip", + Action::Overwrite => "overwrite", + Action::Merge => "merge", + Action::Ambiguous => "ambiguous", + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct PlanItem { + pub key: String, + pub kind: &'static str, + pub action: Action, + /// Local alias that will receive the item (profiles/providers). + #[serde(skip_serializing_if = "Option::is_none")] + pub target: Option, + pub reason: String, + /// Content to write when `action.writes()`. Never serialised. + #[serde(skip)] + pub write: Option<(LogicalKey, String)>, + /// Whether this write replaces the live Codex auth.json too. + #[serde(skip)] + pub updates_live: bool, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct ImportPlan { + pub items: Vec, + pub source_current: Option, + /// `source_current` after any rename on this machine. + pub source_current_target: Option, + pub notices: Vec, +} + +impl ImportPlan { + pub(crate) fn needs_confirmation(&self) -> bool { + self.items + .iter() + .any(|i| matches!(i.action, Action::Overwrite | Action::Merge)) + } + pub(crate) fn has_writes(&self) -> bool { + self.items.iter().any(|i| i.action.writes()) + } + /// A comparable fingerprint of the decisions, to detect that the store + /// changed between the confirmation and taking the lock. + pub(crate) fn decisions(&self) -> Vec<(String, Action, Option)> { + self.items + .iter() + .map(|i| (i.key.clone(), i.action, i.target.clone())) + .collect() + } +} + +/// Lowercased aliases already used locally by profiles and providers. macOS and +/// Windows file systems are case-insensitive, so `Work` and `work` collide. +fn local_taken() -> Result> { + let mut taken = BTreeSet::new(); + for alias in profile::list_profiles()? { + taken.insert(alias.to_ascii_lowercase()); + } + for alias in crate::provider::list_providers()? { + taken.insert(alias.to_ascii_lowercase()); + } + Ok(taken) +} + +const MAX_ALIAS_LEN: usize = 64; + +fn unique_alias(base: &str, taken: &BTreeSet) -> Result { + for n in 2..1000u32 { + let suffix = format!("_{n}"); + let prefix: String = base + .chars() + .take(MAX_ALIAS_LEN.saturating_sub(suffix.len())) + .collect(); + let candidate = format!("{prefix}{suffix}"); + if !taken.contains(&candidate.to_ascii_lowercase()) { + return Ok(candidate); + } + } + anyhow::bail!("could not generate a unique alias for '{base}'") +} + +#[cfg(windows)] +fn windows_reserved(alias: &str) -> bool { + let stem = alias + .split('.') + .next() + .unwrap_or(alias) + .to_ascii_uppercase(); + matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL") + || ((stem.starts_with("COM") || stem.starts_with("LPT")) + && stem.len() == 4 + && stem.as_bytes()[3].is_ascii_digit()) +} +#[cfg(not(windows))] +fn windows_reserved(_alias: &str) -> bool { + false +} + +struct LocalProfile { + alias: String, + value: serde_json::Value, + identity: profile::AccountIdentity, +} + +fn local_profiles() -> Result> { + let mut out = Vec::new(); + for alias in profile::list_profiles()? { + let Ok(path) = profile::profile_auth_path(&alias) else { + continue; + }; + let Ok(value) = crate::auth::read_auth(&path) else { + continue; + }; + let identity = profile::extract_identity(&value); + out.push(LocalProfile { + alias, + value, + identity, + }); + } + Ok(out) +} + +fn parse_auth_text(key: &str, text: &str) -> Result { + serde_json::from_str(text.trim_start_matches('\u{feff}')) + .map_err(|e| CsError::InvalidBundle(format!("{key}: invalid auth JSON: {e}")).into()) +} + +fn item( + key: &LogicalKey, + action: Action, + target: Option, + reason: impl Into, +) -> PlanItem { + PlanItem { + key: key.render(), + kind: key.kind().as_str(), + action, + target, + reason: reason.into(), + write: None, + updates_live: false, + } +} + +/// Validate the whole payload and decide what happens to each entry. Any +/// validation failure aborts the import before anything is written. +pub(crate) fn classify(payload: &Payload, strategy: Strategy) -> Result { + let app = crate::auth::app_home()?; + let mut taken = local_taken()?; + let locals = local_profiles()?; + let local_current = profile::read_current(); + let mut notices = Vec::new(); + + // Parse and validate every key first, and reject duplicates. + let mut seen = BTreeSet::new(); + let mut keyed: Vec<(LogicalKey, &Entry)> = Vec::new(); + for entry in &payload.entries { + let key = parse_key(&entry.key)?; + if key.kind() != entry.kind { + return Err(CsError::InvalidBundle(format!( + "entry '{}' has kind '{}' but its key is a {}", + entry.key, + entry.kind.as_str(), + key.kind().as_str() + )) + .into()); + } + if !seen.insert(entry.key.to_ascii_lowercase()) { + return Err(CsError::InvalidBundle(format!("duplicate entry '{}'", entry.key)).into()); + } + keyed.push((key, entry)); + } + let bundle_providers: BTreeSet = keyed + .iter() + .filter_map(|(k, _)| match k { + LogicalKey::Provider(a) => Some(a.clone()), + _ => None, + }) + .collect(); + for (key, _) in &keyed { + if let LogicalKey::ProviderModels(a) = key + && !bundle_providers.contains(a) + { + return Err(CsError::InvalidBundle(format!( + "{} has no matching provider.toml", + key.render() + )) + .into()); + } + } + + let mut items = Vec::new(); + let mut renames: BTreeMap = BTreeMap::new(); + let mut provider_targets: BTreeMap = BTreeMap::new(); + + // Profiles. + for (key, entry) in &keyed { + let LogicalKey::ProfileAuth(alias) = key else { + continue; + }; + let value = parse_auth_text(&entry.key, &entry.text)?; + let identity = profile::extract_identity(&value); + if identity.account_id.as_deref().is_none_or(str::is_empty) { + return Err(CsError::InvalidBundle(format!( + "{}: imported auth must contain a non-empty account_id", + entry.key + )) + .into()); + } + crate::auth::validate_managed_auth_value(&value) + .with_context(|| format!("{} is not allowed by the managed policy", entry.key))?; + + let exact = locals.iter().find(|l| { + l.identity.account_id == identity.account_id + && l.identity.email.is_some() + && l.identity.email == identity.email + }); + let email_only: Vec<&LocalProfile> = locals + .iter() + .filter(|l| { + l.identity.account_id.is_none() + && l.identity.email.is_some() + && l.identity.email == identity.email + }) + .collect(); + + if let Some(local) = exact { + if local.value == value { + items.push(item( + key, + Action::Unchanged, + Some(local.alias.clone()), + "same account, identical credentials", + )); + renames.insert(alias.clone(), local.alias.clone()); + continue; + } + let imported_ts = profile::parse_last_refresh(&value); + let local_ts = profile::parse_last_refresh(&local.value); + let (action, reason) = match strategy { + Strategy::Skip => ( + Action::Skip, + "same account with different credentials; local kept (use --strategy)" + .to_string(), + ), + Strategy::Overwrite => { + let mut reason = "same account; imported credentials replace local".to_string(); + if matches!((imported_ts, local_ts), (Some(i), Some(l)) if i < l) { + reason.push_str( + " (warning: imported last_refresh is older and may hold an already-rotated refresh token)", + ); + } + (Action::Overwrite, reason) + } + Strategy::Merge => match (imported_ts, local_ts) { + (Some(i), Some(l)) if i > l => ( + Action::Overwrite, + "same account; imported credentials are newer (last_refresh)".to_string(), + ), + _ => ( + Action::Skip, + "same account; local credentials are newer or not comparable".to_string(), + ), + }, + }; + let mut it = item(key, action, Some(local.alias.clone()), reason); + if action.writes() { + it.write = Some(( + LogicalKey::ProfileAuth(local.alias.clone()), + entry.text.clone(), + )); + it.updates_live = local_current == local.alias; + } + renames.insert(alias.clone(), local.alias.clone()); + items.push(it); + continue; + } + if !email_only.is_empty() { + items.push(item( + key, + Action::Ambiguous, + None, + format!( + "email matches local profile(s) {} without a provable account_id; skipped", + email_only + .iter() + .map(|l| l.alias.as_str()) + .collect::>() + .join(", ") + ), + )); + continue; + } + let lower = alias.to_ascii_lowercase(); + let (action, target, reason) = if taken.contains(&lower) || windows_reserved(alias) { + let target = unique_alias(alias, &taken)?; + ( + Action::Rename, + target, + format!("alias '{alias}' is taken locally by a different account or provider"), + ) + } else { + (Action::Create, alias.clone(), "new account".to_string()) + }; + taken.insert(target.to_ascii_lowercase()); + renames.insert(alias.clone(), target.clone()); + let mut it = item(key, action, Some(target.clone()), reason); + it.write = Some((LogicalKey::ProfileAuth(target), entry.text.clone())); + items.push(it); + } + + // Providers. + for (key, entry) in &keyed { + let LogicalKey::Provider(alias) = key else { + continue; + }; + let imported = crate::provider::parse_and_validate(alias, &entry.text)?; + let local_path = key.path_in(&app); + // Parse rather than `provider::load`, which may migrate files on read. + let local = std::fs::read_to_string(&local_path) + .ok() + .and_then(|text| crate::provider::parse_and_validate(alias, &text).ok()); + match local { + Some(local) if local.identity_id == imported.identity_id => { + let local_text = std::fs::read_to_string(&local_path).unwrap_or_default(); + let (action, reason) = if local_text == entry.text { + (Action::Unchanged, "identical provider".to_string()) + } else { + match strategy { + Strategy::Skip => ( + Action::Skip, + "same provider with different settings; local kept".to_string(), + ), + Strategy::Overwrite => ( + Action::Overwrite, + "same provider; imported settings replace local".to_string(), + ), + Strategy::Merge => ( + Action::Skip, + "provider merge is not supported yet; local kept (use overwrite to replace)" + .to_string(), + ), + } + }; + let mut it = item(key, action, Some(alias.clone()), reason); + if action.writes() { + it.write = Some((key.clone(), entry.text.clone())); + } + provider_targets.insert(alias.clone(), (action, alias.clone())); + items.push(it); + } + _ => { + let lower = alias.to_ascii_lowercase(); + let (action, target, text, reason) = + if taken.contains(&lower) || windows_reserved(alias) { + let target = unique_alias(alias, &taken)?; + let text = + crate::provider::retarget_provider_text(alias, &target, &entry.text)?; + ( + Action::Rename, + target, + text, + format!("alias '{alias}' is taken locally by something else"), + ) + } else { + ( + Action::Create, + alias.clone(), + entry.text.clone(), + "new provider".to_string(), + ) + }; + taken.insert(target.to_ascii_lowercase()); + let mut it = item(key, action, Some(target.clone()), reason); + it.write = Some((LogicalKey::Provider(target.clone()), text)); + provider_targets.insert(alias.clone(), (action, target)); + items.push(it); + } + } + } + + // Provider model catalogs follow their provider. + for (key, entry) in &keyed { + let LogicalKey::ProviderModels(alias) = key else { + continue; + }; + serde_json::from_str::(&entry.text) + .map_err(|e| CsError::InvalidBundle(format!("{}: invalid JSON: {e}", entry.key)))?; + let (provider_action, target) = provider_targets + .get(alias) + .cloned() + .unwrap_or((Action::Skip, alias.clone())); + let target_key = LogicalKey::ProviderModels(target.clone()); + let local_text = std::fs::read_to_string(target_key.path_in(&app)).ok(); + let (action, reason) = if provider_action.writes() { + if local_text.as_deref() == Some(entry.text.as_str()) { + (Action::Unchanged, "identical model catalog".to_string()) + } else { + (provider_action, "follows its provider".to_string()) + } + } else if local_text.as_deref() == Some(entry.text.as_str()) { + (Action::Unchanged, "identical model catalog".to_string()) + } else { + ( + Action::Skip, + "provider not written; catalog kept".to_string(), + ) + }; + let mut it = item(key, action, Some(target), reason); + if action.writes() { + it.write = Some((target_key, entry.text.clone())); + } + items.push(it); + } + + // App config. + for (key, entry) in &keyed { + if *key != LogicalKey::AppConfig { + continue; + } + crate::config::load_from_str_with_warnings(&entry.text) + .map_err(|e| CsError::InvalidBundle(format!("config.toml: {e}")))?; + let imported: toml::Table = toml::from_str(&entry.text) + .map_err(|e| CsError::InvalidBundle(format!("config.toml: {e}")))?; + if imported + .get("proxy") + .and_then(|p| p.as_table()) + .is_some_and(|p| !p.is_empty()) + { + notices + .push("config.toml [proxy]: machine-specific, review it after import".to_string()); + } + let local_path = key.path_in(&app); + let local_text = std::fs::read_to_string(&local_path).ok(); + let (action, text, reason) = match local_text { + None => ( + Action::Create, + entry.text.clone(), + "no local config.toml".to_string(), + ), + Some(local) if local == entry.text => { + (Action::Unchanged, String::new(), "identical".to_string()) + } + Some(local) => match strategy { + Strategy::Skip => ( + Action::Skip, + String::new(), + "local config.toml differs; kept".to_string(), + ), + Strategy::Overwrite => ( + Action::Overwrite, + entry.text.clone(), + "imported config.toml replaces local".to_string(), + ), + Strategy::Merge => { + let mut local_table: toml::Table = toml::from_str(&local) + .with_context(|| format!("parsing {}", local_path.display()))?; + let added = merge_missing(&mut local_table, &imported); + if added == 0 { + ( + Action::Unchanged, + String::new(), + "local config.toml already has every imported key".to_string(), + ) + } else { + let merged = toml::to_string_pretty(&local_table) + .context("serializing merged config.toml")?; + crate::config::load_from_str_with_warnings(&merged) + .map_err(|e| anyhow::anyhow!("merged config.toml is invalid: {e}"))?; + ( + Action::Merge, + merged, + format!("added {added} missing key(s); local values kept"), + ) + } + } + }, + }; + let mut it = item(key, action, None, reason); + if action.writes() { + it.write = Some((LogicalKey::AppConfig, text)); + } + items.push(it); + } + + let source_current_target = payload + .source_current + .as_ref() + .and_then(|a| renames.get(a).cloned()); + Ok(ImportPlan { + items, + source_current: payload.source_current.clone(), + source_current_target, + notices, + }) +} + +/// Add keys from `incoming` that `local` lacks, recursively. Returns how many +/// leaf keys were added. Local values always win. +fn merge_missing(local: &mut toml::Table, incoming: &toml::Table) -> usize { + let mut added = 0; + for (k, v) in incoming { + match (local.get_mut(k), v) { + (None, v) => { + local.insert(k.clone(), v.clone()); + added += 1; + } + (Some(toml::Value::Table(l)), toml::Value::Table(i)) => { + added += merge_missing(l, i); + } + _ => {} + } + } + added +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn merge_keeps_local_values() { + let mut local: toml::Table = toml::from_str("[cache]\nttl = 10\n").unwrap(); + let incoming: toml::Table = + toml::from_str("[cache]\nttl = 99\n[network]\nmax_concurrent = 4\n").unwrap(); + assert_eq!(merge_missing(&mut local, &incoming), 1); + assert_eq!(local["cache"]["ttl"].as_integer(), Some(10)); + assert_eq!(local["network"]["max_concurrent"].as_integer(), Some(4)); + } + + #[test] + fn unique_alias_is_case_insensitive() { + let taken: BTreeSet = ["work".into(), "work_2".into()].into_iter().collect(); + assert_eq!(unique_alias("Work", &taken).unwrap(), "Work_3"); + } +} diff --git a/tests/fixtures/export-v1.csbundle b/tests/fixtures/export-v1.csbundle new file mode 100644 index 0000000..31c3e2e --- /dev/null +++ b/tests/fixtures/export-v1.csbundle @@ -0,0 +1,22 @@ +{ + "format": "codex-switch-export", + "schema_version": 1, + "app_version": "20261015.2.0", + "platform": { + "os": "linux", + "arch": "x86_64" + }, + "created_at": "2026-10-19T07:59:53Z", + "encryption": { + "scheme": "argon2id-aes256gcm", + "kdf": { + "m_cost_kib": 65536, + "t_cost": 3, + "p_cost": 1, + "salt": "xepkL/82KDRgEuE8nd/yMA==" + }, + "nonce": "uknim6BgKobpYEZh", + "key_check": "Jxn4AAPQgEbDrR5cqL132eFFMengCZX93Ypu0cTUkPo=" + }, + "ciphertext": "HzZTulyvX8IgH2mBnZ1EFXlAYl2IdO8guuHKNYHCNdLGFa6+z5t1gCZCg4MRAcEqkCfW5ZlJI++eAofJpQtiB0kn7aHhqp7rzW3GiY1QqEEpgyuImpzamPFCQ9fMxrCnamkEehS8Ltq40S2wdrZ9xmUSUJeg70Rd6Rng4pEINP8/djgizts9VhF1w1okCGVxcTyQOZwWi2qYTEgV3F9v5oMTpDFVJuVfy/HaYx34byIj128ysiYvb7T2s0IbQrVRDj7qsM4gEKXnu27LbTdYSTV2U9xJ5645HmRnV5dLJzmLCXHA3dVRvzQUPxZZSNi5JLgeYJJSnkAJ5q132slQMh7hL9COE50M4T14zHd1lDVyjg/Lvs2K8fPKX1WgrhdFXB/iN7Udm53aeyMyFuyKVc9HCDibUKmT1g0k63IQEQPYofV7g0bgTk8svyt9+DkmXb1E3xrQN4P5QKJFyBl07BvL4BLmrWrCn3IjwQ8wztPgWZBg5LHJhjr8PIYMgfAj05KKfkDNg6eL/fTcPXzMX7SyLNzx4udUYO6HFku5cVLHcFTjPPmUtgHFSCdM2WVjkNDu5S88ekRLbxJHq6bp+A3m8IhPbARPVKI3Q3XM5Mf8cFk3AMEqDGLb4hYu27QyubGFfgfOOSN9VvmSajHtUMt4ta6xuVnsz2OEp9gPCocXav0bSgsUBs950OBgaIJ3MJOO/nHvtLgarp6cd6TP+lpyU0A0IkL9rISjPfLk3Zo=" +} diff --git a/tests/test_transfer.rs b/tests/test_transfer.rs new file mode 100644 index 0000000..dee6c8d --- /dev/null +++ b/tests/test_transfer.rs @@ -0,0 +1,917 @@ +//! End-to-end tests for `codex-switch export` / `import `. +//! +//! Every test spawns the real binary against isolated HOME / CODEX_HOME / +//! CODEX_SWITCH_HOME directories ("machines") and uses fake JWTs only. + +use std::collections::BTreeMap; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use base64::Engine; +use serde_json::{Value, json}; + +static NEXT_ID: AtomicU64 = AtomicU64::new(0); +const PASS: &str = "correct horse battery staple"; + +fn temp_home(name: &str) -> PathBuf { + let ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let id = NEXT_ID.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!("codex-switch-transfer-{name}-{ts}-{id}")); + fs::create_dir_all(&path).unwrap(); + path +} + +fn jwt(payload: &Value) -> String { + let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(serde_json::to_vec(payload).unwrap()); + format!("x.{encoded}.y") +} + +fn auth(email: &str, account_id: &str, tag: &str, last_refresh: &str) -> Value { + let claims = json!({ + "email": email, + "https://api.openai.com/auth": { + "chatgpt_plan_type": "plus", + "chatgpt_account_id": account_id, + "chatgpt_user_id": format!("user_{account_id}"), + } + }); + json!({ + "tokens": { + "id_token": jwt(&claims), + "access_token": format!("access-{tag}"), + "refresh_token": format!("refresh-{tag}"), + "account_id": account_id, + }, + "last_refresh": last_refresh, + }) +} + +fn app(home: &Path) -> PathBuf { + home.join(".codex-switch") +} + +fn write(path: impl AsRef, text: &str) { + let path = path.as_ref(); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, text).unwrap(); +} + +fn put_profile(home: &Path, alias: &str, value: &Value) { + write( + app(home).join("profiles").join(alias).join("auth.json"), + &serde_json::to_string_pretty(value).unwrap(), + ); +} + +fn provider_toml(alias: &str, identity: &str, key: &str, model: &str) -> String { + format!( + r#"identity_id = "{identity}" +provider_id = "{alias}" +name = "{alias}" +base_url = "https://example.invalid/v1" +env_key = "CS_TEST_PROVIDER_KEY" +default_model = "{model}" +api_key = "{key}" + +[[models]] +id = "{model}" +"# + ) +} + +const CONFIG_A: &str = + "[proxy]\nurl = \"http://user:proxy-secret-pw@127.0.0.1:9\"\n\n[cache]\nttl = 120\n"; + +/// Machine A: two profiles (work is current), one provider with models.json, +/// and a config.toml carrying a proxy password. +fn machine_a() -> PathBuf { + let home = temp_home("a"); + put_profile( + &home, + "work", + &auth( + "work@example.com", + "acct-work", + "work-a", + "2026-10-10T00:00:00Z", + ), + ); + put_profile( + &home, + "personal", + &auth("me@example.com", "acct-me", "me-a", "2026-10-10T00:00:00Z"), + ); + write(app(&home).join("current"), "work"); + write( + app(&home).join("providers/openrouter/provider.toml"), + &provider_toml( + "openrouter", + "11111111-1111-4111-8111-111111111111", + "sk-provider-secret", + "m1", + ), + ); + write( + app(&home).join("providers/openrouter/models.json"), + "{\"models\":[]}\n", + ); + write(app(&home).join("config.toml"), CONFIG_A); + home +} + +fn command(home: &Path, args: &[&str]) -> Command { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_codex-switch")); + cmd.args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .env("HOME", home) + .env("USERPROFILE", home) + .env("CODEX_HOME", home.join(".codex")) + .env("CODEX_SWITCH_HOME", app(home)) + .env_remove("HTTP_PROXY") + .env_remove("HTTPS_PROXY") + .env_remove("ALL_PROXY") + .env_remove("CS_PROXY") + .env_remove("CODEX_SWITCH_TEST_IMPORT_FAIL_AT"); + cmd +} + +fn run_with_stdin(mut cmd: Command, stdin: &str) -> Output { + cmd.stdin(Stdio::piped()); + let mut child = cmd.spawn().unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(stdin.as_bytes()) + .unwrap(); + child.wait_with_output().unwrap() +} + +fn run(home: &Path, args: &[&str], stdin: Option<&str>) -> Output { + let cmd = command(home, args); + match stdin { + Some(input) => run_with_stdin(cmd, input), + None => { + let mut cmd = cmd; + cmd.output().unwrap() + } + } +} + +fn text(out: &Output) -> String { + format!( + "status={:?}\nstdout={}\nstderr={}", + out.status.code(), + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ) +} + +fn export(home: &Path, dest: &Path) { + let out = run( + home, + &["export", dest.to_str().unwrap(), "--passphrase-stdin"], + Some(&format!("{PASS}\n")), + ); + assert!(out.status.success(), "{}", text(&out)); +} + +fn import(home: &Path, bundle: &Path, extra: &[&str], pass: &str) -> Output { + let mut args = vec!["import", bundle.to_str().unwrap(), "--passphrase-stdin"]; + args.extend_from_slice(extra); + run(home, &args, Some(&format!("{pass}\n"))) +} + +fn json_import(home: &Path, bundle: &Path, extra: &[&str], pass: &str) -> (Output, Value) { + let mut args = vec![ + "--json", + "import", + bundle.to_str().unwrap(), + "--passphrase-stdin", + ]; + args.extend_from_slice(extra); + let out = run(home, &args, Some(&format!("{pass}\n"))); + let value: Value = serde_json::from_slice(&out.stdout) + .unwrap_or_else(|e| panic!("stdout is not one JSON value ({e}): {}", text(&out))); + (out, value) +} + +/// Snapshot of every file under the app home, ignoring logs and lock files, +/// which any command touches. +fn tree(home: &Path) -> BTreeMap> { + fn walk(root: &Path, dir: &Path, out: &mut BTreeMap>) { + let Ok(read) = fs::read_dir(dir) else { return }; + for entry in read.flatten() { + let path = entry.path(); + let rel = path + .strip_prefix(root) + .unwrap() + .to_string_lossy() + .replace('\\', "/"); + if rel.starts_with(".codex-switch/logs") || rel.ends_with(".lock") { + continue; + } + if path.is_dir() { + walk(root, &path, out); + } else { + out.insert(rel, fs::read(&path).unwrap()); + } + } + } + let mut out = BTreeMap::new(); + walk(home, home, &mut out); + out +} + +fn items(v: &Value) -> Vec<(String, String, String)> { + v["items"] + .as_array() + .unwrap() + .iter() + .map(|i| { + ( + i["key"].as_str().unwrap().to_string(), + i["action"].as_str().unwrap().to_string(), + i["target"].as_str().unwrap_or("-").to_string(), + ) + }) + .collect() +} + +fn action_of(v: &Value, key: &str) -> String { + items(v) + .into_iter() + .find(|(k, _, _)| k == key) + .map(|(_, a, _)| a) + .unwrap_or_else(|| panic!("no item {key} in {v}")) +} + +fn read_json(path: &Path) -> Value { + serde_json::from_slice(&fs::read(path).unwrap()).unwrap() +} + +fn bundle_of(name: &str) -> (PathBuf, PathBuf) { + let a = machine_a(); + let bundle = temp_home(name).join("out.csbundle"); + export(&a, &bundle); + (a, bundle) +} + +// 1 +#[test] +fn round_trip_into_empty_machine() { + let (a, bundle) = bundle_of("rt"); + let b = temp_home("b"); + let out = import(&b, &bundle, &[], PASS); + assert!(out.status.success(), "{}", text(&out)); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("codex-switch use work"), "{stdout}"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("single-use"), "{stderr}"); + + for rel in [ + "profiles/work/auth.json", + "profiles/personal/auth.json", + "providers/openrouter/provider.toml", + "providers/openrouter/models.json", + "config.toml", + ] { + assert_eq!( + fs::read(app(&a).join(rel)).unwrap(), + fs::read(app(&b).join(rel)).unwrap(), + "{rel} differs" + ); + } + assert!( + !app(&b).join("current").exists(), + "current must not be written" + ); + assert!(!b.join(".codex/auth.json").exists()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = |p: PathBuf| fs::metadata(p).unwrap().permissions().mode() & 0o777; + assert_eq!(mode(app(&b).join("profiles/work/auth.json")), 0o600); + assert_eq!(mode(app(&b).join("profiles/work")), 0o700); + assert_eq!( + mode(app(&b).join("providers/openrouter/provider.toml")), + 0o600 + ); + assert_eq!(mode(bundle.clone()), 0o600); + } +} + +// 2 +#[test] +fn wrong_passphrase_is_distinct_and_writes_nothing() { + let (_a, bundle) = bundle_of("wp"); + let b = temp_home("b"); + let before = tree(&b); + let (out, v) = json_import(&b, &bundle, &[], "not the passphrase"); + assert!(!out.status.success()); + assert_eq!(v["code"], "wrong_passphrase", "{v}"); + assert_eq!(before, tree(&b)); +} + +fn edit_bundle(bundle: &Path, f: impl FnOnce(&mut Value)) -> PathBuf { + let mut v = read_json(bundle); + f(&mut v); + let out = bundle.with_file_name(format!( + "edited-{}.csbundle", + NEXT_ID.fetch_add(1, Ordering::Relaxed) + )); + fs::write(&out, serde_json::to_vec_pretty(&v).unwrap()).unwrap(); + out +} + +// 3 +#[test] +fn tampering_is_detected() { + let (_a, bundle) = bundle_of("tamper"); + let b = temp_home("b"); + let before = tree(&b); + + let flipped = edit_bundle(&bundle, |v| { + let b64 = base64::engine::general_purpose::STANDARD; + let mut ct = b64.decode(v["ciphertext"].as_str().unwrap()).unwrap(); + ct[5] ^= 0x01; + v["ciphertext"] = json!(b64.encode(ct)); + }); + let (_, v) = json_import(&b, &flipped, &[], PASS); + assert_eq!(v["code"], "bundle_tampered", "{v}"); + + let dated = edit_bundle(&bundle, |v| v["created_at"] = json!("2020-01-01T00:00:00Z")); + let (_, v) = json_import(&b, &dated, &[], PASS); + assert_eq!(v["code"], "bundle_tampered", "{v}"); + + let salted = edit_bundle(&bundle, |v| { + v["encryption"]["kdf"]["salt"] = json!("AAAAAAAAAAAAAAAAAAAAAA==") + }); + let (_, v) = json_import(&b, &salted, &[], PASS); + assert_eq!(v["code"], "wrong_passphrase", "{v}"); + + assert_eq!(before, tree(&b)); +} + +// 4 +#[test] +fn kdf_bounds_are_checked_before_derivation() { + let (_a, bundle) = bundle_of("kdf"); + let b = temp_home("b"); + let huge = edit_bundle(&bundle, |v| { + v["encryption"]["kdf"]["m_cost_kib"] = json!(4_194_304) + }); + let started = std::time::Instant::now(); + let (out, v) = json_import(&b, &huge, &[], PASS); + assert!(!out.status.success()); + assert_eq!(v["code"], "unsupported_encryption", "{v}"); + assert!(started.elapsed() < std::time::Duration::from_secs(10)); +} + +/// Machine B already has `work` (same identity as A's) with other tokens. +fn machine_b_with_conflict(last_refresh: &str) -> PathBuf { + let b = temp_home("b"); + put_profile( + &b, + "work", + &auth("work@example.com", "acct-work", "work-b", last_refresh), + ); + b +} + +// 5 +#[test] +fn strategy_skip_keeps_local_by_default() { + let (_a, bundle) = bundle_of("skip"); + let b = machine_b_with_conflict("2026-10-01T00:00:00Z"); + let local = fs::read(app(&b).join("profiles/work/auth.json")).unwrap(); + let (out, v) = json_import(&b, &bundle, &[], PASS); + assert!(out.status.success(), "{}", text(&out)); + assert_eq!(v["strategy"], "skip"); + assert_eq!(action_of(&v, "profiles/work/auth.json"), "skip"); + assert_eq!(action_of(&v, "profiles/personal/auth.json"), "create"); + assert_eq!( + fs::read(app(&b).join("profiles/work/auth.json")).unwrap(), + local + ); +} + +// 6 +#[test] +fn strategy_overwrite_replaces_backs_up_and_updates_live() { + let (a, bundle) = bundle_of("ow"); + let b = machine_b_with_conflict("2026-10-01T00:00:00Z"); + let local = fs::read(app(&b).join("profiles/work/auth.json")).unwrap(); + write(app(&b).join("current"), "work"); + write( + b.join(".codex/auth.json"), + &String::from_utf8(local.clone()).unwrap(), + ); + + // Without --yes a non-interactive overwrite is refused. + let (out, _) = json_import(&b, &bundle, &["--strategy", "overwrite"], PASS); + assert!(!out.status.success()); + assert_eq!( + fs::read(app(&b).join("profiles/work/auth.json")).unwrap(), + local + ); + + let (out, v) = json_import(&b, &bundle, &["--strategy", "overwrite", "--yes"], PASS); + assert!(out.status.success(), "{}", text(&out)); + assert_eq!(action_of(&v, "profiles/work/auth.json"), "overwrite"); + let imported = fs::read(app(&a).join("profiles/work/auth.json")).unwrap(); + assert_eq!( + fs::read(app(&b).join("profiles/work/auth.json")).unwrap(), + imported + ); + assert_eq!( + read_json(&b.join(".codex/auth.json")), + read_json(&app(&a).join("profiles/work/auth.json")) + ); + let backup = PathBuf::from(v["backup_dir"].as_str().unwrap()); + assert_eq!( + fs::read(backup.join("backup/app/profiles/work/auth.json")).unwrap(), + local + ); + let journal = read_json(&backup.join("journal.json")); + assert_eq!(journal["state"], "committed"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + for dir in [ + "", + "backup", + "backup/app", + "backup/app/profiles", + "backup/live", + ] { + let mode = fs::metadata(backup.join(dir)).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o700, "backup dir '{dir}'"); + } + } +} + +// 7 +#[test] +fn strategy_merge_keeps_newer_credentials_and_local_config() { + // (a) imported newer -> imported wins + let (a, bundle) = bundle_of("merge"); + let b = machine_b_with_conflict("2026-10-01T00:00:00Z"); + write(app(&b).join("config.toml"), "[cache]\nttl = 999\n"); + write( + app(&b).join("providers/openrouter/provider.toml"), + &provider_toml( + "openrouter", + "11111111-1111-4111-8111-111111111111", + "sk-local", + "m2", + ), + ); + let (out, v) = json_import(&b, &bundle, &["--strategy", "merge", "--yes"], PASS); + assert!(out.status.success(), "{}", text(&out)); + assert_eq!(action_of(&v, "profiles/work/auth.json"), "overwrite"); + assert_eq!( + fs::read(app(&b).join("profiles/work/auth.json")).unwrap(), + fs::read(app(&a).join("profiles/work/auth.json")).unwrap() + ); + let config = fs::read_to_string(app(&b).join("config.toml")).unwrap(); + assert_eq!(action_of(&v, "config.toml"), "merge"); + assert!(config.contains("ttl = 999"), "{config}"); + assert!(config.contains("proxy"), "{config}"); + // Provider merge is deferred: treated as skip. + assert_eq!(action_of(&v, "providers/openrouter/provider.toml"), "skip"); + assert!( + fs::read_to_string(app(&b).join("providers/openrouter/provider.toml")) + .unwrap() + .contains("sk-local") + ); + + // (b) imported older -> local kept + let b2 = machine_b_with_conflict("2026-10-20T00:00:00Z"); + let local = fs::read(app(&b2).join("profiles/work/auth.json")).unwrap(); + let (out, v) = json_import(&b2, &bundle, &["--strategy", "merge", "--yes"], PASS); + assert!(out.status.success(), "{}", text(&out)); + assert_eq!(action_of(&v, "profiles/work/auth.json"), "skip"); + assert_eq!( + fs::read(app(&b2).join("profiles/work/auth.json")).unwrap(), + local + ); +} + +// 8 +#[test] +fn alias_collision_with_other_account_always_renames() { + let (_a, bundle) = bundle_of("collide"); + for strategy in ["skip", "overwrite", "merge"] { + let b = temp_home("b"); + put_profile( + &b, + "work", + &auth( + "other@example.com", + "acct-other", + "other", + "2026-10-01T00:00:00Z", + ), + ); + // Case-only collision with a different account. + put_profile( + &b, + "Personal", + &auth("x@example.com", "acct-x", "x", "2026-10-01T00:00:00Z"), + ); + let local_work = fs::read(app(&b).join("profiles/work/auth.json")).unwrap(); + let (out, v) = json_import(&b, &bundle, &["--strategy", strategy, "--yes"], PASS); + assert!(out.status.success(), "{strategy}: {}", text(&out)); + let it = items(&v); + assert!( + it.contains(&( + "profiles/work/auth.json".into(), + "rename".into(), + "work_2".into() + )), + "{strategy}: {v}" + ); + assert!( + it.contains(&( + "profiles/personal/auth.json".into(), + "rename".into(), + "personal_2".into() + )), + "{strategy}: {v}" + ); + assert_eq!( + fs::read(app(&b).join("profiles/work/auth.json")).unwrap(), + local_work + ); + assert!(app(&b).join("profiles/work_2/auth.json").exists()); + assert_eq!(v["source_current_target"], "work_2"); + } +} + +// 9 +#[test] +fn dry_run_writes_nothing_and_prints_no_secrets() { + let (_a, bundle) = bundle_of("dry"); + let b = machine_b_with_conflict("2026-10-01T00:00:00Z"); + let before = tree(&b); + for strategy in ["skip", "overwrite", "merge"] { + let out = import(&b, &bundle, &["--dry-run", "--strategy", strategy], PASS); + assert!(out.status.success(), "{}", text(&out)); + let all = text(&out); + assert!(all.contains("profiles/personal/auth.json"), "{all}"); + assert!(all.contains("machine-specific"), "{all}"); + for secret in [ + "refresh-work-a", + "access-work-a", + "sk-provider-secret", + "proxy-secret-pw", + ] { + assert!(!all.contains(secret), "{secret} leaked: {all}"); + } + } + assert_eq!(before, tree(&b)); +} + +// 10 +#[test] +fn failure_mid_apply_rolls_back() { + let (_a, bundle) = bundle_of("rollback"); + let b = machine_b_with_conflict("2026-10-01T00:00:00Z"); + write(app(&b).join("config.toml"), "[cache]\nttl = 5\n"); + let before = tree(&b); + let mut cmd = command( + &b, + &[ + "--json", + "import", + bundle.to_str().unwrap(), + "--passphrase-stdin", + "--strategy", + "overwrite", + "--yes", + ], + ); + cmd.env("CODEX_SWITCH_TEST_IMPORT_FAIL_AT", "3"); + let out = run_with_stdin(cmd, &format!("{PASS}\n")); + assert!(!out.status.success(), "{}", text(&out)); + assert!(text(&out).contains("rolled back"), "{}", text(&out)); + let after: BTreeMap<_, _> = tree(&b) + .into_iter() + .filter(|(k, _)| !k.contains("import-backups")) + .collect(); + assert_eq!(before, after); + let backups = app(&b).join("import-backups"); + let dir = fs::read_dir(&backups) + .unwrap() + .next() + .unwrap() + .unwrap() + .path(); + assert_eq!(read_json(&dir.join("journal.json"))["state"], "rolled-back"); + + // A later import is not blocked by a rolled-back journal. + let (out, _) = json_import(&b, &bundle, &["--dry-run"], PASS); + assert!(out.status.success(), "{}", text(&out)); +} + +fn fixture() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/export-v1.csbundle") +} + +// 11 +#[test] +fn old_and_future_bundle_versions() { + let b = temp_home("b"); + let (out, v) = json_import(&b, &fixture(), &[], "fixture-passphrase"); + assert!(out.status.success(), "{}", text(&out)); + assert_eq!(action_of(&v, "profiles/fixture/auth.json"), "create"); + assert!(app(&b).join("profiles/fixture/auth.json").exists()); + + let dir = temp_home("future"); + let future = dir.join("future.csbundle"); + fs::copy(fixture(), &future).unwrap(); + let future = edit_bundle(&future, |v| v["schema_version"] = json!(2)); + // No stdin at all: the version error must come before any passphrase prompt. + let out = run(&b, &["--json", "import", future.to_str().unwrap()], None); + let v: Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(v["code"], "unsupported_bundle_version", "{v}"); + assert!(v["error"].as_str().unwrap().contains("self-update")); + + // Without the magic the file is not a bundle, so it takes the legacy path + // where bundle-only flags are a usage error. + let not_bundle = edit_bundle(&future, |v| v["format"] = json!("something-else")); + let out = run( + &b, + &["import", not_bundle.to_str().unwrap(), "--dry-run"], + None, + ); + assert_eq!(out.status.code(), Some(2), "{}", text(&out)); +} + +// 12 +#[test] +fn plaintext_requires_confirmation() { + let a = machine_a(); + let dest = temp_home("pt").join("plain.csbundle"); + let out = run(&a, &["export", dest.to_str().unwrap(), "--plaintext"], None); + assert!(!out.status.success()); + assert!(!dest.exists()); + + let out = run( + &a, + &["export", dest.to_str().unwrap(), "--plaintext", "--yes"], + None, + ); + assert!(out.status.success(), "{}", text(&out)); + assert!(String::from_utf8_lossy(&out.stderr).contains("NOT encrypted")); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(&dest).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + let v = read_json(&dest); + assert!(v["encryption"].is_null()); + + let b = temp_home("b"); + let out = run(&b, &["import", dest.to_str().unwrap()], None); + assert!(out.status.success(), "{}", text(&out)); + assert!(String::from_utf8_lossy(&out.stderr).contains("NOT encrypted")); + assert!(String::from_utf8_lossy(&out.stdout).contains("Delete")); + assert!(app(&b).join("profiles/work/auth.json").exists()); +} + +// 13 +#[test] +fn encrypted_bundle_contains_no_secret_bytes() { + let (_a, bundle) = bundle_of("bytes"); + let raw = fs::read(&bundle).unwrap(); + let v = read_json(&bundle); + let ct = base64::engine::general_purpose::STANDARD + .decode(v["ciphertext"].as_str().unwrap()) + .unwrap(); + for secret in [ + "refresh-work-a", + "access-work-a", + "refresh-me-a", + "sk-provider-secret", + "proxy-secret-pw", + "acct-work", + "work@example.com", + ] { + let b64 = base64::engine::general_purpose::STANDARD.encode(secret); + for hay in [&raw, &ct] { + for needle in [secret.as_bytes(), b64.as_bytes()] { + assert!( + !hay.windows(needle.len()).any(|w| w == needle), + "{secret} found in bundle bytes" + ); + } + } + } +} + +fn plaintext_bundle(entries: &[(&str, &str, &str)]) -> PathBuf { + // Serialise in the payload struct's field order so the checksum matches. + let entries_json: Vec = entries + .iter() + .map(|(key, kind, text)| { + format!( + "{{\"key\":{},\"kind\":{},\"text\":{}}}", + json!(key), + json!(kind), + json!(text) + ) + }) + .collect(); + let payload_str = format!( + "{{\"source_current\":null,\"entries\":[{}]}}", + entries_json.join(",") + ); + use sha2::Digest; + let digest = hex::encode(sha2::Sha256::digest(payload_str.as_bytes())); + let doc = format!( + "{{\"format\":\"codex-switch-export\",\"schema_version\":1,\"app_version\":\"test\",\"platform\":{{\"os\":\"linux\",\"arch\":\"x86_64\"}},\"created_at\":\"2026-10-19T00:00:00Z\",\"encryption\":null,\"payload_sha256\":\"{digest}\",\"payload\":{payload_str}}}" + ); + let path = temp_home("hand").join("hand.csbundle"); + fs::write(&path, doc).unwrap(); + path +} + +// 14 +#[test] +fn unsafe_keys_are_rejected_before_any_write() { + let good = serde_json::to_string(&auth( + "ok@example.com", + "acct-ok", + "ok", + "2026-10-01T00:00:00Z", + )) + .unwrap(); + // Sanity: a well-formed handcrafted bundle imports. + let b = temp_home("b"); + let ok = plaintext_bundle(&[("profiles/ok/auth.json", "profile_auth", &good)]); + let out = run(&b, &["import", ok.to_str().unwrap()], None); + assert!(out.status.success(), "{}", text(&out)); + + for bad in [ + "../x", + "/etc/x", + "profiles/a/../../x", + "C:\\x", + "profiles/a/other.json", + ] { + let b = temp_home("b"); + let path = plaintext_bundle(&[ + ("profiles/ok/auth.json", "profile_auth", &good), + (bad, "profile_auth", &good), + ]); + let before = tree(&b); + let out = run(&b, &["--json", "import", path.to_str().unwrap()], None); + assert!(!out.status.success(), "{bad}: {}", text(&out)); + let v: Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(v["code"], "invalid_bundle", "{bad}: {v}"); + assert_eq!(before, tree(&b), "{bad} wrote something"); + } +} + +// 15 +#[test] +fn export_does_not_clobber_without_force() { + let a = machine_a(); + let dest = temp_home("nc").join("exists.csbundle"); + fs::write(&dest, "keep me").unwrap(); + let out = run( + &a, + &["export", dest.to_str().unwrap(), "--passphrase-stdin"], + Some("pw\n"), + ); + assert!(!out.status.success()); + assert_eq!(fs::read(&dest).unwrap(), b"keep me"); + let out = run( + &a, + &[ + "export", + dest.to_str().unwrap(), + "--passphrase-stdin", + "--force", + ], + Some("pw\n"), + ); + assert!(out.status.success(), "{}", text(&out)); + assert_eq!(read_json(&dest)["format"], "codex-switch-export"); +} + +// 16 +#[test] +fn legacy_import_paths_are_unchanged() { + let (_a, bundle) = bundle_of("legacy"); + let b = temp_home("b"); + // A directory scan only collects *.json, so a bundle alone is "no JSON files". + let dir = temp_home("scan"); + fs::copy(&bundle, dir.join("backup.csbundle")).unwrap(); + let out = run(&b, &["import", dir.to_str().unwrap()], None); + assert!(!out.status.success()); + assert!(text(&out).contains("no JSON files"), "{}", text(&out)); + // Bundle-only flags on a legacy auth.json path are a usage error. + let auth_path = dir.join("auth.json"); + fs::write( + &auth_path, + serde_json::to_vec(&auth( + "l@example.com", + "acct-l", + "l", + "2026-10-01T00:00:00Z", + )) + .unwrap(), + ) + .unwrap(); + for flag in [ + "--dry-run", + "--strategy=merge", + "--yes", + "--activate", + "--passphrase-stdin", + ] { + let out = run(&b, &["import", auth_path.to_str().unwrap(), flag], None); + assert_eq!(out.status.code(), Some(2), "{flag}: {}", text(&out)); + } + // An alias is a usage error for bundles. + let out = run(&b, &["import", bundle.to_str().unwrap(), "alias"], None); + assert_eq!(out.status.code(), Some(2), "{}", text(&out)); +} + +// 17 +#[test] +fn json_output_is_exactly_one_value() { + let a = machine_a(); + let dest = temp_home("json").join("j.csbundle"); + let out = run( + &a, + &[ + "--json", + "export", + dest.to_str().unwrap(), + "--passphrase-stdin", + ], + Some("pw\n"), + ); + assert!(out.status.success(), "{}", text(&out)); + let v: Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(v["ok"], true); + assert_eq!(v["encrypted"], true); + assert_eq!(v["source_current"], "work"); + + let b = temp_home("b"); + for extra in [&["--dry-run"][..], &[][..]] { + let (out, v) = json_import(&b, &dest, extra, "pw"); + assert!(out.status.success(), "{}", text(&out)); + assert_eq!(v["ok"], true); + assert_eq!( + serde_json::Deserializer::from_slice(&out.stdout) + .into_iter::() + .count(), + 1 + ); + } +} + +#[test] +fn activate_switches_to_source_current() { + let (_a, bundle) = bundle_of("activate"); + let b = temp_home("b"); + let (out, v) = json_import(&b, &bundle, &["--activate"], PASS); + assert!(out.status.success(), "{}", text(&out)); + assert_eq!(v["activated"], "work"); + assert_eq!( + fs::read_to_string(app(&b).join("current")).unwrap().trim(), + "work" + ); + assert!(b.join(".codex/auth.json").exists()); +} + +#[test] +fn second_import_is_unchanged() { + let (_a, bundle) = bundle_of("again"); + let b = temp_home("b"); + assert!(import(&b, &bundle, &[], PASS).status.success()); + let (out, v) = json_import(&b, &bundle, &[], PASS); + assert!(out.status.success(), "{}", text(&out)); + for (key, action, _) in items(&v) { + assert_eq!(action, "unchanged", "{key}"); + } + assert!(v["backup_dir"].is_null()); +} From 1925defadfaf92cb3177d5bbacd8168181a35964 Mon Sep 17 00:00:00 2001 From: 7678837-glitch <318980587+7678837-glitch@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:09:51 +0800 Subject: [PATCH 2/2] fix(transfer): guard live auth.json and duplicates on bundle import (review round 1) H1: when an import updates the current profile, the live auth.json is compared too. Merge writes only when the imported credentials are provably newer than both the profile and live; overwrite replaces live only when live is provably older, otherwise the item is skipped unless --replace-live is given. Missing/equal timestamps are flagged. Profile and live writes re-check account identity and the newer-token rule at write time (profile::guard_imported_credentials). The plan and JSON expose replaces_live, which also requires confirmation and is part of the locked decision fingerprint. H2: live is only replaced when it belongs to the imported account; a live login of another (untracked) account is left untouched with a hint, and --activate goes through cmd_use's untracked-live confirmation. M1: one account under several aliases in a bundle is written once, from the provably newest entry (or not at all); several local profiles of one account are ambiguous; no two writes may target the same file. M3: importing an unencrypted bundle, or adding a provider with codex_config overrides, needs --yes or interactive confirmation; the plan lists provider base_url and override count. M2: the journal maps each replaced path to its backup; the recovery message lists the pairs; import --dry-run, use, launch and export warn about an unfinished import; directory entries are fsynced after each write. Also: KDF bounds tightened to m<=256 MiB, t<=6 (L1); --json bundle usage errors are JSON (L3). CHANGELOG entry rewritten in Chinese. --- docs/CHANGELOG.md | 6 +- docs/wiki/Chinese-Guide.md | 7 +- docs/wiki/Command-Reference.md | 2 +- docs/wiki/Feature-Guide.md | 4 +- src/cli.rs | 5 +- src/commands/transfer.rs | 63 ++++- src/entry.rs | 29 ++- src/profile.rs | 48 +++- src/transfer/apply.rs | 165 +++++++++++-- src/transfer/crypto.rs | 15 +- src/transfer/plan.rs | 327 +++++++++++++++++++++--- tests/test_transfer.rs | 437 ++++++++++++++++++++++++++++++++- 12 files changed, 1027 insertions(+), 81 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d8a6b33..671fdfe 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,7 +2,11 @@ ## Unreleased -- **Export / import to another machine.** `codex-switch export [FILE]` writes all profiles, providers (with API keys and model catalogs) and `config.toml` into one `.csbundle`, encrypted with AES-256-GCM under an Argon2id key (64 MiB, t=3, p=1) and written `0600`; `--plaintext` requires confirmation. `codex-switch import ` detects bundles by content, imports offline (managed policy and identity checks still apply), offers `--dry-run`, `--strategy skip|overwrite|merge`, `--activate`, backs up replaced files to `import-backups/` and rolls back on error. Existing `auth.json` imports are unchanged. Refresh tokens are single-use: both commands warn that using one account on two machines forces a re-login. +- **整机导出/导入。** `codex-switch export [FILE]` 把全部账号、提供方(含 API 密钥和模型目录)与 `config.toml` 写入一个 `.csbundle`,用 Argon2id(64 MiB、t=3、p=1)派生密钥、AES-256-GCM 加密,文件权限 `0600`;`--plaintext` 需要确认。`codex-switch import <导出包>` 按内容识别导出包并离线导入(仍执行管理员策略和身份检查),支持 `--dry-run`、`--strategy skip|overwrite|merge`、`--activate`,被替换的文件备份到 `import-backups/`,出错时回滚。原有的 `auth.json` 导入不变。refresh token 只能用一次,两条命令都会提示同一账号不要在两台机器上同时使用。 +- 导入只在当前账号的 live `auth.json` 属于同一账号、且能证明比导入内容旧时才替换它:`merge` 遇到更新或无法比较的 live 会跳过,`overwrite` 需要再加 `--replace-live`;live 属于其他账号(如未跟踪的 `codex login`)时不会被改动,`--activate` 也不会在无确认时覆盖它。计划和 `--json` 中新增 `replaces_live`。 +- 同一导出包里同一账号出现多次时只写入能证明最新的一份,无法判断时整组跳过;本地有多个同账号 profile 时标为 `ambiguous`。 +- 导入未加密的导出包、或会新增带 `codex_config` 覆盖项的提供方时,也需要交互确认或 `--yes`;计划中列出提供方的 `base_url` 和覆盖项数量。 +- 中断的导入会在日志中记录每个被替换文件与其备份的对应关系,下次导入给出逐条恢复步骤;`import --dry-run`、`use`、`launch`、`export` 会提示存在未完成的导入。KDF 参数上限收紧为 256 MiB/t=6;`--json` 下的参数用法错误也输出 JSON。 ## v20261015.2.0 — 2026-10-15 diff --git a/docs/wiki/Chinese-Guide.md b/docs/wiki/Chinese-Guide.md index 72afc3b..c1e665d 100644 --- a/docs/wiki/Chinese-Guide.md +++ b/docs/wiki/Chinese-Guide.md @@ -63,10 +63,11 @@ codex-switch import ~/codex-switch.csbundle --strategy merge --yes --activate - `--plaintext` 写出未加密的导出包,需要交互确认或 `--yes`;文件里是可直接使用的 token 和 API 密钥,导入后请立即删除。 - **refresh token 只能使用一次。** 导出不会让源机器上的账号失效;两台机器同时使用同一账号时,先刷新的一方会让另一方报 `refresh_token_reused`,需要重新 `codex-switch login <别名>`。 - 导入离线进行,不调用 Usage API,但仍执行管理员工作区策略和身份检查;任何条目校验失败都会在写入前整体中止。口令错误、文件被改动、版本过新分别给出不同错误(`--json` 下的 `code` 为 `wrong_passphrase`、`bundle_tampered`、`unsupported_bundle_version`)。 -- 冲突策略:`skip`(默认,保留本地)、`overwrite`(用导入内容替换,旧文件备份到 `import-backups/`)、`merge`(账号保留 `last_refresh` 更新的一份;`config.toml` 只补充本地缺少的键;提供方暂按 `skip` 处理)。别名被其他账号或提供方占用时(大小写不敏感)总是改名导入为 `<别名>_2`,绝不覆盖其他账号。`overwrite` / `merge` 需要确认,非交互或 `--json` 时需要 `--yes`。 +- 冲突策略:`skip`(默认,保留本地)、`overwrite`(用导入内容替换,旧文件备份到 `import-backups/`)、`merge`(账号保留 `last_refresh` 更新的一份;`config.toml` 只补充本地缺少的键;提供方暂按 `skip` 处理)。别名被其他账号或提供方占用时(大小写不敏感)总是改名导入为 `<别名>_2`,绝不覆盖其他账号。同一导出包中同一账号出现多次时只写入 `last_refresh` 可证明最新的一份,无法判断时整组跳过;本地有多个同账号 profile 时标为 `ambiguous`。`overwrite` / `merge`、替换 live `auth.json`、新增带 `codex_config` 覆盖项的提供方,以及未加密导出包的任何写入都需要确认,非交互或 `--json` 时需要 `--yes`;预览中会列出提供方的 `base_url` 和覆盖项数量。 +- live `~/.codex/auth.json` 和 profile 同等对待:导入更新当前账号时,只有 live 属于同一账号、且可证明比导入内容旧(`refresh_token` 相同,或 `last_refresh` 更早)才会替换。Codex 刚刷新过、更新或无法比较时,`merge` 跳过该账号,`overwrite` 需要再加 `--replace-live`;live 属于其他账号时绝不改动。`--json` 每项都有 `replaces_live`。 - `--dry-run` 会把 `config.toml` 中的 `[proxy]` 标为“machine-specific, review”,代理通常与机器相关,导入后请检查。 -- 导入默认不切换当前账号,只提示源机器当前账号;加 `--activate[=别名]` 才会切换。 -- 写入中途出错会按日志反向回滚;进程崩溃留下 `in-progress` 日志时,下次导入会拒绝执行并打印手动恢复步骤。 +- 导入默认不切换当前账号,只提示源机器当前账号;加 `--activate[=别名]` 才会切换。与 `use` 一样,live `auth.json` 不属于任何已保存账号时,未经交互确认不会被覆盖。 +- 写入中途出错会按日志反向回滚;进程崩溃留下 `in-progress` 日志时,日志里记录了每个被替换文件对应的备份,下次导入会拒绝执行并逐条列出“备份 -> 原路径”的恢复步骤;`import --dry-run`、`use`、`launch`、`export` 会提示存在未完成的导入。 - 缓存、日志、已删除账号、`recovery/` 和 Codex 自身文件不导出。 ## 日常操作(ChatGPT 账号) diff --git a/docs/wiki/Command-Reference.md b/docs/wiki/Command-Reference.md index 0f3d2f8..8ed1be9 100644 --- a/docs/wiki/Command-Reference.md +++ b/docs/wiki/Command-Reference.md @@ -11,7 +11,7 @@ The installed binary remains authoritative: use `codex-switch --help` and `codex | `login [--device] [alias]` | Add or reauthorize a profile through browser PKCE or device-code login. If the alias already exists, it is reauthorized; otherwise a new profile is created. | | `import [alias]` | Validate and import one `auth.json`, or recursively scan a directory for JSON files. The alias applies to single-file imports only; directories auto-assign aliases. An account that is already saved (same file, or same `account_id` and email) is skipped instead of duplicated, so its single-use refresh token is not spent. | | `export [FILE] [--plaintext [--yes]] [--force] [--passphrase-stdin] [--no-providers] [--no-config]` | Write every saved profile, custom provider (API key and `models.json` included) and `config.toml` into one bundle, default `./codex-switch-.csbundle`, mode `0600`. The bundle is encrypted with AES-256-GCM under an Argon2id key (m = 64 MiB, t = 3, p = 1); the whole header is authenticated. The passphrase is prompted twice, or read from the first stdin line with `--passphrase-stdin`; it is never taken from argv or the environment. An existing file is refused unless `--force`. `--plaintext` writes an unencrypted bundle and needs interactive confirmation or `--yes` (always `--yes` under `--json` or without a TTY). Caches, logs, deleted profiles, `recovery/` and Codex-owned files are not exported. Refresh tokens are single-use: the exported accounts stay valid on the source, and using one account on both machines makes one of them log in again. | -| `import [--strategy skip\|overwrite\|merge] [--dry-run] [--yes] [--activate[=ALIAS]] [--passphrase-stdin]` | Import an export bundle, detected by content (`"format": "codex-switch-export"`), not extension. Offline: no Usage API call, but the managed workspace policy, the `account_id` requirement and the identity rules still apply, and any invalid entry aborts before a write. Same account (`account_id` + email) or same provider (alias + `identity_id`) with different content, or a different `config.toml`, is a conflict handled by `--strategy` (default `skip`; `overwrite` replaces after a backup; `merge` keeps the newer credential by `last_refresh` and adds only missing `config.toml` keys; provider merge is treated as `skip`). A different account under a taken alias (case-insensitive) is always imported under `_2`. Email-only matches are skipped as `ambiguous`. `overwrite`/`merge` need confirmation or `--yes`. `--dry-run` prints the plan and writes nothing; `[proxy]` in `config.toml` is flagged "machine-specific, review". Replaced files are backed up under `import-backups//` with a `journal.json`; an error rolls back. `current` and the live `auth.json` change only with `--activate` (default alias: the source machine's current account), except that overwriting the local current profile also updates the live file. Bundle-only flags on an `auth.json` path, or an alias with a bundle, are usage errors (exit 2). | +| `import [--strategy skip\|overwrite\|merge] [--dry-run] [--yes] [--activate[=ALIAS]] [--passphrase-stdin] [--replace-live]` | Import an export bundle, detected by content (`"format": "codex-switch-export"`), not extension. Offline: no Usage API call, but the managed workspace policy, the `account_id` requirement and the identity rules still apply, and any invalid entry aborts before a write. Same account (`account_id` + email) or same provider (alias + `identity_id`) with different content, or a different `config.toml`, is a conflict handled by `--strategy` (default `skip`; `overwrite` replaces after a backup; `merge` keeps the newer credential by `last_refresh` and adds only missing `config.toml` keys; provider merge is treated as `skip`). A different account under a taken alias (case-insensitive) is always imported under `_2`. Email-only matches, and accounts saved under several local aliases, are skipped as `ambiguous`; an account that appears more than once in the bundle is written once, from the entry with the provably newest `last_refresh` (none if that cannot be shown). `overwrite`/`merge`, replacing the live `auth.json`, providers that add `codex_config` overrides, and any write from an unencrypted bundle need confirmation or `--yes`; the plan lists each provider's `base_url` and override count. `--dry-run` prints the plan and writes nothing; `[proxy]` in `config.toml` is flagged "machine-specific, review". Replaced files are backed up under `import-backups//` with a `journal.json`; an error rolls back. An import interrupted by a crash leaves an `in-progress` journal that maps every replaced path to its backup; the next import refuses and prints those pairs, and `import --dry-run`, `use`, `launch` and `export` warn. `current` changes only with `--activate` (default alias: the source machine's current account), which, like `use`, will not overwrite a live `auth.json` that belongs to no saved profile without interactive confirmation. When the local current profile is updated, its live `auth.json` is replaced too only if it belongs to the same account and is provably older than the imported credentials (same `refresh_token`, or an earlier `last_refresh`); otherwise `merge` skips the profile and `overwrite` skips it unless `--replace-live` is given. A live file of another account is never touched. JSON items carry `replaces_live`. Bundle-only flags on an `auth.json` path, or an alias with a bundle, are usage errors (exit 2). | | `doctor [--desktop-codex ]` | Check the selected Codex runtime and, optionally, a desktop app's explicitly supplied bundled engine. Runtime selection uses `CODEX_SWITCH_CODEX`, then PATH, then known macOS desktop installations. A missing PATH CLI alone does not fail a healthy desktop-only setup. Invalid selected or explicitly supplied engines produce a nonzero exit. | | `list [-f]` | Show profiles, usage, and availability; `-f` / `--force` bypasses the cache. | | `use [alias] [--consume-card]` | Switch explicitly, or omit the alias to auto-select with the unified scoring algorithm. When the pool is exhausted, `--consume-card` consumes the earliest-expiring reset card to revive an account (auto-select only; ignored when an alias is given). | diff --git a/docs/wiki/Feature-Guide.md b/docs/wiki/Feature-Guide.md index cbd0b31..b3134d8 100644 --- a/docs/wiki/Feature-Guide.md +++ b/docs/wiki/Feature-Guide.md @@ -37,7 +37,9 @@ codex-switch import ~/codex-switch.csbundle --strategy merge --yes --activate The bundle is AES-256-GCM encrypted under an Argon2id key and written with mode `0600`. A wrong passphrase, a modified file and a bundle from a newer codex-switch fail with distinct errors before anything is written. Import is offline — tokens are not sent to the Usage API — but the managed workspace policy and identity checks still run, and one invalid entry aborts the whole import. -Conflicts follow `--strategy`: `skip` (default) keeps the local copy, `overwrite` replaces it after a backup to `import-backups/`, and `merge` keeps the credential with the newer `last_refresh` and adds only missing `config.toml` keys (providers are kept as with `skip`). A different account under a taken alias is always imported under a new alias, never over the other account. The dry-run flags `[proxy]` as machine-specific. The current account is switched only with `--activate`. +Conflicts follow `--strategy`: `skip` (default) keeps the local copy, `overwrite` replaces it after a backup to `import-backups/`, and `merge` keeps the credential with the newer `last_refresh` and adds only missing `config.toml` keys (providers are kept as with `skip`). A different account under a taken alias is always imported under a new alias, never over the other account. The dry-run flags `[proxy]` as machine-specific and lists each provider's `base_url` and `codex_config` override count. The current account is switched only with `--activate`. + +The live `~/.codex/auth.json` is treated like a profile: when the import updates your current profile, live is replaced only if it is the same account and provably older than the imported credentials. If Codex refreshed it more recently, `merge` keeps everything and `overwrite` asks for `--replace-live`; a live login of another account is never touched. Unencrypted bundles carry no authentication, so importing one needs confirmation or `--yes`, as does a provider that adds Codex overrides. If an import is killed halfway, the next import prints which backup restores which file. Refresh tokens are single-use. Export does not retire the accounts on the source machine; if both machines keep using the same account, one of them will fail with `refresh_token_reused` and need `codex-switch login `. diff --git a/src/cli.rs b/src/cli.rs index 32164f0..be306ac 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -216,7 +216,7 @@ pub enum Commands { }, /// Import an auth.json file, a directory of JSON files, or an export bundle #[command( - after_help = "A file whose JSON has \"format\": \"codex-switch-export\" is imported as an export bundle (see `export --help`); anything else uses the auth.json import, which validates each token through the Usage API.\nBundle import is offline: tokens are not sent anywhere, but the managed workspace policy and identity checks still apply. Conflicts (same account or provider with different content, or a different config.toml) follow --strategy; a different account under a taken alias is always imported under a new alias. Replaced files are backed up under import-backups/.\nThe current account is never switched unless --activate is given.\n\nExamples:\n codex-switch import ./auth.json work\n codex-switch import backup.csbundle --dry-run\n codex-switch import backup.csbundle --strategy merge --yes --activate" + after_help = "A file whose JSON has \"format\": \"codex-switch-export\" is imported as an export bundle (see `export --help`); anything else uses the auth.json import, which validates each token through the Usage API.\nBundle import is offline: tokens are not sent anywhere, but the managed workspace policy and identity checks still apply. Conflicts (same account or provider with different content, or a different config.toml) follow --strategy; a different account under a taken alias is always imported under a new alias. The live auth.json is replaced only for the current profile, only when it belongs to the same account, and only when it is provably older than the imported credentials (or with --replace-live). Replaced files are backed up under import-backups/. Unencrypted bundles, overwrites, merges and providers with codex_config overrides need --yes or interactive confirmation.\nThe current account is never switched unless --activate is given.\n\nExamples:\n codex-switch import ./auth.json work\n codex-switch import backup.csbundle --dry-run\n codex-switch import backup.csbundle --strategy merge --yes --activate" )] Import { /// Path to an auth.json file, a directory containing JSON files, or a .csbundle export @@ -238,6 +238,9 @@ pub enum Commands { /// [bundle] Read the passphrase from the first line of stdin #[arg(long)] passphrase_stdin: bool, + /// [bundle] With --strategy overwrite: also replace the current profile's live auth.json when it cannot be shown to be older than the imported credentials + #[arg(long)] + replace_live: bool, }, /// Export all profiles, providers and config.toml into one passphrase-encrypted bundle #[command( diff --git a/src/commands/transfer.rs b/src/commands/transfer.rs index 05c2e8a..b3b5dea 100644 --- a/src/commands/transfer.rs +++ b/src/commands/transfer.rs @@ -166,6 +166,7 @@ pub(crate) struct ImportBundleArgs<'a> { pub yes: bool, pub activate: Option>, pub passphrase_stdin: bool, + pub replace_live: bool, pub json: bool, } @@ -190,20 +191,37 @@ fn print_plan(plan: &plan::ImportPlan) { "KEY", "KIND", "ACTION", "TARGET" ); for item in &plan.items { + let mut reason = item.reason.clone(); + if let Some(url) = &item.base_url { + reason.push_str(&format!("; base_url {url}")); + } + if let Some(n) = item.codex_config_overrides.filter(|n| *n > 0) { + reason.push_str(&format!( + "; {n} codex_config override(s) passed to Codex on launch" + )); + } println!( "{:<44} {:<16} {:<10} {:<20} {}", item.key, item.kind, item.action.as_str(), item.target.as_deref().unwrap_or("-"), - item.reason + reason ); + if item.replaces_live && item.action.writes() { + println!("{:<44} also replaces the live auth.json", ""); + } } for notice in &plan.notices { warn(notice); } } +fn live_is_untracked() -> Result { + let live = auth::codex_auth_path()?; + Ok(live.exists() && profile::find_matching_profile(&live).is_none()) +} + pub(crate) fn import_bundle_cmd(args: ImportBundleArgs<'_>) -> Result<()> { auth::ensure_file_credentials_store()?; let bundle = transfer::read_bundle(args.path)?; @@ -216,9 +234,12 @@ pub(crate) fn import_bundle_cmd(args: ImportBundleArgs<'_>) -> Result<()> { }; let payload = transfer::open_payload(&bundle, passphrase.as_ref().map(|p| p.as_str()))?; drop(passphrase); - let preview = plan::classify(&payload, args.strategy)?; + let preview = plan::classify(&payload, args.strategy, args.replace_live)?; if args.dry_run { + if let Some(w) = transfer::apply::unfinished_import_warning() { + warn(&w); + } if args.json { print_json(&JsonImportBundle { ok: true, @@ -242,10 +263,20 @@ pub(crate) fn import_bundle_cmd(args: ImportBundleArgs<'_>) -> Result<()> { } transfer::apply::ensure_no_unfinished_import()?; - if preview.needs_confirmation() && !args.yes { - if args.json || !std::io::stdin().is_terminal() || args.passphrase_stdin { + let can_prompt = !args.json && std::io::stdin().is_terminal() && !args.passphrase_stdin; + // A plaintext bundle carries no authentication: whoever can write the + // file chooses its accounts, endpoints and Codex overrides. Importing one + // is confirmed exactly like writing one. + let unauthenticated = !encrypted && preview.has_writes(); + if (preview.needs_confirmation() || unauthenticated) && !args.yes { + if !can_prompt { + if unauthenticated { + anyhow::bail!( + "this bundle is NOT encrypted, so nothing proves who wrote it; review it with --dry-run and rerun with --yes" + ); + } anyhow::bail!( - "this import would overwrite or merge existing data; review it with --dry-run and rerun with --yes" + "this import would overwrite or merge existing data, replace the live auth.json, or add Codex overrides; review it with --dry-run and rerun with --yes" ); } print_plan(&preview); @@ -253,10 +284,17 @@ pub(crate) fn import_bundle_cmd(args: ImportBundleArgs<'_>) -> Result<()> { return Err(crate::error::CsError::Aborted.into()); } } + // --activate replaces the live auth.json; like `use`, it must not discard + // a login that belongs to no saved profile without asking. + if args.activate.is_some() && !can_prompt && live_is_untracked()? { + anyhow::bail!( + "the live auth.json belongs to no saved profile and --activate would overwrite it; import without --activate, then run `codex-switch use ` interactively" + ); + } let result = { let _transaction = profile::lock_auth_transaction()?; - let locked = plan::classify(&payload, args.strategy)?; + let locked = plan::classify(&payload, args.strategy, args.replace_live)?; if locked.decisions() != preview.decisions() { anyhow::bail!( "the local store changed while the import was being prepared; rerun the import" @@ -282,7 +320,16 @@ pub(crate) fn import_bundle_cmd(args: ImportBundleArgs<'_>) -> Result<()> { if !profile::profile_auth_path(&alias)?.exists() { anyhow::bail!("cannot activate '{alias}': no such profile after import"); } - profile::switch_profile(&alias)?; + profile::cmd_use(&alias, can_prompt).with_context(|| { + format!( + "the import itself finished{}, but --activate {alias} did not", + applied + .backup_dir + .as_ref() + .map(|d| format!(" (backup {})", d.display())) + .unwrap_or_default() + ) + })?; Some(alias) } }; @@ -319,7 +366,7 @@ pub(crate) fn import_bundle_cmd(args: ImportBundleArgs<'_>) -> Result<()> { ); } match (&activated, &final_plan.source_current_target) { - (Some(alias), _) => user_println(&format!("Switched to profile: {alias}")), + (Some(_), _) => {} (None, Some(target)) => println!( "Source machine's current account: {} → run `codex-switch use {target}`", final_plan.source_current.as_deref().unwrap_or(target) diff --git a/src/entry.rs b/src/entry.rs index 71dd32c..c93f3a6 100644 --- a/src/entry.rs +++ b/src/entry.rs @@ -159,8 +159,13 @@ pub async fn run_cli() { /// Report a bundle-only flag misuse the way clap reports a usage error /// (exit code 2), since it can only be detected after reading the file. -fn bundle_usage_error(message: &str) -> ! { +fn bundle_usage_error(message: &str, json: bool) -> ! { use clap::CommandFactory; + if json { + // `--json` promises exactly one JSON value on stdout, errors included. + crate::output::print_error_with_code(message, Some("usage")); + std::process::exit(2); + } crate::cli::Cli::command() .error(clap::error::ErrorKind::ArgumentConflict, message) .exit() @@ -215,6 +220,13 @@ async fn dispatch( AuthCheckResult::NoChange }; let auth_handled = !matches!(auth_check, AuthCheckResult::NoChange); + if matches!( + &cmd, + Commands::Use { .. } | Commands::Launch { .. } | Commands::Export { .. } + ) && let Some(warning) = crate::transfer::apply::unfinished_import_warning() + { + eprintln!("{}", color::warn(&format!("Warning: {warning}"))); + } match cmd { Commands::Use { @@ -236,6 +248,7 @@ async fn dispatch( yes, activate, passphrase_stdin, + replace_live, } => { let bundle_flags: Vec<&str> = [ (strategy.is_some(), "--strategy"), @@ -243,6 +256,7 @@ async fn dispatch( (yes, "--yes"), (activate.is_some(), "--activate"), (passphrase_stdin, "--passphrase-stdin"), + (replace_live, "--replace-live"), ] .into_iter() .filter_map(|(set, name)| set.then_some(name)) @@ -252,6 +266,7 @@ async fn dispatch( if alias.is_some() { bundle_usage_error( "an alias cannot be given when importing an export bundle; aliases come from the bundle", + json, ); } commands::import_bundle_cmd(commands::ImportBundleArgs { @@ -261,14 +276,18 @@ async fn dispatch( yes, activate, passphrase_stdin, + replace_live, json, })? } else { if !bundle_flags.is_empty() { - bundle_usage_error(&format!( - "{} can only be used with an export bundle, and '{path}' is not one", - bundle_flags.join(", ") - )); + bundle_usage_error( + &format!( + "{} can only be used with an export bundle, and '{path}' is not one", + bundle_flags.join(", ") + ), + json, + ); } commands::import_cmd(&path, alias.as_deref(), json).await? } diff --git a/src/profile.rs b/src/profile.rs index 19350b6..6a454d4 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -998,8 +998,10 @@ fn ensure_live_not_older( /// store (`cmd_save`, `update_profile_from_live`, and login replacement) goes /// through here, so the two invariants cannot be bypassed by adding a caller: /// the credentials must belong to this profile's account, and they must not -/// roll its single-use `refresh_token` backwards. Imports are create-only and -/// never select an existing profile. +/// roll its single-use `refresh_token` backwards. The auth.json import is +/// create-only and never selects an existing profile; export-bundle import +/// writes byte-exact files and checks the same two rules through +/// [`guard_imported_credentials`]. /// /// A profile that does not exist yet has nothing to protect, so this doubles as /// the create path; callers that require an existing profile check that first. @@ -1016,6 +1018,48 @@ fn write_profile_credentials(alias: &str, incoming: &serde_json::Value) -> Resul Ok(()) } +/// Whether `incoming` belongs to the same account as `existing`, by the strict +/// rule credential replacement uses (email equal, account_id not conflicting). +pub(crate) fn same_account_identity( + existing: &serde_json::Value, + incoming: &serde_json::Value, +) -> bool { + ensure_same_account_identity("", existing, incoming).is_ok() +} + +/// Whether replacing `existing` with `incoming` provably keeps the newest +/// single-use `refresh_token`: the [`ensure_live_not_older`] rule (same +/// refresh_token, or both stamps present and incoming strictly later). +pub(crate) fn credentials_not_older( + existing: &serde_json::Value, + incoming: &serde_json::Value, +) -> bool { + ensure_live_not_older("", existing, incoming).is_ok() +} + +/// Write-time check for export-bundle import, run under the auth transaction +/// right before `path` (a profile or the live auth.json) is replaced. The +/// account must match whatever is there now; the newer-credentials rule may be +/// waived only when the user explicitly chose to replace (`allow_older`). +pub(crate) fn guard_imported_credentials( + label: &str, + path: &Path, + incoming: &serde_json::Value, + allow_older: bool, +) -> Result<()> { + crate::auth::validate_managed_auth_value(incoming)?; + if !path.exists() { + return Ok(()); + } + let existing = read_auth(path) + .with_context(|| format!("refusing to replace unreadable {}", path.display()))?; + ensure_same_account_identity(label, &existing, incoming)?; + if !allow_older { + ensure_live_not_older(label, &existing, incoming)?; + } + Ok(()) +} + /// The profile these credentials provably belong to, if there is exactly one. /// /// `Err` when several profiles share the email and nothing tells them apart: diff --git a/src/transfer/apply.rs b/src/transfer/apply.rs index 82ac010..4b6563e 100644 --- a/src/transfer/apply.rs +++ b/src/transfer/apply.rs @@ -9,11 +9,22 @@ use serde::{Deserialize, Serialize}; use super::LogicalKey; use super::plan::{ImportPlan, PlanItem}; +#[derive(Debug, Serialize, Deserialize)] +pub(crate) struct BackupEntry { + /// Absolute path that was replaced. + pub path: String, + /// Its previous content, relative to the journal's directory. + pub backup: String, +} + #[derive(Debug, Serialize, Deserialize)] pub(crate) struct Journal { pub state: String, /// Absolute paths replaced; their previous content is under `backup/`. pub replaced: Vec, + /// `replaced` with the backup file of each, for manual recovery. + #[serde(default)] + pub backups: Vec, /// Absolute paths created by this import. pub created: Vec, /// Directories created by this import (removed on rollback when empty). @@ -24,13 +35,14 @@ pub(crate) fn backups_root() -> Result { Ok(crate::auth::app_home()?.join("import-backups")) } -/// Refuse to start while an earlier import never finished: its journal and -/// backup are the only record of what to restore. -pub(crate) fn ensure_no_unfinished_import() -> Result<()> { +/// Journals of imports that never finished (killed or powered off between +/// the first and the last write), with their directories. +fn unfinished_imports() -> Result> { let root = backups_root()?; let Ok(read) = std::fs::read_dir(&root) else { - return Ok(()); + return Ok(Vec::new()); }; + let mut out = Vec::new(); for entry in read.flatten() { let journal_path = entry.path().join("journal.json"); let Ok(raw) = std::fs::read(&journal_path) else { @@ -40,19 +52,76 @@ pub(crate) fn ensure_no_unfinished_import() -> Result<()> { continue; }; if journal.state == "in-progress" { - anyhow::bail!( - "an earlier import did not finish (journal {}).\nRestore it manually before \ - importing again:\n 1. copy each file under {} back to the path listed in \ - `replaced`\n 2. delete each path listed in `created`\n 3. set \"state\" in the \ - journal to \"rolled-back\"", - journal_path.display(), - entry.path().join("backup").display() - ); + out.push((entry.path(), journal)); } } + out.sort_by(|a, b| a.0.cmp(&b.0)); + Ok(out) +} + +fn recovery_steps(app: &Path, dir: &Path, journal: &Journal) -> String { + let pairs: Vec<(String, PathBuf)> = if journal.backups.is_empty() { + journal + .replaced + .iter() + .map(|p| { + ( + p.clone(), + dir.join("backup").join(backup_relative(app, Path::new(p))), + ) + }) + .collect() + } else { + journal + .backups + .iter() + .map(|b| (b.path.clone(), dir.join(&b.backup))) + .collect() + }; + let mut msg = format!( + "an earlier import did not finish (journal {}); some files may already hold \ + imported data while others do not. Restore it manually before importing again:\n \ + 1. copy each backup back over its original path:", + dir.join("journal.json").display() + ); + if pairs.is_empty() { + msg.push_str("\n (none: this import replaced no existing file)"); + } + for (path, backup) in &pairs { + msg.push_str(&format!("\n {} -> {}", backup.display(), path)); + } + msg.push_str("\n 2. delete each file this import created:"); + if journal.created.is_empty() { + msg.push_str("\n (none)"); + } + for path in &journal.created { + msg.push_str(&format!("\n {path}")); + } + msg.push_str("\n 3. set \"state\" in the journal to \"rolled-back\""); + msg +} + +/// Refuse to start while an earlier import never finished: its journal and +/// backup are the only record of what to restore. +pub(crate) fn ensure_no_unfinished_import() -> Result<()> { + let app = crate::auth::app_home()?; + if let Some((dir, journal)) = unfinished_imports()?.into_iter().next() { + anyhow::bail!("{}", recovery_steps(&app, &dir, &journal)); + } Ok(()) } +/// A one-line warning for commands that do not block on an unfinished import +/// (`import --dry-run`, `use`, `launch`, `export`), or `None`. +pub(crate) fn unfinished_import_warning() -> Option { + let found = unfinished_imports().ok()?; + let (dir, _) = found.first()?; + Some(format!( + "an earlier import did not finish (journal {}); profiles and the live auth.json may be inconsistent. Run `codex-switch import ` to see the recovery steps.", + dir.join("journal.json").display() + )) +} + fn ensure_private_dir_tracked(dir: &Path, created: &mut Vec) -> Result<()> { let mut missing = Vec::new(); let mut cursor = Some(dir); @@ -77,8 +146,27 @@ fn ensure_private_dir_tracked(dir: &Path, created: &mut Vec) -> Result<( Ok(()) } +/// After a rename, flush the directory entry too so that, after a power +/// loss, the journal and the files it describes are on disk in write order. +fn sync_parent(path: &Path) { + #[cfg(unix)] + if let Some(parent) = path.parent() + && let Ok(dir) = std::fs::File::open(parent) + { + let _ = dir.sync_all(); + } + #[cfg(not(unix))] + let _ = path; +} + +fn write_durable(path: &Path, bytes: &[u8]) -> Result<()> { + crate::auth::atomic_write_private(path, bytes)?; + sync_parent(path); + Ok(()) +} + fn write_journal(dir: &Path, journal: &Journal) -> Result<()> { - crate::auth::atomic_write_private( + write_durable( &dir.join("journal.json"), &serde_json::to_vec_pretty(journal)?, ) @@ -93,12 +181,18 @@ fn backup_relative(app: &Path, path: &Path) -> PathBuf { #[cfg(debug_assertions)] fn injected_failure(write_index: usize) -> Result<()> { - // Test hook (debug builds only): fail before the Nth write. + // Test hooks (debug builds only): fail, or kill the process without any + // cleanup, before the Nth write. if let Ok(n) = std::env::var("CODEX_SWITCH_TEST_IMPORT_FAIL_AT") && n.parse::().ok() == Some(write_index) { anyhow::bail!("injected import failure at write {write_index}"); } + if let Ok(n) = std::env::var("CODEX_SWITCH_TEST_IMPORT_ABORT_AT") + && n.parse::().ok() == Some(write_index) + { + std::process::abort(); + } Ok(()) } #[cfg(not(debug_assertions))] @@ -141,12 +235,35 @@ pub(crate) fn apply(plan: &ImportPlan) -> Result { let mut scratch = Vec::new(); ensure_private_dir_tracked(&backup_dir, &mut scratch)?; - // Targets: each write, plus the live auth.json when a current profile changes. + // Targets: each write, plus the live auth.json when the plan says the + // current profile's live file is replaced too. Credentials are re-checked + // against what is on disk now (account identity, and the newer-token rule + // unless the user explicitly waived it) before anything is written. let live = crate::auth::codex_auth_path()?; let mut targets: Vec<(PathBuf, &str)> = Vec::new(); for w in &writes { - targets.push((w.key.path_in(&app), w.text)); - if w.item.updates_live { + let path = w.key.path_in(&app); + if matches!(w.key, LogicalKey::ProfileAuth(_)) { + let incoming: serde_json::Value = + serde_json::from_str(w.text.trim_start_matches('\u{feff}')) + .context("parsing imported auth")?; + crate::profile::guard_imported_credentials( + &w.key.render(), + &path, + &incoming, + w.item.allow_older, + )?; + if w.item.replaces_live { + crate::profile::guard_imported_credentials( + "live auth.json", + &live, + &incoming, + w.item.allow_older_live, + )?; + } + } + targets.push((path, w.text)); + if w.item.replaces_live { targets.push((live.clone(), w.text)); } } @@ -154,20 +271,28 @@ pub(crate) fn apply(plan: &ImportPlan) -> Result { let mut journal = Journal { state: "in-progress".into(), replaced: Vec::new(), + backups: Vec::new(), created: Vec::new(), created_dirs: Vec::new(), }; for (path, _) in &targets { if path.exists() { let rel = backup_relative(&app, path); - let dst = backup_dir.join("backup").join(rel); + let dst = backup_dir.join("backup").join(&rel); let raw = std::fs::read(path).with_context(|| format!("backing up {}", path.display()))?; if let Some(parent) = dst.parent() { ensure_private_dir_tracked(parent, &mut scratch)?; } - crate::auth::atomic_write_private(&dst, &raw)?; + write_durable(&dst, &raw)?; journal.replaced.push(path.display().to_string()); + journal.backups.push(BackupEntry { + path: path.display().to_string(), + backup: PathBuf::from("backup") + .join(rel) + .to_string_lossy() + .replace('\\', "/"), + }); } } write_journal(&backup_dir, &journal)?; @@ -183,7 +308,7 @@ pub(crate) fn apply(plan: &ImportPlan) -> Result { journal.created.push(path.display().to_string()); write_journal(&backup_dir, &journal)?; } - crate::auth::atomic_write_private(path, text.as_bytes())?; + write_durable(path, text.as_bytes())?; } Ok(()) })(); diff --git a/src/transfer/crypto.rs b/src/transfer/crypto.rs index caf945b..ab2b5b3 100644 --- a/src/transfer/crypto.rs +++ b/src/transfer/crypto.rs @@ -21,7 +21,10 @@ pub(crate) const DEFAULT_T_COST: u32 = 3; pub(crate) const DEFAULT_P_COST: u32 = 1; const MIN_M_COST_KIB: u32 = 8 * 1024; -const MAX_M_COST_KIB: u32 = 1024 * 1024; +// Upper bounds are checked before deriving and before the passphrase is +// known, so a hostile bundle can cost at most ~4x the default work. +const MAX_M_COST_KIB: u32 = 256 * 1024; +const MAX_T_COST: u32 = 6; const SALT_LEN: usize = 16; const NONCE_LEN: usize = 12; const KEY_CHECK_LEN: usize = 32; @@ -161,10 +164,12 @@ pub(crate) fn check_bounds(enc: &Encryption) -> Result<()> { )) .into()); } - if !(1..=10).contains(&k.t_cost) { - return Err( - CsError::UnsupportedEncryption(format!("t_cost {} outside 1..=10", k.t_cost)).into(), - ); + if !(1..=MAX_T_COST).contains(&k.t_cost) { + return Err(CsError::UnsupportedEncryption(format!( + "t_cost {} outside 1..={MAX_T_COST}", + k.t_cost + )) + .into()); } if !(1..=4).contains(&k.p_cost) { return Err( diff --git a/src/transfer/plan.rs b/src/transfer/plan.rs index a6f5685..4f6fbe6 100644 --- a/src/transfer/plan.rs +++ b/src/transfer/plan.rs @@ -186,12 +186,24 @@ pub(crate) struct PlanItem { #[serde(skip_serializing_if = "Option::is_none")] pub target: Option, pub reason: String, + /// Whether this write also replaces the live Codex auth.json (the item is + /// the current profile and live provably belongs to the same account). + pub replaces_live: bool, + /// Providers: the endpoint the imported settings point at. + #[serde(skip_serializing_if = "Option::is_none")] + pub base_url: Option, + /// Providers: how many raw `codex_config` overrides are passed to Codex. + #[serde(skip_serializing_if = "Option::is_none")] + pub codex_config_overrides: Option, /// Content to write when `action.writes()`. Never serialised. #[serde(skip)] pub write: Option<(LogicalKey, String)>, - /// Whether this write replaces the live Codex auth.json too. + /// The user explicitly accepted replacing credentials that cannot be shown + /// to be older (`--strategy overwrite`, and `--replace-live` for live). + #[serde(skip)] + pub allow_older: bool, #[serde(skip)] - pub updates_live: bool, + pub allow_older_live: bool, } #[derive(Debug, Clone, Serialize)] @@ -205,19 +217,21 @@ pub(crate) struct ImportPlan { impl ImportPlan { pub(crate) fn needs_confirmation(&self) -> bool { - self.items - .iter() - .any(|i| matches!(i.action, Action::Overwrite | Action::Merge)) + self.items.iter().any(|i| { + matches!(i.action, Action::Overwrite | Action::Merge) + || i.replaces_live + || (i.action.writes() && i.codex_config_overrides.is_some_and(|n| n > 0)) + }) } pub(crate) fn has_writes(&self) -> bool { self.items.iter().any(|i| i.action.writes()) } /// A comparable fingerprint of the decisions, to detect that the store /// changed between the confirmation and taking the lock. - pub(crate) fn decisions(&self) -> Vec<(String, Action, Option)> { + pub(crate) fn decisions(&self) -> Vec<(String, Action, Option, bool)> { self.items .iter() - .map(|i| (i.key.clone(), i.action, i.target.clone())) + .map(|i| (i.key.clone(), i.action, i.target.clone(), i.replaces_live)) .collect() } } @@ -311,18 +325,114 @@ fn item( action, target, reason: reason.into(), + replaces_live: false, + base_url: None, + codex_config_overrides: None, write: None, - updates_live: false, + allow_older: false, + allow_older_live: false, } } +/// What the live Codex auth.json is, relative to a current profile. +enum LiveState { + /// The profile is not the current one; live is not touched. + NotCurrent, + /// No live auth.json. + Absent, + /// Live belongs to the imported account. + Same(serde_json::Value), + /// Live is another account (e.g. an untracked `codex login`) or unreadable. + Foreign, +} + +fn strictly_newer(existing: &serde_json::Value, incoming: &serde_json::Value) -> bool { + matches!( + (profile::parse_last_refresh(incoming), profile::parse_last_refresh(existing)), + (Some(i), Some(l)) if i > l + ) +} + +struct BundleProfile<'a> { + key: &'a LogicalKey, + alias: &'a String, + entry: &'a Entry, + value: serde_json::Value, + identity: profile::AccountIdentity, +} + +/// Several aliases in one bundle may carry the same account. Writing each of +/// them would hit the same local target (last write wins) or create two +/// profiles for one account, so each account is written at most once: the +/// entry whose credentials are provably newest wins; with no provable winner +/// none is written. Returns, per bundle alias, `Some(winner alias)` for losers +/// and an `Err(reason)` for unresolvable groups. +fn bundle_duplicates(profiles: &[BundleProfile<'_>]) -> BTreeMap> { + let mut groups: BTreeMap<(Option, Option), Vec> = BTreeMap::new(); + for (idx, p) in profiles.iter().enumerate() { + groups + .entry((p.identity.account_id.clone(), p.identity.email.clone())) + .or_default() + .push(idx); + } + let mut out = BTreeMap::new(); + for members in groups.values().filter(|m| m.len() > 1) { + let identical = members + .iter() + .all(|&i| profiles[i].value == profiles[members[0]].value); + let winner = if identical { + Some(members[0]) + } else { + members.iter().copied().find(|&w| { + members + .iter() + .all(|&o| o == w || strictly_newer(&profiles[o].value, &profiles[w].value)) + }) + }; + let keys = members + .iter() + .map(|&i| profiles[i].entry.key.as_str()) + .collect::>() + .join(", "); + for &i in members { + match winner { + Some(w) if w == i => {} + Some(w) => { + out.insert(profiles[i].alias.clone(), Ok(profiles[w].alias.clone())); + } + None => { + out.insert( + profiles[i].alias.clone(), + Err(format!( + "the same account appears {} times in this bundle ({keys}) and none \ + is provably newest (last_refresh); skipped", + members.len() + )), + ); + } + } + } + } + out +} + /// Validate the whole payload and decide what happens to each entry. Any /// validation failure aborts the import before anything is written. -pub(crate) fn classify(payload: &Payload, strategy: Strategy) -> Result { +pub(crate) fn classify( + payload: &Payload, + strategy: Strategy, + replace_live: bool, +) -> Result { let app = crate::auth::app_home()?; let mut taken = local_taken()?; let locals = local_profiles()?; let local_current = profile::read_current(); + let live_path = crate::auth::codex_auth_path()?; + let live_value = if live_path.exists() { + Some(crate::auth::read_auth(&live_path).ok()) + } else { + None + }; let mut notices = Vec::new(); // Parse and validate every key first, and reject duplicates. @@ -367,7 +477,8 @@ pub(crate) fn classify(payload: &Payload, strategy: Strategy) -> Result = BTreeMap::new(); let mut provider_targets: BTreeMap = BTreeMap::new(); - // Profiles. + // Profiles: validate every entry before deciding anything. + let mut bundle_profiles = Vec::new(); for (key, entry) in &keyed { let LogicalKey::ProfileAuth(alias) = key else { continue; @@ -383,12 +494,54 @@ pub(crate) fn classify(payload: &Payload, strategy: Strategy) -> Result = Vec::new(); + + for bp in &bundle_profiles { + let BundleProfile { + key, + alias, + entry, + value, + identity, + } = bp; + let (key, alias, value) = (*key, *alias, value); + match duplicates.get(alias.as_str()) { + Some(Ok(winner)) => { + items.push(item( + key, + Action::Skip, + None, + format!( + "same account as profiles/{winner}/auth.json in this bundle, which is newer; skipped" + ), + )); + duplicate_of.push((alias.clone(), winner.clone())); + continue; + } + Some(Err(reason)) => { + items.push(item(key, Action::Ambiguous, None, reason.clone())); + continue; + } + None => {} + } + + let exact: Vec<&LocalProfile> = locals + .iter() + .filter(|l| { + l.identity.account_id == identity.account_id + && l.identity.email.is_some() + && l.identity.email == identity.email + }) + .collect(); let email_only: Vec<&LocalProfile> = locals .iter() .filter(|l| { @@ -398,8 +551,24 @@ pub(crate) fn classify(payload: &Payload, strategy: Strategy) -> Result 1 { + items.push(item( + key, + Action::Ambiguous, + None, + format!( + "same account as several local profiles ({}); skipped", + exact + .iter() + .map(|l| l.alias.as_str()) + .collect::>() + .join(", ") + ), + )); + continue; + } + if let Some(local) = exact.first() { + if local.value == *value { items.push(item( key, Action::Unchanged, @@ -409,33 +578,100 @@ pub(crate) fn classify(payload: &Payload, strategy: Strategy) -> Result LiveState::Absent, + Some(Some(v)) if profile::same_account_identity(v, value) => { + LiveState::Same(v.clone()) + } + Some(_) => LiveState::Foreign, + } + }; + let use_hint = format!( + "live auth.json is another account or unreadable and is left untouched; run `codex-switch use {}` to switch", + local.alias + ); + let mut replaces_live = false; + let mut allow_older = false; + let mut allow_older_live = false; let (action, reason) = match strategy { Strategy::Skip => ( Action::Skip, "same account with different credentials; local kept (use --strategy)" .to_string(), ), + Strategy::Merge => { + if !strictly_newer(&local.value, value) { + ( + Action::Skip, + "same account; local credentials are newer or not comparable" + .to_string(), + ) + } else { + match &live { + LiveState::Same(v) if !strictly_newer(v, value) => ( + Action::Skip, + "same account; the live auth.json of this current profile is newer or not comparable; local kept" + .to_string(), + ), + LiveState::Same(_) => { + replaces_live = true; + ( + Action::Overwrite, + "same account; imported credentials are newer than the profile and the live auth.json (last_refresh)".to_string(), + ) + } + LiveState::Foreign => ( + Action::Overwrite, + format!( + "same account; imported credentials are newer (last_refresh); {use_hint}" + ), + ), + LiveState::Absent | LiveState::NotCurrent => ( + Action::Overwrite, + "same account; imported credentials are newer (last_refresh)" + .to_string(), + ), + } + } + } Strategy::Overwrite => { + allow_older = true; let mut reason = "same account; imported credentials replace local".to_string(); - if matches!((imported_ts, local_ts), (Some(i), Some(l)) if i < l) { + if !profile::credentials_not_older(&local.value, value) { reason.push_str( - " (warning: imported last_refresh is older and may hold an already-rotated refresh token)", + " (warning: imported credentials cannot be shown to be newer (last_refresh) and the local refresh token may already be the only valid one; it is kept in the backup)", ); } - (Action::Overwrite, reason) + match &live { + LiveState::Same(v) if profile::credentials_not_older(v, value) => { + replaces_live = true; + reason.push_str("; also replaces the live auth.json"); + (Action::Overwrite, reason) + } + LiveState::Same(_) if replace_live => { + replaces_live = true; + allow_older_live = true; + reason.push_str( + "; also replaces the live auth.json although it cannot be shown to be older (--replace-live; kept in the backup)", + ); + (Action::Overwrite, reason) + } + LiveState::Same(_) => ( + Action::Skip, + "warning: same account, but the live auth.json of this current profile cannot be shown to be older than the imported credentials; local kept (rerun with --replace-live to replace it anyway)" + .to_string(), + ), + LiveState::Foreign => { + reason.push_str("; "); + reason.push_str(&use_hint); + (Action::Overwrite, reason) + } + LiveState::Absent | LiveState::NotCurrent => (Action::Overwrite, reason), + } } - Strategy::Merge => match (imported_ts, local_ts) { - (Some(i), Some(l)) if i > l => ( - Action::Overwrite, - "same account; imported credentials are newer (last_refresh)".to_string(), - ), - _ => ( - Action::Skip, - "same account; local credentials are newer or not comparable".to_string(), - ), - }, }; let mut it = item(key, action, Some(local.alias.clone()), reason); if action.writes() { @@ -443,7 +679,9 @@ pub(crate) fn classify(payload: &Payload, strategy: Strategy) -> Result Result Result Result Result String { + read_json(&home.join(".codex/auth.json"))["tokens"]["refresh_token"] + .as_str() + .unwrap() + .to_string() +} + +fn profile_refresh(home: &Path, alias: &str) -> String { + read_json(&app(home).join("profiles").join(alias).join("auth.json"))["tokens"]["refresh_token"] + .as_str() + .unwrap() + .to_string() +} + +fn item_of(v: &Value, key: &str) -> Value { + v["items"] + .as_array() + .unwrap() + .iter() + .find(|i| i["key"] == key) + .cloned() + .unwrap_or_else(|| panic!("no item {key} in {v}")) +} + +fn without_last_refresh(mut value: Value) -> Value { + value.as_object_mut().unwrap().remove("last_refresh"); + value +} + +/// Machine B where `work` is current, its saved profile is old (10-01) and +/// Codex has since refreshed the live auth.json (10-18) without syncing back. +/// Machine A's bundle carries `work` from 10-10: newer than the profile, +/// older than live. +fn machine_b_live_newer() -> PathBuf { + let b = machine_b_with_conflict("2026-10-01T00:00:00Z"); + write(app(&b).join("current"), "work"); + put_live( + &b, + &auth( + "work@example.com", + "acct-work", + "work-live", + "2026-10-18T00:00:00Z", + ), + ); + b +} + +// R1-H1a: merge must compare against live, not only the profile file. +#[test] +fn r1_merge_never_replaces_newer_live_credentials() { + let (_a, bundle) = bundle_of("r1h1a"); + let b = machine_b_live_newer(); + let (out, v) = json_import(&b, &bundle, &["--strategy", "merge", "--yes"], PASS); + assert!(out.status.success(), "{}", text(&out)); + assert_eq!(live_refresh(&b), "refresh-work-live", "{v}"); + assert_eq!(action_of(&v, "profiles/work/auth.json"), "skip", "{v}"); + assert_eq!(profile_refresh(&b, "work"), "refresh-work-b"); +} + +// R1-H1b: overwrite must not silently replace live credentials that cannot be +// shown to be older than the imported ones; --replace-live opts in. +#[test] +fn r1_overwrite_keeps_newer_live_unless_replace_live() { + let (_a, bundle) = bundle_of("r1h1b"); + let b = machine_b_live_newer(); + let (out, v) = json_import(&b, &bundle, &["--strategy", "overwrite", "--yes"], PASS); + assert!(out.status.success(), "{}", text(&out)); + assert_eq!(live_refresh(&b), "refresh-work-live", "{v}"); + let it = item_of(&v, "profiles/work/auth.json"); + assert_eq!(it["replaces_live"], false, "{it}"); + + let b = machine_b_live_newer(); + let (out, v) = json_import( + &b, + &bundle, + &["--strategy", "overwrite", "--yes", "--replace-live"], + PASS, + ); + assert!(out.status.success(), "{}", text(&out)); + assert_eq!(live_refresh(&b), "refresh-work-a", "{v}"); + assert_eq!(profile_refresh(&b, "work"), "refresh-work-a"); + let it = item_of(&v, "profiles/work/auth.json"); + assert_eq!(it["action"], "overwrite", "{it}"); + assert_eq!(it["replaces_live"], true, "{it}"); + // The newest live token survives in the backup. + let backup = PathBuf::from(v["backup_dir"].as_str().unwrap()); + assert_eq!( + read_json(&backup.join("backup/live/auth.json"))["tokens"]["refresh_token"], + "refresh-work-live" + ); +} + +// R1-H1c: with no last_refresh on either side, an overwrite of a different +// refresh_token is flagged in the plan and does not touch live by default. +#[test] +fn r1_overwrite_without_timestamps_is_flagged() { + let a = temp_home("a"); + put_profile( + &a, + "work", + &without_last_refresh(auth("work@example.com", "acct-work", "work-a", "")), + ); + let bundle = temp_home("r1h1c").join("out.csbundle"); + export(&a, &bundle); + let b = temp_home("b"); + let local = without_last_refresh(auth("work@example.com", "acct-work", "work-b", "")); + put_profile(&b, "work", &local); + write(app(&b).join("current"), "work"); + put_live(&b, &local); + + let (out, v) = json_import(&b, &bundle, &["--strategy", "overwrite", "--dry-run"], PASS); + assert!(out.status.success(), "{}", text(&out)); + let it = item_of(&v, "profiles/work/auth.json"); + assert!( + it["reason"] + .as_str() + .unwrap() + .to_lowercase() + .contains("warning"), + "{it}" + ); + assert_eq!(it["replaces_live"], false, "{it}"); + + let (out, _) = json_import(&b, &bundle, &["--strategy", "overwrite", "--yes"], PASS); + assert!(out.status.success(), "{}", text(&out)); + assert_eq!(live_refresh(&b), "refresh-work-b"); +} + +// R1-H1d: when an import does replace live, the plan says so before writing. +#[test] +fn r1_plan_reports_live_replacement() { + let (_a, bundle) = bundle_of("r1h1d"); + let b = machine_b_with_conflict("2026-10-01T00:00:00Z"); + write(app(&b).join("current"), "work"); + let local = read_json(&app(&b).join("profiles/work/auth.json")); + put_live(&b, &local); + let (out, v) = json_import(&b, &bundle, &["--strategy", "merge", "--dry-run"], PASS); + assert!(out.status.success(), "{}", text(&out)); + let it = item_of(&v, "profiles/work/auth.json"); + assert_eq!(it["replaces_live"], true, "{it}"); + assert_eq!(live_refresh(&b), "refresh-work-b"); + // Text output names the live file too. + let out = import(&b, &bundle, &["--strategy", "merge", "--dry-run"], PASS); + assert!( + String::from_utf8_lossy(&out.stdout).contains("live auth.json"), + "{}", + text(&out) + ); +} + +// R1-H2: live belongs to an untracked other account -> never overwritten. +#[test] +fn r1_live_of_another_account_is_never_overwritten() { + let (_a, bundle) = bundle_of("r1h2"); + let b = machine_b_with_conflict("2026-10-01T00:00:00Z"); + write(app(&b).join("current"), "work"); + let stranger = auth( + "stranger@example.com", + "acct-stranger", + "stranger", + "2026-10-18T00:00:00Z", + ); + put_live(&b, &stranger); + let (out, v) = json_import(&b, &bundle, &["--strategy", "overwrite", "--yes"], PASS); + assert!(out.status.success(), "{}", text(&out)); + assert_eq!(read_json(&b.join(".codex/auth.json")), stranger, "{v}"); + // The saved profile itself is still updated as requested. + assert_eq!(profile_refresh(&b, "work"), "refresh-work-a"); + let it = item_of(&v, "profiles/work/auth.json"); + assert_eq!(it["replaces_live"], false, "{it}"); + + // --activate must not silently replace an untracked live login either. + let b = temp_home("b"); + put_live(&b, &stranger); + let (out, _) = json_import(&b, &bundle, &["--activate"], PASS); + assert!(!out.status.success(), "{}", text(&out)); + assert_eq!(read_json(&b.join(".codex/auth.json")), stranger); +} + +// R1-M1: one account under two aliases in a bundle writes one target once, +// with the newer credentials. +#[test] +fn r1_duplicate_account_in_bundle_is_written_once() { + let newer = serde_json::to_string(&auth( + "work@example.com", + "acct-work", + "dupA", + "2026-10-05T00:00:00Z", + )) + .unwrap(); + let older = serde_json::to_string(&auth( + "work@example.com", + "acct-work", + "dupB", + "2026-10-02T00:00:00Z", + )) + .unwrap(); + let bundle = plaintext_bundle(&[ + ("profiles/x1/auth.json", "profile_auth", &newer), + ("profiles/x2/auth.json", "profile_auth", &older), + ]); + + let b = machine_b_with_conflict("2026-10-01T00:00:00Z"); + let out = run( + &b, + &[ + "--json", + "import", + bundle.to_str().unwrap(), + "--strategy", + "overwrite", + "--yes", + ], + None, + ); + assert!(out.status.success(), "{}", text(&out)); + let v: Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(profile_refresh(&b, "work"), "refresh-dupA", "{v}"); + let writes = v["items"] + .as_array() + .unwrap() + .iter() + .filter(|i| { + ["create", "rename", "overwrite", "merge"].contains(&i["action"].as_str().unwrap()) + }) + .count(); + assert_eq!(writes, 1, "{v}"); + + // Into an empty machine: one profile, not two copies of one account. + let b = temp_home("b"); + let out = run( + &b, + &["--json", "import", bundle.to_str().unwrap(), "--yes"], + None, + ); + assert!(out.status.success(), "{}", text(&out)); + let profiles: Vec<_> = fs::read_dir(app(&b).join("profiles")) + .unwrap() + .flatten() + .collect(); + assert_eq!(profiles.len(), 1, "{}", text(&out)); + assert_eq!(profile_refresh(&b, "x1"), "refresh-dupA"); +} + +const EVIL_PROVIDER: &str = r#"identity_id = "22222222-2222-4222-8222-222222222222" +provider_id = "evil" +name = "evil" +base_url = "https://evil.example.invalid/v1" +env_key = "CS_TEST_PROVIDER_KEY" +default_model = "m1" +api_key = "sk-attacker" +codex_config = ['notify=["sh","-c","true"]'] + +[[models]] +id = "m1" +"#; + +// R1-M3: a plaintext bundle is unauthenticated, so importing one needs the +// same explicit confirmation as writing one; provider overrides are shown. +#[test] +fn r1_plaintext_import_requires_confirmation() { + let good = serde_json::to_string(&auth( + "ok@example.com", + "acct-ok", + "ok", + "2026-10-01T00:00:00Z", + )) + .unwrap(); + let bundle = plaintext_bundle(&[ + ("profiles/ok/auth.json", "profile_auth", &good), + ("providers/evil/provider.toml", "provider", EVIL_PROVIDER), + ]); + let b = temp_home("b"); + let before = tree(&b); + for args in [ + &["import", bundle.to_str().unwrap()][..], + &["--json", "import", bundle.to_str().unwrap()][..], + ] { + let out = run(&b, args, None); + assert!(!out.status.success(), "{}", text(&out)); + assert_eq!(before, tree(&b), "{}", text(&out)); + } + + let out = run( + &b, + &["--json", "import", bundle.to_str().unwrap(), "--dry-run"], + None, + ); + assert!(out.status.success(), "{}", text(&out)); + let v: Value = serde_json::from_slice(&out.stdout).unwrap(); + let it = item_of(&v, "providers/evil/provider.toml"); + assert_eq!(it["base_url"], "https://evil.example.invalid/v1", "{it}"); + assert_eq!(it["codex_config_overrides"], 1, "{it}"); + assert!(!text(&out).contains("sk-attacker")); + + let out = run(&b, &["import", bundle.to_str().unwrap(), "--yes"], None); + assert!(out.status.success(), "{}", text(&out)); + assert!(app(&b).join("profiles/ok/auth.json").exists()); +} + +// R1-M3b: an encrypted bundle that adds a provider with codex_config overrides +// also needs confirmation. +#[test] +fn r1_provider_overrides_need_confirmation() { + let a = temp_home("a"); + write(app(&a).join("providers/evil/provider.toml"), EVIL_PROVIDER); + let bundle = temp_home("r1m3b").join("out.csbundle"); + export(&a, &bundle); + let b = temp_home("b"); + let before = tree(&b); + let (out, _) = json_import(&b, &bundle, &[], PASS); + assert!(!out.status.success(), "{}", text(&out)); + assert_eq!(before, tree(&b)); + let (out, _) = json_import(&b, &bundle, &["--yes"], PASS); + assert!(out.status.success(), "{}", text(&out)); + assert!(app(&b).join("providers/evil/provider.toml").exists()); +} + +// R1-M2: a process killed mid-import leaves a journal that maps every replaced +// file to its backup, the recovery message lists the pairs, and other +// commands warn about it. +#[test] +fn r1_interrupted_import_is_recoverable() { + let (_a, bundle) = bundle_of("r1m2"); + let b = machine_b_with_conflict("2026-10-01T00:00:00Z"); + write(app(&b).join("current"), "work"); + let local = read_json(&app(&b).join("profiles/work/auth.json")); + put_live(&b, &local); + let mut cmd = command( + &b, + &[ + "import", + bundle.to_str().unwrap(), + "--passphrase-stdin", + "--strategy", + "overwrite", + "--yes", + ], + ); + // Hard-kill (abort) right before the 3rd write (personal created, work + // profile replaced, live not yet): the state SIGKILL left in review. + cmd.env("CODEX_SWITCH_TEST_IMPORT_ABORT_AT", "3"); + let out = run_with_stdin(cmd, &format!("{PASS}\n")); + assert!(!out.status.success(), "{}", text(&out)); + + let backups = app(&b).join("import-backups"); + let dir = fs::read_dir(&backups) + .unwrap() + .next() + .unwrap() + .unwrap() + .path(); + let journal = read_json(&dir.join("journal.json")); + assert_eq!(journal["state"], "in-progress", "{journal}"); + let entries = journal["backups"].as_array().expect("backups mapping"); + assert!(!entries.is_empty(), "{journal}"); + for e in entries { + let backup = dir.join(e["backup"].as_str().unwrap()); + assert!(backup.is_file(), "{e}"); + assert!(Path::new(e["path"].as_str().unwrap()).is_absolute(), "{e}"); + } + + // The next import refuses and names each original path with its backup. + let out = import(&b, &bundle, &["--strategy", "overwrite", "--yes"], PASS); + assert!(!out.status.success()); + let err = text(&out); + for e in entries { + assert!(err.contains(e["path"].as_str().unwrap()), "{err}"); + assert!( + err.contains( + &dir.join(e["backup"].as_str().unwrap()) + .display() + .to_string() + ), + "{err}" + ); + } + // Dry-run and `use` still work but warn. + for args in [ + &[ + "import", + bundle.to_str().unwrap(), + "--passphrase-stdin", + "--dry-run", + ][..], + &["use", "work"][..], + ] { + let out = run(&b, args, Some(&format!("{PASS}\n"))); + assert!( + String::from_utf8_lossy(&out.stderr).contains("did not finish"), + "{args:?}: {}", + text(&out) + ); + } +}