diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index f90108b..39cbcbb 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -38,9 +38,6 @@ jobs: - name: Rust tests run: cargo test --manifest-path rust/Cargo.toml - - name: Rust legacy parity tests - run: cargo test --manifest-path rust/Cargo.toml --test legacy_parity - - name: Rust security regression tests run: cargo test --manifest-path rust/Cargo.toml --test security_regressions diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1aeac12..17e495e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,7 +41,6 @@ jobs: cargo fmt --manifest-path rust/Cargo.toml -- --check cargo clippy --manifest-path rust/Cargo.toml --all-targets -- -D warnings cargo test --manifest-path rust/Cargo.toml - cargo test --manifest-path rust/Cargo.toml --test legacy_parity cargo test --manifest-path rust/Cargo.toml --test security_regressions - name: Build release binary diff --git a/README.md b/README.md index 599c807..7484595 100644 --- a/README.md +++ b/README.md @@ -147,14 +147,9 @@ apw login https://example.com Machine-readable build metadata is available via `apw version` and `apw version --json`. -Legacy migration commands remain available in the repo: - -```bash -apw start -apw auth -apw pw -apw host doctor --json -``` +Legacy daemon commands (`apw start`, `apw auth`, `apw pw`, and `apw otp`) have +been removed from the active CLI contract. Use `apw app launch`, +`apw login `, `apw fill `, and `apw doctor` for supported v2 flows. `apw otp` has no v2 replacement and is removed from the Rust CLI. See [`docs/MIGRATION_AND_PARITY.md`](docs/MIGRATION_AND_PARITY.md) for the @@ -174,7 +169,7 @@ Security and release validation guidance: ## Repository layout -- [`rust/`](rust/): supported CLI, legacy daemon, migration scaffolding, and packaging target +- [`rust/`](rust/): supported CLI, app-broker migration scaffolding, and packaging target - `native-app/`: v2 bootstrap macOS app bundle and local broker service - `native-host/`: legacy macOS companion host from the parity line - [`browser-bridge/`](browser-bridge/): legacy bridge retained only during migration diff --git a/docs/ARCHIVE_POLICY.md b/docs/ARCHIVE_POLICY.md index 383f5c3..fcb3900 100644 --- a/docs/ARCHIVE_POLICY.md +++ b/docs/ARCHIVE_POLICY.md @@ -2,13 +2,13 @@ ## Canonical archive path -The full archived, non-maintained implementation is at: +The archived, non-maintained implementations are at: - `legacy/deno/` (repo path) - -The browser bridge is also archived in place: - -- `browser-bridge/` (repo path) +- `legacy/browser-bridge/` (repo path) +- `legacy/native-host/` (repo path) +- `legacy/rust-src/` (repo path, old daemon/browser/native-host internals) +- `legacy/scripts/` (repo path, old browser/native-host helper scripts) ## Purpose of archive @@ -19,11 +19,13 @@ The browser bridge is also archived in place: ## Maintenance rules - `legacy/deno/` is read-only by default. -- `browser-bridge/` is read-only by default and carries an in-directory +- `legacy/browser-bridge/` is read-only by default and carries an in-directory `ARCHIVED` tombstone. +- `legacy/native-host/`, `legacy/rust-src/`, and `legacy/scripts/` are read-only + by default. - No feature work or new behavior should be introduced there. - CI, lint, build, and packaging should target `rust/` only. -- Use `legacy/deno/` or `browser-bridge/` only for: +- Use `legacy/` only for: - behavior audits, - historical diffing, - explicit, manual compatibility re-runs. @@ -33,13 +35,14 @@ The browser bridge is also archived in place: - Never treat archive behavior as a source of truth for releases. - Do not apply dependency or security hardening changes only in the archive. - All release gates, changelog updates, and bug fixes must land in Rust. -- `CODEOWNERS` explicitly owns `browser-bridge/` so archive changes require - code-owner review even though the bridge has no active product CI. +- `CODEOWNERS` explicitly owns archive paths so changes require code-owner + review even though they have no active product CI. ## First-run policy (for maintainers) -- If you are running this project as an active codebase, use `rust/` CLI and daemon paths. -- Ignore `legacy/deno/` unless you are explicitly performing a compatibility audit. +- If you are running this project as an active codebase, use `rust/` CLI and + `native-app/` broker paths. +- Ignore `legacy/` unless you are explicitly performing a compatibility audit. - On first run of a new checkout, execute the normal Rust workflow first: - `cargo fmt --manifest-path rust/Cargo.toml -- --check` - `cargo clippy --manifest-path rust/Cargo.toml --all-targets -- -D warnings` diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index b6de7cd..a1605ae 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -216,13 +216,9 @@ Important v2 fields: - `app.service.running` - `app.service.transport` - `app.service.live` -- `session` -- `daemon` -- `host` -- `bridge` -The legacy daemon/host/bridge sections remain in the payload for migration and -diagnostics, but the new primary health model is app-first. +Legacy daemon/host/bridge diagnostics were archived for v2.1.0; current +`status --json` reports the native app broker surface directly. ## Development and release checks @@ -237,9 +233,8 @@ cargo build --manifest-path rust/Cargo.toml --release ./scripts/build-native-app.sh ``` -Optional parity and release helpers: +Optional release helper: ```bash -cargo test --manifest-path rust/Cargo.toml --test legacy_parity ./scripts/release-bootstrap.sh ``` diff --git a/docs/MIGRATION_AND_PARITY.md b/docs/MIGRATION_AND_PARITY.md index 194392d..eea139c 100644 --- a/docs/MIGRATION_AND_PARITY.md +++ b/docs/MIGRATION_AND_PARITY.md @@ -10,19 +10,18 @@ Release reference version: `v2.0.0` - Supported v2 implementation: [`rust/`](../rust/) + `native-app/` - Archived implementation: [`legacy/deno/`](../legacy/deno/) +- Archived browser/helper implementation: + [`legacy/browser-bridge/`](../legacy/browser-bridge/), + [`legacy/native-host/`](../legacy/native-host/), and + [`legacy/rust-src/`](../legacy/rust-src/) - Packaging, release, fixes, and hardening land in the Rust CLI and native app -- Legacy daemon/browser-helper code remains in-tree for migration only -- Legacy daemon commands (`apw start`, `apw auth`, and `apw pw`) emit runtime - deprecation warnings and are targeted for removal in `v2.1.0`. -- `apw otp` has already been removed from the Rust CLI. There is no v2 - replacement. +- Legacy daemon/browser-helper code is no longer in the active Rust module tree + and is preserved only for historical audit work -## Planned removals +## Removed commands -The following CLI subcommands are part of the legacy daemon path and are -scheduled for removal in **v2.1.0**. As of `v2.0.0` they emit a one-line -stderr deprecation warning at startup (suppressed in `--json` mode) and -their `--help` output is prefixed with a `DEPRECATED:` banner. (issue #9) +The following legacy daemon CLI subcommands were removed from the active Rust +CLI for the `v2.1.0` cliff: | Subcommand | Replacement | | ------------ | ---------------------------- | @@ -30,8 +29,8 @@ their `--help` output is prefixed with a `DEPRECATED:` banner. (issue #9) | `apw pw` | `apw login` / `apw fill` | | `apw auth` | (no v2 replacement; v2 broker is app-mediated) | -Operators with scripts pinned to these commands should migrate before -upgrading to v2.1.0. +Operators with scripts pinned to these commands must migrate before upgrading to +v2.1.0. ## OTP no-go decision @@ -81,21 +80,14 @@ cargo clippy --manifest-path rust/Cargo.toml --all-targets -- -D warnings cargo test --manifest-path rust/Cargo.toml --all-targets ``` -Legacy parity harness: - -```bash -cargo test --manifest-path rust/Cargo.toml --test legacy_parity -``` - ## Release expectations Before tagging a public release: 1. Keep versioned surfaces in sync 2. Run the Rust gates -3. Run the parity harness as a migration safeguard -4. Run the security regression matrix -5. Build the app bundle with `./scripts/build-native-app.sh` +3. Run the security regression matrix +4. Build the app bundle with `./scripts/build-native-app.sh` Related docs: diff --git a/docs/NATIVE_MIGRATION.md b/docs/NATIVE_MIGRATION.md index 2250c06..5666e52 100644 --- a/docs/NATIVE_MIGRATION.md +++ b/docs/NATIVE_MIGRATION.md @@ -10,16 +10,16 @@ browser-helper vault reader flow. | Legacy command | v2 status | Replacement | | --- | --- | --- | -| `apw auth` | legacy-only | `apw app launch` then `apw login ` | -| `apw auth request` | legacy-only | no direct replacement | -| `apw auth response` | legacy-only | no direct replacement | -| `apw pw list` | legacy-only | no replacement in v2 | -| `apw pw get ` | legacy-only | `apw login ` | -| `apw otp list` | removed | no replacement in v2 | -| `apw otp get ` | removed | no replacement in v2 | +| `apw auth` | removed in v2.1.0 | `apw app launch` then `apw login ` | +| `apw auth request` | removed in v2.1.0 | no direct replacement | +| `apw auth response` | removed in v2.1.0 | no direct replacement | +| `apw pw list` | removed in v2.1.0 | no replacement in v2 | +| `apw pw get ` | removed in v2.1.0 | `apw login ` | +| `apw otp list` | removed in v2.1.0 | no replacement in v2 | +| `apw otp get ` | removed in v2.1.0 | no replacement in v2 | | `apw status` | supported | `apw status --json` now reports app/broker readiness | -| `apw host doctor` | legacy-only | `apw doctor` | -| `apw start` | legacy-only | `apw app launch` | +| `apw host doctor` | archived in v2.1.0 | `apw doctor` | +| `apw start` | removed in v2.1.0 | `apw app launch` | ## Bootstrap Flow @@ -34,9 +34,9 @@ browser-helper vault reader flow. - `v1.x` remains the historical parity line for browser-helper behavior. - The v2 bootstrap currently supports one demo associated domain: `https://example.com` -- Legacy `auth` and `pw` commands remain in the repo for migration and - reference, but they are no longer the primary contract. -- `apw otp` is removed from the Rust CLI. Apple's public +- Legacy `auth`, `pw`, `otp`, and `start` commands are no longer available in + the active CLI. The archived parity line remains under `legacy/`. +- Apple's public AuthenticationServices path supports app-mediated password credentials for sign-in and OTP AutoFill provider extensions that supply codes to the system; it does not provide an APW-style API for a CLI to retrieve arbitrary diff --git a/docs/NATIVE_ONLY_REDESIGN.md b/docs/NATIVE_ONLY_REDESIGN.md index 92f0194..666afaf 100644 --- a/docs/NATIVE_ONLY_REDESIGN.md +++ b/docs/NATIVE_ONLY_REDESIGN.md @@ -145,11 +145,11 @@ Requirements: | `apw auth request` | remove | private-helper flow goes away | | `apw auth response` | remove | private-helper flow goes away | | `apw pw list` | remove | unsupported as a vault-wide native API contract | -| `apw pw get ` | deprecate then replace | map to `apw login https://` | -| `apw otp list` | removed | no v2 replacement; public Apple APIs do not expose arbitrary CLI retrieval | -| `apw otp get` | removed | no v2 replacement; public Apple APIs do not expose arbitrary CLI retrieval | +| `apw pw get ` | remove | use `apw login https://` | +| `apw otp list` | remove | unsupported until proven otherwise | +| `apw otp get` | remove | unsupported until a native verification-code path is proven | | `apw status` | keep | report app/XPC/entitlement readiness | -| `apw start` | remove or repurpose | native app launch replaces daemon start | +| `apw start` | remove | native app launch replaces daemon start | ## Implementation phases @@ -271,19 +271,18 @@ Deliverables: ### Rust changes - add an app/XPC client module -- deprecate daemon/helper-specific runtime modes in CLI help - introduce a new command family: - `apw app install` - `apw app launch` - `apw doctor` - `apw login ` -### Code to archive after cutover +### Archived after cutover -- `browser-bridge/` -- native-host private-helper bridge code -- helper manifest install scripts -- launchd/direct helper runtime modes +- `legacy/browser-bridge/` +- `legacy/native-host/` +- `legacy/scripts/` +- `legacy/rust-src/` for the former daemon/browser/native-host internals ## Security requirements @@ -337,8 +336,7 @@ These are product decisions, not implementation bugs. 1. Create `docs/NATIVE_MIGRATION.md` with a command-by-command migration matrix. 2. Create `native-app/` with the minimal signed app skeleton. -3. Prototype `apw status` against a local app presence check instead of the - current native-host helper flow. +3. Keep `apw status` focused on the local app broker presence check. 4. Spike one supported credential request flow for a single associated domain. 5. Decide whether `v2.0.0` is a hard product break or ships with a temporary compatibility shim for `pw get`. diff --git a/docs/SECURITY_POSTURE_AND_TESTING.md b/docs/SECURITY_POSTURE_AND_TESTING.md index c6b4f14..b68aa21 100644 --- a/docs/SECURITY_POSTURE_AND_TESTING.md +++ b/docs/SECURITY_POSTURE_AND_TESTING.md @@ -28,7 +28,8 @@ Release reference version: `v2.0.0` ### Runtime broker hardening - the v2 app broker uses a same-user local UNIX socket under `~/.apw/native-app/` -- `status --json` exposes app/broker readiness while retaining legacy daemon diagnostics +- `status --json` exposes native app broker readiness; legacy + daemon/host/bridge diagnostics are archived under `legacy/` - requests and responses use typed JSON envelopes with bounded payload sizes - bootstrap credentials are read from a local runtime file for the supported demo domain only; the app does not create that plaintext file on default @@ -58,7 +59,6 @@ Run these before publishing: cargo fmt --manifest-path rust/Cargo.toml -- --check cargo clippy --manifest-path rust/Cargo.toml --all-targets -- -D warnings cargo test --manifest-path rust/Cargo.toml --all-targets -cargo test --manifest-path rust/Cargo.toml --test legacy_parity cargo test --manifest-path rust/Cargo.toml --test native_app_e2e cargo build --manifest-path rust/Cargo.toml --release ./scripts/build-native-app.sh @@ -70,7 +70,7 @@ The Rust test suite covers: - invalid PIN rejection before transport use - invalid URL rejection before auth dependency -- stable JSON status shape +- stable native app JSON status shape - launch failure precedence over session errors - malformed or oversized payload rejection - native app socket timeout handling @@ -111,7 +111,8 @@ but the APW surfaces around it remain in scope. ## Archive policy -The Deno implementation is archived and not part of the supported security -surface. Use it only for compatibility audit work. +The historical Deno, browser bridge, native host, and legacy Rust helper +implementations are archived and not part of the supported security surface. +Use them only for compatibility audit work. Archive rules: [ARCHIVE_POLICY.md](ARCHIVE_POLICY.md) diff --git a/browser-bridge/ARCHIVED b/legacy/browser-bridge/ARCHIVED similarity index 100% rename from browser-bridge/ARCHIVED rename to legacy/browser-bridge/ARCHIVED diff --git a/browser-bridge/background.js b/legacy/browser-bridge/background.js similarity index 100% rename from browser-bridge/background.js rename to legacy/browser-bridge/background.js diff --git a/browser-bridge/bridge-controller.js b/legacy/browser-bridge/bridge-controller.js similarity index 100% rename from browser-bridge/bridge-controller.js rename to legacy/browser-bridge/bridge-controller.js diff --git a/browser-bridge/bridge-controller.test.js b/legacy/browser-bridge/bridge-controller.test.js similarity index 100% rename from browser-bridge/bridge-controller.test.js rename to legacy/browser-bridge/bridge-controller.test.js diff --git a/browser-bridge/manifest.json b/legacy/browser-bridge/manifest.json similarity index 100% rename from browser-bridge/manifest.json rename to legacy/browser-bridge/manifest.json diff --git a/browser-bridge/package.json b/legacy/browser-bridge/package.json similarity index 100% rename from browser-bridge/package.json rename to legacy/browser-bridge/package.json diff --git a/browser-bridge/popup.html b/legacy/browser-bridge/popup.html similarity index 100% rename from browser-bridge/popup.html rename to legacy/browser-bridge/popup.html diff --git a/browser-bridge/popup.js b/legacy/browser-bridge/popup.js similarity index 100% rename from browser-bridge/popup.js rename to legacy/browser-bridge/popup.js diff --git a/native-host/Package.swift b/legacy/native-host/Package.swift similarity index 100% rename from native-host/Package.swift rename to legacy/native-host/Package.swift diff --git a/native-host/Sources/main.swift b/legacy/native-host/Sources/main.swift similarity index 100% rename from native-host/Sources/main.swift rename to legacy/native-host/Sources/main.swift diff --git a/rust/src/client.rs b/legacy/rust-src/client.rs similarity index 99% rename from rust/src/client.rs rename to legacy/rust-src/client.rs index f11aaa4..ac5a847 100644 --- a/rust/src/client.rs +++ b/legacy/rust-src/client.rs @@ -38,7 +38,7 @@ const LAUNCH_STATUS_FAILED: &str = "failed"; const LAUNCH_STATUS_DISABLED: &str = "disabled"; const LAUNCH_NOT_RUNNING_MESSAGE: &str = "Helper process is not running."; const UNAUTHENTICATED_DAEMON_MESSAGE: &str = - "Daemon is running but not authenticated. Run `apw auth`."; + "Daemon is running but not authenticated. Use `apw app launch` and `apw login `."; #[derive(Clone, Copy)] pub struct ClientSendOpts { @@ -780,7 +780,7 @@ impl ApplePasswordManager { { UNAUTHENTICATED_DAEMON_MESSAGE } else { - "No active session. Start the daemon with `apw start`, then run `apw auth`." + "No active session. Use `apw app launch` and `apw login ` through the native app broker." }, )) } diff --git a/rust/src/daemon.rs b/legacy/rust-src/daemon.rs similarity index 99% rename from rust/src/daemon.rs rename to legacy/rust-src/daemon.rs index a7a4718..c598640 100644 --- a/rust/src/daemon.rs +++ b/legacy/rust-src/daemon.rs @@ -2599,7 +2599,13 @@ mod tests { socket.send_to(&payload, ("127.0.0.1", port)).unwrap(); let mut buffer = vec![0_u8; 4096]; - let size = socket.recv(&mut buffer).unwrap(); + let size = loop { + match socket.recv(&mut buffer) { + Ok(size) => break size, + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue, + Err(error) => panic!("failed to receive daemon response: {error}"), + } + }; serde_json::from_slice(&buffer[..size]).unwrap() } diff --git a/rust/src/host.rs b/legacy/rust-src/host.rs similarity index 99% rename from rust/src/host.rs rename to legacy/rust-src/host.rs index 61b5d6a..f524250 100644 --- a/rust/src/host.rs +++ b/legacy/rust-src/host.rs @@ -461,13 +461,13 @@ pub fn native_host_failure_message(base_message: &str) -> String { let guidance = match status { "app_missing" | "launch_agent_missing" | "launch_agent_unloaded" => { - "Run `apw host install`, then `apw host doctor`, then `apw start`." + "Run `apw host install`, then `apw host doctor`; use `apw app launch` for the supported v2 broker." } "helper_missing" => { "The Apple helper is unavailable on this host; run `apw host doctor` for details." } "ready" => { - "Run `apw host doctor` and ensure the native host stays attached after `apw start`." + "Run `apw host doctor`; use `apw app launch` for the supported v2 broker." } _ => "Run `apw host doctor` for native host diagnostics.", }; @@ -840,7 +840,7 @@ mod tests { let message = native_host_failure_message("Base failure."); assert!(message - .contains("Run `apw host install`, then `apw host doctor`, then `apw start`.")); + .contains("Run `apw host install`, then `apw host doctor`; use `apw app launch`")); assert!(message.contains("daemon.preflight.status=app_missing")); }); } diff --git a/rust/src/srp.rs b/legacy/rust-src/srp.rs similarity index 97% rename from rust/src/srp.rs rename to legacy/rust-src/srp.rs index 0163985..ae4754c 100644 --- a/rust/src/srp.rs +++ b/legacy/rust-src/srp.rs @@ -290,7 +290,7 @@ impl SRPSession { let shared_key = self.shared_key.as_ref().ok_or_else(|| { APWError::new( Status::InvalidSession, - "Missing encryption key. Reauthenticate with `apw auth`.", + "Missing encryption key. Use `apw app launch` and `apw login ` through the native app broker.", ) })?; @@ -321,7 +321,7 @@ impl SRPSession { let shared_key = self.shared_key.as_ref().ok_or_else(|| { APWError::new( Status::InvalidSession, - "Missing encryption key. Reauthenticate with `apw auth`.", + "Missing encryption key. Use `apw app launch` and `apw login ` through the native app broker.", ) })?; @@ -356,7 +356,7 @@ impl SRPSession { let shared_key = self.shared_key.as_ref().ok_or_else(|| { APWError::new( Status::InvalidSession, - "Missing encryption key. Reauthenticate with `apw auth`.", + "Missing encryption key. Use `apw app launch` and `apw login ` through the native app broker.", ) })?; let key = shared_key.to_bytes_be(); @@ -403,7 +403,7 @@ impl SRPSession { let shared_key = self.shared_key.as_ref().ok_or_else(|| { APWError::new( Status::InvalidSession, - "Missing encryption key. Reauthenticate with `apw auth`.", + "Missing encryption key. Use `apw app launch` and `apw login ` through the native app broker.", ) })?; if payload.len() <= NONCE_BYTES { diff --git a/legacy/rust-tests/legacy_parity.rs b/legacy/rust-tests/legacy_parity.rs new file mode 100644 index 0000000..62f2981 --- /dev/null +++ b/legacy/rust-tests/legacy_parity.rs @@ -0,0 +1,141 @@ +use serde_json::Value; +use std::env; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use tempfile::TempDir; + +#[derive(Debug)] +struct CommandOutput { + status: i32, + stdout: String, + stderr: String, +} + +fn has_deno() -> bool { + Command::new("deno") + .arg("--version") + .output() + .map(|output| output.status.success()) + .unwrap_or(false) +} + +fn run_command(program: &Path, args: &[&str], home: &Path) -> CommandOutput { + let mut command = Command::new(program); + command.env("HOME", home).args(args).env("NO_COLOR", "1"); + + let output: Output = command.output().expect("failed to run command"); + + CommandOutput { + status: output.status.code().unwrap_or(-1), + stdout: String::from_utf8_lossy(&output.stdout).trim().to_string(), + stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(), + } +} + +fn run_rust_cli(home: &Path, args: &[&str]) -> CommandOutput { + let path = PathBuf::from(env!("CARGO_BIN_EXE_apw")); + run_command(&path, args, home) +} + +fn run_deno_cli(home: &Path, args: &[&str]) -> CommandOutput { + let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let cli = workspace.join("../legacy/deno/src/cli.ts"); + let mut cmd = Command::new("deno"); + let output = cmd + .current_dir(&workspace) + .env("HOME", home) + .env("NO_COLOR", "1") + .arg("run") + .arg("--quiet") + .arg("--allow-read") + .arg("--allow-write") + .arg("--allow-env") + .arg("--allow-net") + .arg(cli) + .args(args) + .output() + .expect("failed to run legacy deno cli"); + + CommandOutput { + status: output.status.code().unwrap_or(-1), + stdout: String::from_utf8_lossy(&output.stdout).trim().to_string(), + stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(), + } +} + +fn parse_json_output(output: &CommandOutput) -> Value { + serde_json::from_str(&output.stdout) + .unwrap_or_else(|_| panic!("command stdout was not JSON: {:?}", output.stdout)) +} + +fn run_with_temp_home(run: F) -> R +where + F: FnOnce(&Path) -> R, +{ + let temp = TempDir::new().expect("failed to create temp home"); + let previous_home = env::var("HOME").ok(); + env::set_var("HOME", temp.path()); + + let result = run(temp.path()); + + if let Some(previous_home) = previous_home { + env::set_var("HOME", previous_home); + } else { + env::remove_var("HOME"); + } + + result +} + +#[test] +fn removed_legacy_daemon_commands_are_not_active_contract() { + run_with_temp_home(|home| { + for args in [ + &["auth"][..], + &["pw", "list", "example.com"][..], + &["otp", "list", "example.com"][..], + &["start"][..], + ] { + let output = run_rust_cli(home, args); + assert_ne!( + output.status, 0, + "removed legacy command unexpectedly succeeded: {args:?}" + ); + assert!( + output.stderr.contains("unrecognized subcommand"), + "removed legacy command should be rejected by clap: {args:?}, stderr={:?}", + output.stderr + ); + } + }); +} + +#[test] +fn parity_status_output_with_no_session_is_shape_compatible() { + if !has_deno() { + return; + } + + run_with_temp_home(|home| { + let rust = run_rust_cli(home, &["status", "--json"]); + let deno = run_deno_cli(home, &["status", "--json"]); + + assert_eq!(rust.status, 0, "rust status failed: {rust:#?}"); + if deno.status != 0 && deno.stderr.contains("JSR package manifest") { + return; + } + assert_eq!(deno.status, 0, "deno status failed: {deno:#?}"); + + let rust_payload = parse_json_output(&rust); + let deno_payload = parse_json_output(&deno); + + assert_eq!(rust_payload["ok"], deno_payload["ok"]); + assert_eq!(rust_payload["code"], deno_payload["code"]); + assert_eq!( + rust_payload["payload"]["daemon"]["host"], + deno_payload["payload"]["daemon"]["host"] + ); + assert_eq!(rust_payload["payload"]["session"]["authenticated"], false); + assert_eq!(deno_payload["payload"]["session"]["authenticated"], false); + }); +} diff --git a/scripts/browser-host-smoke.sh b/legacy/scripts/browser-host-smoke.sh similarity index 100% rename from scripts/browser-host-smoke.sh rename to legacy/scripts/browser-host-smoke.sh diff --git a/scripts/build-native-host.sh b/legacy/scripts/build-native-host.sh similarity index 100% rename from scripts/build-native-host.sh rename to legacy/scripts/build-native-host.sh diff --git a/scripts/install-browser-bridge.sh b/legacy/scripts/install-browser-bridge.sh similarity index 100% rename from scripts/install-browser-bridge.sh rename to legacy/scripts/install-browser-bridge.sh diff --git a/rust/src/cli.rs b/rust/src/cli.rs index f135eed..ee4dc1b 100644 --- a/rust/src/cli.rs +++ b/rust/src/cli.rs @@ -1,70 +1,12 @@ -use crate::client::ApplePasswordManager; -use crate::daemon::{start_daemon, DaemonOptions}; use crate::error::APWError; -use crate::host::{native_host_doctor, native_host_install, native_host_uninstall}; use crate::logging::{self, LogLevel}; use crate::native_app::{ native_app_doctor, native_app_fill, native_app_install, native_app_launch, native_app_login, + native_app_status, }; -use crate::types::{ - Payload, RuntimeMode, Status, BUILD_DATE, BUILD_TARGET, GIT_SHA, RUST_VERSION, VERSION, -}; -use crate::utils::{bigint_to_base64, read_bigint}; +use crate::types::{Status, BUILD_DATE, BUILD_TARGET, GIT_SHA, RUST_VERSION, VERSION}; use clap::{Args, Parser, Subcommand}; -use rpassword::prompt_password; use serde_json::json; -use std::io::{self, Write}; - -const LEGACY_DAEMON_DEPRECATION_WARNING: &str = "This command uses the legacy daemon path and will be removed in v2.1.0. Use the native app broker flow instead; see docs/MIGRATION_AND_PARITY.md."; - -fn read_prompt(prompt: &str) -> Result { - print!("{prompt}"); - io::stdout().flush().map_err(|error| { - APWError::new( - Status::GenericError, - format!("Failed to print prompt: {error}"), - ) - })?; - - let mut value = String::new(); - io::stdin().read_line(&mut value).map_err(|error| { - APWError::new( - Status::GenericError, - format!("Failed to read input: {error}"), - ) - })?; - - Ok(value.trim().to_string()) -} - -fn normalize_pin(value: String) -> Result { - if !value.chars().all(|c| c.is_ascii_digit()) || value.len() != 6 { - return Err(APWError::new( - Status::InvalidParam, - "PIN must be exactly 6 digits.", - )); - } - Ok(value) -} - -fn is_valid_host(host: &str) -> bool { - !host.trim().is_empty() && !host.contains('\0') && !host.contains(' ') -} - -fn parse_host(raw: &str) -> Result { - let trimmed = raw.trim(); - if trimmed.is_empty() { - return Err(APWError::new(Status::InvalidParam, "Missing host.")); - } - if !is_valid_host(trimmed) { - return Err(APWError::new(Status::InvalidParam, "Invalid host.")); - } - Ok(trimmed.to_string()) -} - -fn parse_host_arg(raw: &str) -> std::result::Result { - parse_host(raw).map_err(|error| error.message) -} fn sanitize_url(raw: &str) -> Result { let trimmed = raw.trim(); @@ -128,96 +70,10 @@ fn print_output(payload: &serde_json::Value, status: Status, json_output: bool) } } -fn print_entries(payload: &Payload, json_output: bool) -> Result<(), APWError> { - if payload.status != Status::Success { - return Err(APWError::new( - payload.status, - crate::types::status_text(payload.status), - )); - } - - let entries = payload - .entries - .iter() - .filter_map(|entry| { - if let Some(username) = entry.get("USR").and_then(serde_json::Value::as_str) { - let domain = entry - .get("sites") - .and_then(serde_json::Value::as_array) - .and_then(|sites| sites.first()) - .and_then(serde_json::Value::as_str) - .unwrap_or(""); - let password = entry - .get("PWD") - .and_then(serde_json::Value::as_str) - .unwrap_or("Not Included"); - return Some(serde_json::json!({ - "username": username, - "domain": domain, - "password": password, - })); - } - - if let Some(username) = entry.get("username").and_then(serde_json::Value::as_str) { - let code = entry - .get("code") - .and_then(serde_json::Value::as_str) - .unwrap_or("Not Included"); - let domain = entry - .get("domain") - .and_then(serde_json::Value::as_str) - .unwrap_or(""); - return Some(serde_json::json!({ - "username": username, - "domain": domain, - "code": code, - })); - } - - None - }) - .collect::>(); - - let mapped = json!({ - "results": entries, - "status": "ok", - }); - print_output(&mapped, Status::Success, json_output); - Ok(()) -} - fn print_status(payload: serde_json::Value, json_output: bool) { print_output(&payload, Status::Success, json_output); } -fn warn_legacy_daemon_path(command: &str) { - logging::warn(command, LEGACY_DAEMON_DEPRECATION_WARNING); -} - -fn parse_pin_prompt(optional: Option) -> Result { - if let Some(pin) = optional { - return normalize_pin(pin); - } - normalize_pin(prompt_password("Enter PIN: ").map_err(|error| { - APWError::new(Status::GenericError, format!("Failed to read PIN: {error}")) - })?) -} - -fn ask_pw_action() -> Result { - let selected = read_prompt("Choose action:\n 1) list accounts\n 2) get password\n> ")?; - let lowered = selected.trim().to_lowercase(); - if lowered == "1" || lowered == "list" || lowered == "list accounts" { - Ok(PwAction::List { url: String::new() }) - } else if lowered == "2" || lowered == "get" || lowered == "get password" { - Ok(PwAction::Get { - url: String::new(), - username: None, - }) - } else { - Err(APWError::new(Status::InvalidParam, "Invalid action.")) - } -} - #[derive(Parser)] #[command(name = "apw")] #[command(version = env!("CARGO_PKG_VERSION"))] @@ -238,22 +94,9 @@ pub struct Cli { #[derive(Subcommand)] pub enum Commands { App(AppCommand), - #[command( - long_about = "This command uses the legacy daemon path and will be removed in v2.1.0. Use the native app broker flow instead; see docs/MIGRATION_AND_PARITY.md." - )] - Auth(AuthCommand), Doctor(DoctorCommand), Fill(FillCommand), - Host(HostCommand), Login(LoginCommand), - #[command( - long_about = "This command uses the legacy daemon path and will be removed in v2.1.0. Use `apw login ` through the native app broker instead; see docs/MIGRATION_AND_PARITY.md." - )] - Pw(PwCommand), - #[command( - long_about = "This command starts the legacy daemon path and will be removed in v2.1.0. Use `apw app launch` for the native app broker instead; see docs/MIGRATION_AND_PARITY.md." - )] - Start(StartCommand), Status(StatusCommand), Version(VersionCommand), } @@ -294,98 +137,6 @@ pub struct FillCommand { pub url: String, } -#[derive(Args)] -#[command( - long_about = "DEPRECATED: `apw auth` is part of the legacy daemon path and will be removed in v2.1.0. See docs/MIGRATION_AND_PARITY.md." -)] -pub struct AuthCommand { - #[command(subcommand)] - pub command: Option, - #[arg(short, long)] - pub pin: Option, -} - -#[derive(Subcommand)] -pub enum AuthSubcommand { - Logout, - Request, - Response(AuthResponseArgs), -} - -#[derive(Args)] -pub struct AuthResponseArgs { - #[arg(short, long)] - pub pin: String, - #[arg(short, long)] - pub salt: String, - #[arg(long = "server_key", alias = "serverKey")] - pub server_key: String, - #[arg(long = "client_key", short, alias = "clientKey")] - pub client_key: String, - #[arg(short, long)] - pub username: String, -} - -#[derive(Args)] -pub struct HostCommand { - #[command(subcommand)] - pub command: HostSubcommand, -} - -#[derive(Subcommand)] -pub enum HostSubcommand { - Install, - Doctor(HostDoctorArgs), - Uninstall, -} - -#[derive(Args)] -pub struct HostDoctorArgs { - #[arg(long)] - pub json: bool, -} - -#[derive(Args)] -#[command( - long_about = "DEPRECATED: `apw pw` is part of the legacy daemon path and will be removed in v2.1.0. Use `apw login`/`apw fill` for the v2 broker. See docs/MIGRATION_AND_PARITY.md." -)] -pub struct PwCommand { - #[command(subcommand)] - pub action: Option, -} - -#[derive(Subcommand)] -pub enum PwAction { - Get { - #[arg(value_name = "url")] - url: String, - username: Option, - }, - List { - url: String, - }, -} - -#[derive(Args)] -#[command( - long_about = "DEPRECATED: `apw start` launches the legacy WebSocket daemon and will be removed in v2.1.0. The v2 broker runs as a per-user app under `apw app launch`. See docs/MIGRATION_AND_PARITY.md." -)] -pub struct StartCommand { - #[arg(short, long, default_value_t = 0)] - pub port: u16, - #[arg( - short, - long, - default_value = "127.0.0.1", - value_parser = parse_host_arg - )] - pub bind: String, - #[arg(short = 'm', long, default_value = "auto", value_parser = parse_runtime_mode)] - pub runtime_mode: RuntimeMode, - #[arg(long)] - pub dry_run: bool, -} - #[derive(Args)] pub struct StatusCommand { #[arg(long)] @@ -395,17 +146,13 @@ pub struct StatusCommand { #[derive(Args, Default)] pub struct VersionCommand {} -pub async fn run(mut manager: ApplePasswordManager, cli: Cli) -> Result<(), APWError> { +pub async fn run(cli: Cli) -> Result<(), APWError> { match cli.command { Commands::App(args) => run_app(args, cli.json), - Commands::Auth(args) => run_auth(&mut manager, args, cli.json), Commands::Doctor(args) => run_doctor(args, cli.json), Commands::Fill(args) => run_fill(args, cli.json), - Commands::Host(args) => run_host(args, cli.json), Commands::Login(args) => run_login(args, cli.json), - Commands::Pw(args) => run_pw(&mut manager, args, cli.json), - Commands::Start(args) => run_start(args).await, - Commands::Status(args) => run_status(&mut manager, args, cli.json), + Commands::Status(args) => run_status(args, cli.json), Commands::Version(args) => run_version(args, cli.json), } } @@ -474,13 +221,9 @@ fn run_login(args: LoginCommand, cli_json: bool) -> Result<(), APWError> { Ok(()) } -fn run_status( - manager: &mut ApplePasswordManager, - args: StatusCommand, - cli_json: bool, -) -> Result<(), APWError> { - logging::debug("status", "collecting runtime status"); - let payload = manager.status(); +fn run_status(args: StatusCommand, cli_json: bool) -> Result<(), APWError> { + logging::debug("status", "collecting native app status"); + let payload = native_app_status(); print_status(payload, args.json || cli_json); Ok(()) } @@ -499,120 +242,6 @@ fn run_version(_args: VersionCommand, cli_json: bool) -> Result<(), APWError> { Ok(()) } -fn run_auth( - manager: &mut ApplePasswordManager, - args: AuthCommand, - cli_json: bool, -) -> Result<(), APWError> { - warn_legacy_daemon_path("auth"); - let result = match args.command { - Some(AuthSubcommand::Logout) => { - manager.logout()?; - serde_json::json!({"status": "logged out"}) - } - Some(AuthSubcommand::Request) => { - manager.request_challenge()?; - let values = manager.session.return_values(); - serde_json::json!({ - "salt": bigint_to_base64(&values.salt.unwrap_or_default()), - "serverKey": bigint_to_base64(&values.server_public_key.unwrap_or_default()), - "username": values.username.unwrap_or_default(), - "clientKey": bigint_to_base64(&values.client_private_key.unwrap_or_default()), - }) - } - Some(AuthSubcommand::Response(options)) => { - let salt = read_bigint(&options.salt)?; - let server_key = read_bigint(&options.server_key)?; - let client_key = read_bigint(&options.client_key)?; - manager.set_session_for_response(options.username, client_key, server_key, salt); - let pin = normalize_pin(options.pin)?; - manager.verify_challenge(pin)?; - serde_json::json!({"status": "ok"}) - } - None => { - let pin = parse_pin_prompt(args.pin)?; - manager.request_challenge()?; - manager.verify_challenge(pin)?; - serde_json::json!({"status": "ok"}) - } - }; - - print_output(&result, Status::Success, cli_json); - Ok(()) -} - -fn run_host(args: HostCommand, cli_json: bool) -> Result<(), APWError> { - match args.command { - HostSubcommand::Install => { - let payload = native_host_install()?; - print_output(&payload, Status::Success, cli_json); - } - HostSubcommand::Doctor(options) => { - let payload = native_host_doctor()?; - print_output(&payload, Status::Success, options.json || cli_json); - } - HostSubcommand::Uninstall => { - let payload = native_host_uninstall()?; - print_output(&payload, Status::Success, cli_json); - } - } - - Ok(()) -} - -fn run_pw( - manager: &mut ApplePasswordManager, - args: PwCommand, - cli_json: bool, -) -> Result<(), APWError> { - warn_legacy_daemon_path("pw"); - match args.action { - Some(PwAction::Get { url, username }) => { - let payload = manager.get_password_for_url( - &sanitize_url(&url)?, - username.unwrap_or_default().as_str(), - )?; - print_entries(&payload, cli_json) - } - Some(PwAction::List { url }) => { - let payload = manager.get_login_names_for_url(&sanitize_url(&url)?)?; - print_entries(&payload, cli_json) - } - None => { - let action = ask_pw_action()?; - let url = sanitize_url(&read_prompt("Enter URL: ")?)?; - match action { - PwAction::Get { .. } => { - let username = read_prompt("Enter username (optional): ")?; - let payload = manager.get_password_for_url(&url, username.as_str())?; - print_entries(&payload, cli_json) - } - PwAction::List { .. } => { - let payload = manager.get_login_names_for_url(&url)?; - print_entries(&payload, cli_json) - } - } - } - } -} - -async fn run_start(args: StartCommand) -> Result<(), APWError> { - warn_legacy_daemon_path("start"); - logging::info( - "daemon", - format!("starting daemon on {}:{}", args.bind, args.port), - ); - let host = parse_host(&args.bind)?; - let port = args.port; - start_daemon(DaemonOptions { - port, - host, - runtime_mode: args.runtime_mode, - dry_run: args.dry_run, - }) - .await -} - fn version_payload() -> Result { Ok(json!({ "version": VERSION, @@ -701,53 +330,12 @@ fn validate_semver_identifiers( Ok(()) } -fn parse_runtime_mode(raw: &str) -> std::result::Result { - let normalized = raw.trim().to_lowercase(); - Ok(match normalized.as_str() { - "auto" => RuntimeMode::Auto, - "native" => RuntimeMode::Native, - "browser" => RuntimeMode::Browser, - "direct" => RuntimeMode::Direct, - "launchd" => RuntimeMode::Launchd, - "disabled" => RuntimeMode::Disabled, - _ => { - return Err( - "runtime mode must be one of auto|native|browser|direct|launchd|disabled." - .to_string(), - ); - } - }) -} - #[cfg(test)] mod tests { use super::*; use clap::{CommandFactory, Parser}; use rand::{thread_rng, Rng}; - #[test] - fn host_validation_rejects_spaces() { - assert!(is_valid_host("localhost")); - assert!(!is_valid_host("local host")); - assert!(!is_valid_host(" ")); - assert!(!is_valid_host("a\0b")); - } - - #[test] - fn parse_host_requires_value() { - assert!(parse_host("127.0.0.1").is_ok()); - assert!(parse_host(" ").is_err()); - assert!(parse_host("bad host").is_err()); - } - - #[test] - fn pin_normalization_is_strict() { - assert_eq!(normalize_pin("123456".to_string()).unwrap(), "123456"); - assert!(normalize_pin("12345".to_string()).is_err()); - assert!(normalize_pin("12ab56".to_string()).is_err()); - assert!(normalize_pin(" 123456 ".to_string()).is_err()); - } - #[test] fn parse_url_is_optional_https_default() { assert_eq!(sanitize_url("example.com").unwrap(), "https://example.com"); @@ -782,28 +370,16 @@ mod tests { } #[test] - fn legacy_daemon_help_mentions_deprecation() { + fn legacy_daemon_commands_are_removed_from_help() { let mut command = Cli::command(); - for name in ["auth", "pw", "start"] { - let help = command - .find_subcommand_mut(name) - .expect("legacy subcommand") - .render_long_help() - .to_string(); - assert!(help.contains("legacy daemon path"), "{name} help: {help}"); - assert!(help.contains("v2.1.0"), "{name} help: {help}"); + for name in ["auth", "pw", "otp", "start"] { + assert!( + command.find_subcommand_mut(name).is_none(), + "removed legacy subcommand still appears in help: {name}" + ); } } - #[test] - fn otp_subcommand_is_removed() { - let Err(error) = Cli::try_parse_from(["apw", "otp", "list", "example.com"]) else { - panic!("expected removed otp subcommand to be rejected"); - }; - let rendered = error.to_string(); - assert!(rendered.contains("unrecognized subcommand 'otp'")); - } - #[test] fn version_subcommand_is_parsed() { let cli = Cli::parse_from(["apw", "version"]); @@ -895,20 +471,6 @@ mod tests { } } - #[test] - fn start_command_rejects_invalid_bind_host() { - assert!( - Cli::try_parse_from(["apw", "start", "--bind", "bad host", "--port", "5000"]).is_err() - ); - } - - #[test] - fn start_command_rejects_invalid_port() { - assert!( - Cli::try_parse_from(["apw", "start", "--bind", "127.0.0.1", "--port", "bad"]).is_err() - ); - } - #[test] fn parse_status_global_json_defaults_to_status_json() { let parsed = Cli::try_parse_from(["apw", "--json", "status"]).unwrap(); @@ -916,167 +478,21 @@ mod tests { } #[test] - fn auth_response_command_requires_expected_fields() { - let parsed = Cli::try_parse_from([ - "apw", - "auth", - "response", - "--pin", - "123456", - "--salt", - "AQ==", - "--server_key", - "Ag==", - "--client_key", - "Aw==", - "--username", - "alice", - ]) - .unwrap(); - match parsed.command { - Commands::Auth(auth) => match auth.command { - Some(AuthSubcommand::Response(_)) => {} - _ => panic!("expected auth response command"), - }, - _ => panic!("expected auth command"), - } - } - - #[test] - fn auth_response_command_accepts_camel_case_keys() { - let parsed = Cli::try_parse_from([ - "apw", - "auth", - "response", - "--pin", - "123456", - "--salt", - "AQ==", - "--serverKey", - "Ag==", - "--clientKey", - "Aw==", - "--username", - "alice", - ]) - .unwrap(); - match parsed.command { - Commands::Auth(auth) => match auth.command { - Some(AuthSubcommand::Response(response)) => { - assert_eq!(response.server_key, "Ag=="); - assert_eq!(response.client_key, "Aw=="); - } - _ => panic!("expected auth response command"), - }, - _ => panic!("expected auth command"), - } - } - - #[test] - fn auth_response_command_accepts_legacy_short_flags() { - let parsed = Cli::try_parse_from([ - "apw", - "auth", - "response", - "-p", - "123456", - "-s", - "AQ==", - "--serverKey", - "Ag==", - "-c", - "Aw==", - "-u", - "alice", - ]) - .unwrap(); - match parsed.command { - Commands::Auth(auth) => match auth.command { - Some(AuthSubcommand::Response(response)) => { - assert_eq!(response.pin, "123456"); - assert_eq!(response.salt, "AQ=="); - assert_eq!(response.server_key, "Ag=="); - assert_eq!(response.client_key, "Aw=="); - assert_eq!(response.username, "alice"); - } - _ => panic!("expected auth response command"), - }, - _ => panic!("expected auth command"), - } - } - - #[test] - fn start_command_defaults_match_legacy() { - let parsed = Cli::try_parse_from(["apw", "start"]).unwrap(); - match parsed.command { - Commands::Start(start) => { - assert_eq!(start.port, 0); - assert_eq!(start.bind, "127.0.0.1"); - } - _ => panic!("expected start command"), - } - } - - #[test] - fn start_command_accepts_browser_runtime_mode() { - let parsed = Cli::try_parse_from(["apw", "start", "--runtime-mode", "browser"]).unwrap(); - match parsed.command { - Commands::Start(start) => { - assert_eq!(start.runtime_mode, RuntimeMode::Browser); - } - _ => panic!("expected start command"), - } - } - - #[test] - fn host_install_command_parses() { - let parsed = Cli::try_parse_from(["apw", "host", "install"]).unwrap(); - match parsed.command { - Commands::Host(host) => match host.command { - HostSubcommand::Install => {} - _ => panic!("expected host install command"), - }, - _ => panic!("expected host command"), - } - } - - #[test] - fn host_doctor_command_accepts_json_flag() { - let parsed = Cli::try_parse_from(["apw", "host", "doctor", "--json"]).unwrap(); - match parsed.command { - Commands::Host(host) => match host.command { - HostSubcommand::Doctor(options) => { - assert!(options.json); - } - _ => panic!("expected host doctor command"), - }, - _ => panic!("expected host command"), - } - } - - #[test] - fn host_uninstall_command_parses() { - let parsed = Cli::try_parse_from(["apw", "host", "uninstall"]).unwrap(); - match parsed.command { - Commands::Host(host) => match host.command { - HostSubcommand::Uninstall => {} - _ => panic!("expected host uninstall command"), - }, - _ => panic!("expected host command"), + fn legacy_daemon_commands_are_rejected() { + for args in [ + &["apw", "auth"][..], + &["apw", "host", "install"][..], + &["apw", "pw", "list", "example.com"][..], + &["apw", "otp", "list", "example.com"][..], + &["apw", "start"][..], + ] { + assert!( + Cli::try_parse_from(args).is_err(), + "removed legacy command unexpectedly parsed: {args:?}" + ); } } - #[test] - fn print_entries_rejects_errors() { - let payload = Payload { - status: Status::NoResults, - entries: Vec::new(), - }; - let result = print_entries(&payload, false); - assert!(result.is_err()); - assert_eq!(result.unwrap_err().code, Status::NoResults); - } - #[test] fn app_install_command_parses() { let parsed = Cli::try_parse_from(["apw", "app", "install"]).unwrap(); diff --git a/rust/src/doctor.rs b/rust/src/doctor.rs index a5b17a1..61c7138 100644 --- a/rust/src/doctor.rs +++ b/rust/src/doctor.rs @@ -347,6 +347,7 @@ pub fn checks_to_json(checks: &[DoctorCheck]) -> Value { #[cfg(test)] mod tests { use super::*; + use serial_test::serial; #[test] fn check_status_label_is_uppercase() { @@ -407,12 +408,14 @@ mod tests { } #[test] + #[serial] fn associated_domains_check_skipped_when_env_unset() { std::env::remove_var("APW_AASA_DOMAINS"); assert!(check_associated_domains().is_none()); } #[test] + #[serial] fn associated_domains_check_reports_failure_for_unreachable_host() { // Use a guaranteed-unreachable .invalid TLD (RFC 2606). curl will // exit non-zero so the probe returns None and the check fails. diff --git a/rust/src/main.rs b/rust/src/main.rs index 3b4624c..2d305ab 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -1,33 +1,25 @@ use clap::Parser; mod cli; -mod client; -mod daemon; mod doctor; mod error; -mod host; mod logging; mod native_app; mod secrets; -mod srp; mod types; mod utils; use cli::{run, Cli}; -use client::ApplePasswordManager; use logging::error as log_error; use std::env; use std::process; #[tokio::main] async fn main() { - let raw_args: Vec = env::args().collect(); - let normalized_args = normalize_legacy_args(raw_args); - let args = Cli::parse_from(normalized_args); + let args = Cli::parse_from(env::args()); let json_output = args.json; logging::init(args.log_level, json_output); - let manager = ApplePasswordManager::new(); - if let Err(error) = run(manager, args).await { + if let Err(error) = run(args).await { if should_emit_text_error_log(json_output) { log_error("cli", &error.message); } @@ -51,40 +43,9 @@ fn should_emit_text_error_log(json_output: bool) -> bool { !json_output } -fn normalize_legacy_args(raw: Vec) -> Vec { - raw.into_iter() - .map(|arg| match arg.as_str() { - "-sk" => "--serverKey".to_string(), - "-ck" => "--clientKey".to_string(), - other => other.to_string(), - }) - .collect() -} - #[cfg(test)] mod tests { - use super::{normalize_legacy_args, should_emit_text_error_log}; - - #[test] - fn normalizes_legacy_auth_short_flags() { - let args = vec![ - "apw".to_string(), - "-sk".to_string(), - "server".to_string(), - "-ck".to_string(), - "client".to_string(), - ]; - assert_eq!( - normalize_legacy_args(args), - vec![ - "apw".to_string(), - "--serverKey".to_string(), - "server".to_string(), - "--clientKey".to_string(), - "client".to_string(), - ] - ); - } + use super::should_emit_text_error_log; #[test] fn suppresses_text_error_logs_for_json_output() { diff --git a/rust/src/native_app.rs b/rust/src/native_app.rs index 9d35b89..83bb62b 100644 --- a/rust/src/native_app.rs +++ b/rust/src/native_app.rs @@ -874,7 +874,7 @@ pub fn native_app_doctor() -> Result { json!({ "target": "v2.0.0", "version": VERSION, - "legacyParityCommandsRetained": true, + "legacyRuntimeArchive": "legacy/", }), ); object.insert( @@ -2092,13 +2092,9 @@ print(json.dumps([{"login":{"username":"alice@example.com","password":"secret"," fs::write( &provider_path, format!( - r#"#!/usr/bin/env python3 -import os -import pathlib -import time - -pathlib.Path({pid_path:?}).write_text(str(os.getpid()), encoding="utf-8") -time.sleep(10) + r#"#!/bin/sh +printf '%s' "$$" > {pid_path:?} +sleep 10 "#, pid_path = pid_path.display().to_string() ), diff --git a/rust/src/secrets.rs b/rust/src/secrets.rs index 3039c6b..d06387c 100644 --- a/rust/src/secrets.rs +++ b/rust/src/secrets.rs @@ -1,3 +1,5 @@ +#![allow(dead_code)] + use crate::error::{APWError, Result}; use crate::types::Status; use std::process::Command; diff --git a/rust/src/types.rs b/rust/src/types.rs index 77bd9e2..d7458fe 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -345,10 +345,6 @@ impl ExternalFallbackProvider { pub enum RuntimeMode { #[default] Auto, - Native, - Browser, - Direct, - Launchd, Disabled, } @@ -356,10 +352,6 @@ impl RuntimeMode { fn as_str(self) -> &'static str { match self { Self::Auto => "auto", - Self::Native => "native", - Self::Browser => "browser", - Self::Direct => "direct", - Self::Launchd => "launchd", Self::Disabled => "disabled", } } @@ -381,10 +373,6 @@ impl<'de> Deserialize<'de> for RuntimeMode { { let value = String::deserialize(deserializer)?; Ok(match value.to_lowercase().as_str() { - "native" => Self::Native, - "browser" => Self::Browser, - "direct" => Self::Direct, - "launchd" => Self::Launchd, "disabled" => Self::Disabled, "auto" => Self::Auto, _ => Self::Auto, @@ -682,7 +670,7 @@ pub fn status_text(status: Status) -> &'static str { Status::InvalidMessageFormat => "Invalid message format", Status::DuplicateItem => "Duplicate item found", Status::UnknownAction => "Unknown action requested", - Status::InvalidSession => "Invalid session, reauthenticate with `apw auth`", + Status::InvalidSession => "Invalid session, use `apw app launch` and `apw login `", Status::ServerError => "Server error", Status::CommunicationTimeout => "Communication timeout", Status::InvalidConfig => "Stored configuration is invalid", @@ -717,7 +705,7 @@ mod tests { assert_eq!(status_text(Status::Success), "Operation successful"); assert_eq!( status_text(Status::InvalidSession), - "Invalid session, reauthenticate with `apw auth`" + "Invalid session, use `apw app launch` and `apw login `" ); assert_eq!( status_text(Status::GenericError), diff --git a/rust/src/utils.rs b/rust/src/utils.rs index f4327f8..0698da7 100644 --- a/rust/src/utils.rs +++ b/rust/src/utils.rs @@ -1,3 +1,5 @@ +#![allow(dead_code)] + use crate::error::{APWError, Result}; use crate::secrets::{delete_shared_key, read_shared_key, supports_keychain, write_shared_key}; use crate::types::{ @@ -176,7 +178,7 @@ fn read_config_file_or_null() -> Result { let legacy = serde_json::from_value::(parsed).map_err(|_| { APWError::new( crate::types::Status::InvalidConfig, - "Invalid config format. Run `apw auth` again.", + "Invalid config format. Run `apw doctor` and use `apw login ` through the native app broker.", ) })?; @@ -419,7 +421,7 @@ pub fn read_config(opts: Option) -> Result if options.require_auth { return Err(APWError::new( crate::types::Status::InvalidSession, - "Session expired. Run `apw auth` again.", + "Session expired. Use `apw app launch` and `apw login ` through the native app broker.", )); } return Ok(APWRuntimeConfig { @@ -486,7 +488,7 @@ pub fn read_config(opts: Option) -> Result clear_config(); return Err(APWError::new( crate::types::Status::InvalidSession, - "No active session. Run `apw auth` again.", + "No active session. Use `apw app launch` and `apw login ` through the native app broker.", )); } @@ -564,7 +566,7 @@ pub fn write_config(input: WriteConfigInput) -> Result { if !input.allow_empty && username.is_empty() { return Err(APWError::new( crate::types::Status::InvalidConfig, - "Cannot persist incomplete config. Run `apw auth` again.", + "Cannot persist incomplete config. Use `apw app launch` and `apw login ` through the native app broker.", )); } @@ -626,7 +628,7 @@ pub fn write_config(input: WriteConfigInput) -> Result { if username.is_empty() || (secret_source == SecretSource::File && shared_key.is_empty()) { return Err(APWError::new( crate::types::Status::InvalidConfig, - "Cannot persist incomplete config. Run `apw auth` again.", + "Cannot persist incomplete config. Use `apw app launch` and `apw login ` through the native app broker.", )); } if secret_source == SecretSource::Keychain && !supports_keychain() { @@ -1240,10 +1242,10 @@ mod tests { host: Some("127.0.0.1".to_string()), allow_empty: true, clear_auth: true, - runtime_mode: Some(RuntimeMode::Direct), + runtime_mode: Some(RuntimeMode::Auto), last_launch_status: Some("ok".to_string()), last_launch_error: None, - last_launch_strategy: Some("direct".to_string()), + last_launch_strategy: Some("archived".to_string()), ..WriteConfigInput::default() }) .unwrap(); @@ -1252,10 +1254,10 @@ mod tests { assert_eq!(written.host, "127.0.0.1"); assert_eq!(written.username, ""); assert_eq!(written.shared_key, ""); - assert_eq!(written.runtime_mode, RuntimeMode::Direct); + assert_eq!(written.runtime_mode, RuntimeMode::Auto); assert_eq!(written.last_launch_status.as_deref(), Some("ok")); assert_eq!(written.last_launch_error, None); - assert_eq!(written.last_launch_strategy.as_deref(), Some("direct")); + assert_eq!(written.last_launch_strategy.as_deref(), Some("archived")); let runtime = read_config(Some(ConfigReadOptions { require_auth: false, @@ -1289,10 +1291,10 @@ mod tests { port: Some(10_013), host: Some("127.0.0.1".to_string()), allow_empty: true, - runtime_mode: Some(RuntimeMode::Direct), + runtime_mode: Some(RuntimeMode::Auto), last_launch_status: Some("failed".to_string()), last_launch_error: Some("probe failed".to_string()), - last_launch_strategy: Some("direct".to_string()), + last_launch_strategy: Some("archived".to_string()), ..WriteConfigInput::default() }) .unwrap(); @@ -1334,10 +1336,10 @@ mod tests { port: Some(10_013), host: Some("127.0.0.1".to_string()), allow_empty: true, - runtime_mode: Some(RuntimeMode::Direct), + runtime_mode: Some(RuntimeMode::Auto), last_launch_status: Some("failed".to_string()), last_launch_error: Some("probe failed".to_string()), - last_launch_strategy: Some("direct".to_string()), + last_launch_strategy: Some("archived".to_string()), ..WriteConfigInput::default() }) .unwrap(); @@ -1349,7 +1351,7 @@ mod tests { #[test] #[serial] - fn browser_bridge_metadata_resets_launch_fields_without_clearing_auth() { + fn archived_bridge_metadata_resets_launch_fields_without_clearing_auth() { with_temp_home(|| { supports_keychain_for_tests(Some(false)); write_config(WriteConfigInput { @@ -1359,10 +1361,10 @@ mod tests { host: Some("127.0.0.1".to_string()), allow_empty: false, refresh_created_at: true, - runtime_mode: Some(RuntimeMode::Direct), + runtime_mode: Some(RuntimeMode::Auto), last_launch_status: Some("failed".to_string()), last_launch_error: Some("probe failed".to_string()), - last_launch_strategy: Some("direct".to_string()), + last_launch_strategy: Some("archived".to_string()), ..WriteConfigInput::default() }) .unwrap(); @@ -1371,7 +1373,7 @@ mod tests { port: Some(10_013), host: Some("127.0.0.1".to_string()), allow_empty: true, - runtime_mode: Some(RuntimeMode::Browser), + runtime_mode: Some(RuntimeMode::Auto), bridge_status: Some("attached".to_string()), bridge_browser: Some("chrome".to_string()), bridge_connected_at: Some("2026-03-08T00:00:00Z".to_string()), @@ -1381,7 +1383,7 @@ mod tests { }) .unwrap(); - assert_eq!(written.runtime_mode, RuntimeMode::Browser); + assert_eq!(written.runtime_mode, RuntimeMode::Auto); assert_eq!(written.username, "alice"); assert_eq!(written.bridge_status.as_deref(), Some("attached")); assert_eq!(written.bridge_browser.as_deref(), Some("chrome")); diff --git a/rust/tests/legacy_parity.rs b/rust/tests/legacy_parity.rs deleted file mode 100644 index b04370e..0000000 --- a/rust/tests/legacy_parity.rs +++ /dev/null @@ -1,858 +0,0 @@ -use base64::engine::general_purpose; -use base64::Engine as _; -use chrono::{Duration, Utc}; -use openssl::symm::{Cipher, Crypter, Mode}; -use rand::RngCore; -use serde_json::Value; -use serial_test::serial; -use std::env; -use std::fs; -use std::net::UdpSocket; -use std::path::{Path, PathBuf}; -use std::process::{Command, Output}; -use std::thread; -use std::time::Duration as StdDuration; -use tempfile::TempDir; - -const MAX_MESSAGE_BYTES: usize = 16 * 1024; -const DEFAULT_SHARED_KEY_BYTES: [u8; 16] = [0x10; 16]; - -#[derive(Debug)] -struct CommandOutput { - status: i32, - stdout: String, - stderr: String, -} - -fn has_deno() -> bool { - Command::new("deno") - .arg("--version") - .output() - .map(|output| output.status.success()) - .unwrap_or(false) -} - -fn run_command(program: &Path, args: &[&str], home: &Path) -> CommandOutput { - let mut command = Command::new(program); - command.env("HOME", home).args(args).env("NO_COLOR", "1"); - - let output: Output = command.output().expect("failed to run command"); - - CommandOutput { - status: output.status.code().unwrap_or(-1), - stdout: String::from_utf8_lossy(&output.stdout).trim().to_string(), - stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(), - } -} - -fn run_rust_cli(home: &Path, args: &[&str]) -> CommandOutput { - let path = PathBuf::from(env!("CARGO_BIN_EXE_apw")); - run_command(&path, args, home) -} - -fn run_deno_cli(home: &Path, args: &[&str]) -> CommandOutput { - let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - let cli = workspace.join("../legacy/deno/src/cli.ts"); - let mut cmd = Command::new("deno"); - let output = cmd - .current_dir(&workspace) - .env("HOME", home) - .env("NO_COLOR", "1") - .arg("run") - .arg("--quiet") - .arg("--allow-read") - .arg("--allow-write") - .arg("--allow-env") - .arg("--allow-net") - .arg(cli) - .args(args) - .output() - .expect("failed to run legacy deno cli"); - - CommandOutput { - status: output.status.code().unwrap_or(-1), - stdout: String::from_utf8_lossy(&output.stdout).trim().to_string(), - stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(), - } -} - -fn write_session_config(home: &Path, port: u16, shared_key: &[u8], authenticated: bool) -> Value { - let created_at = if authenticated { - Utc::now().to_rfc3339() - } else { - (Utc::now() - Duration::days(1)).to_rfc3339() - }; - - let config = serde_json::json!({ - "schema": 1, - "port": port, - "host": "127.0.0.1", - "username": "alice", - "sharedKey": general_purpose::STANDARD.encode(shared_key), - "createdAt": created_at, - "secretSource": "file", - }); - - fs::create_dir_all(home.join(".apw")).expect("failed to create config dir"); - fs::write( - home.join(".apw/config.json"), - serde_json::to_string(&config).expect("failed to serialize config"), - ) - .expect("failed to write config"); - - config -} - -fn encrypt_payload(shared_key: &[u8], payload: &Value) -> String { - let key = shared_key; - assert!(key.len() >= 16); - let nonce_seed = { - let mut bytes = [0_u8; 16]; - let mut random = rand::thread_rng(); - random.fill_bytes(&mut bytes); - bytes - }; - let mut cipher = Crypter::new( - Cipher::aes_128_gcm(), - Mode::Encrypt, - &key[..16], - Some(&nonce_seed), - ) - .expect("valid aes key slice"); - let plain = serde_json::to_vec(payload).expect("failed to serialize payload"); - let mut encrypted = vec![0_u8; plain.len() + 16]; - let count = cipher - .update(&plain, &mut encrypted) - .expect("payload encryption failed"); - let finalize_count = cipher - .finalize(&mut encrypted[count..]) - .expect("payload encryption failed"); - encrypted.truncate(count + finalize_count); - let mut tag = [0_u8; 16]; - cipher.get_tag(&mut tag).expect("payload encryption failed"); - encrypted.extend_from_slice(&tag); - - let mut output = nonce_seed.to_vec(); - output.extend_from_slice(&encrypted); - general_purpose::STANDARD.encode(output) -} - -fn spawn_fake_daemon(max_messages: usize, handler: F) -> (u16, thread::JoinHandle<()>) -where - F: Fn(&Value, usize) -> Vec + Send + 'static, -{ - let socket = UdpSocket::bind("127.0.0.1:0").expect("failed to bind daemon socket"); - let port = socket - .local_addr() - .expect("failed to query daemon socket") - .port(); - socket - .set_read_timeout(Some(StdDuration::from_millis(60_000))) - .expect("failed to set socket timeout"); - - let join = thread::spawn(move || { - let mut step = 0usize; - let mut buffer = vec![0_u8; MAX_MESSAGE_BYTES]; - - while step < max_messages { - let (size, peer) = match socket.recv_from(&mut buffer) { - Ok((size, peer)) => (size, peer), - Err(error) if error.kind() == std::io::ErrorKind::TimedOut => break, - Err(_) => break, - }; - - let request = serde_json::from_slice::(&buffer[..size]).unwrap_or(Value::Null); - let response = handler(&request, step); - let _ = socket.send_to(&response, peer); - step = step.saturating_add(1); - } - }); - - (port, join) -} - -fn parse_json_output(output: &CommandOutput) -> Value { - serde_json::from_str(&output.stdout) - .unwrap_or_else(|_| panic!("command stdout was not JSON: {:?}", output.stdout)) -} - -fn parse_json_from_output(output: &CommandOutput) -> Value { - let source = if output.stdout.trim().is_empty() { - &output.stderr - } else { - &output.stdout - }; - serde_json::from_str(source) - .unwrap_or_else(|_| panic!("command output was not JSON: {:?}", source)) -} - -fn run_with_temp_home(run: F) -> R -where - F: FnOnce(&Path) -> R, -{ - let temp = TempDir::new().expect("failed to create temp home"); - let previous_home = env::var("HOME").ok(); - env::set_var("HOME", temp.path()); - - let result = run(temp.path()); - - if let Some(previous_home) = previous_home { - env::set_var("HOME", previous_home); - } else { - env::remove_var("HOME"); - } - - result -} - -#[derive(Clone)] -struct CommandCase { - name: &'static str, - rust_args: &'static [&'static str], - deno_args: &'static [&'static str], - require_session: bool, - expect_code: i32, -} - -#[test] -#[serial] -fn parity_status_output_with_no_session_is_shape_compatible() { - if !has_deno() { - return; - } - - run_with_temp_home(|home| { - let rust = run_rust_cli(home, &["status", "--json"]); - let deno = run_deno_cli(home, &["status", "--json"]); - - assert_eq!(rust.status, 0, "rust auth request failed: {rust:#?}"); - assert_eq!(deno.status, 0, "deno auth request failed: {deno:#?}"); - - let rust_payload = parse_json_output(&rust); - let deno_payload = parse_json_output(&deno); - - assert_eq!(rust_payload["ok"], deno_payload["ok"]); - assert_eq!(rust_payload["code"], deno_payload["code"]); - assert_eq!( - rust_payload["payload"]["daemon"]["host"], - deno_payload["payload"]["daemon"]["host"] - ); - assert_eq!(rust_payload["payload"]["session"]["authenticated"], false); - assert_eq!(deno_payload["payload"]["session"]["authenticated"], false); - }); -} - -#[test] -fn legacy_daemon_commands_emit_deprecation_warning() { - run_with_temp_home(|home| { - for args in [ - &["auth", "logout"][..], - &["pw", "list", "bad host"][..], - &["start", "--dry-run"][..], - ] { - let output = run_rust_cli(home, args); - assert!( - output.stderr.contains("legacy daemon path"), - "{args:?} did not emit deprecation warning: {:?}", - output.stderr - ); - assert!( - output.stderr.contains("v2.1.0"), - "{args:?} did not include removal milestone: {:?}", - output.stderr - ); - } - }); -} - -#[test] -#[serial] -fn parity_auth_request_shape_matches_legacy() { - if !has_deno() { - return; - } - - let (port, handle) = spawn_fake_daemon(2, |_request, _step| { - let msg = _request - .get("msg") - .and_then(|value| value.get("PAKE")) - .and_then(Value::as_str); - let raw = msg - .and_then(|candidate| general_purpose::STANDARD.decode(candidate).ok()) - .and_then(|payload| serde_json::from_slice::(&payload).ok()); - let tid = raw - .as_ref() - .and_then(|value| value.get("TID")) - .and_then(Value::as_str) - .unwrap_or("alice"); - - let response = serde_json::json!({ - "TID": tid, - "MSG": 1, - "A": "AQ==", - "s": "AQ==", - "B": "AQ==", - "PROTO": [1], - "VER": "1.0", - "ErrCode": 0, - }); - - serde_json::to_vec(&serde_json::json!({ - "ok": true, - "code": 0, - "payload": { - "PAKE": general_purpose::STANDARD.encode(serde_json::to_vec(&response).unwrap()) - }, - })) - .expect("failed to encode auth response") - }); - - run_with_temp_home(|home| { - let _ = write_session_config(home, port, DEFAULT_SHARED_KEY_BYTES.as_slice(), true); - let rust = run_rust_cli(home, &["--json", "auth", "request"]); - let deno = run_deno_cli(home, &["--json", "auth", "request"]); - - assert_eq!(rust.status, 0); - assert_eq!(deno.status, 0); - let rust_payload = parse_json_output(&rust); - let deno_payload = parse_json_output(&deno); - - assert_eq!(rust_payload["code"], deno_payload["code"]); - assert!(rust_payload["payload"]["salt"].is_string()); - assert!(rust_payload["payload"]["serverKey"].is_string()); - assert!(rust_payload["payload"]["clientKey"].is_string()); - assert!(rust_payload["payload"]["username"].is_string()); - }); - - handle.join().expect("daemon failed"); -} - -#[test] -#[serial] -fn parity_auth_response_pin_mismatch_maps_to_invalid_session() { - if !has_deno() { - return; - } - - let (port, handle) = spawn_fake_daemon(2, |request, _step| { - let msg = request - .get("msg") - .and_then(|value| value.get("PAKE")) - .and_then(Value::as_str); - let raw = msg - .and_then(|candidate| general_purpose::STANDARD.decode(candidate).ok()) - .and_then(|payload| serde_json::from_slice::(&payload).ok()); - let tid = raw - .as_ref() - .and_then(|value| value.get("TID")) - .and_then(Value::as_str) - .unwrap_or("alice"); - - let response = serde_json::json!({ - "TID": tid, - "MSG": 3, - "A": "AQ==", - "s": "AQ==", - "B": "AQ==", - "PROTO": [1], - "VER": "1.0", - "ErrCode": 1, - }); - - serde_json::to_vec(&serde_json::json!({ - "ok": true, - "code": 0, - "payload": { - "PAKE": general_purpose::STANDARD.encode(serde_json::to_vec(&response).unwrap()) - }, - })) - .expect("failed to encode pin mismatch response") - }); - - run_with_temp_home(|home| { - let _ = write_session_config(home, port, DEFAULT_SHARED_KEY_BYTES.as_slice(), true); - let rust = run_rust_cli( - home, - &[ - "--json", - "auth", - "response", - "--pin", - "123456", - "--salt", - "AQ==", - "--server_key", - "AQ==", - "--client_key", - "AQ==", - "--username", - "alice", - ], - ); - let deno = run_deno_cli( - home, - &[ - "--json", - "auth", - "response", - "--pin", - "123456", - "--salt", - "AQ==", - "--serverKey", - "AQ==", - "--clientKey", - "AQ==", - "--username", - "alice", - ], - ); - - assert_eq!(rust.status, 9, "rust auth response failed: {rust:#?}"); - assert_eq!(deno.status, 9); - assert!(rust.stderr.contains("Incorrect")); - assert!(deno.stderr.contains("Incorrect")); - }); - - handle.join().expect("daemon failed"); -} - -#[test] -#[serial] -fn parity_data_plane_queries_match_legacy() { - if !has_deno() { - return; - } - - let shared_key = DEFAULT_SHARED_KEY_BYTES.to_vec(); - let (port, handle) = spawn_fake_daemon(16, move |request, _step| { - let command = request.get("cmd").and_then(Value::as_i64).unwrap_or(-1); - let response_payload = match command { - 4 => serde_json::json!({ - "STATUS": 0, - "Entries": [{ - "USR": "alice", - "sites": ["https://example.com/"], - "PWD": "secret", - }], - }), - 5 => serde_json::json!({ - "STATUS": 0, - "Entries": [{ - "USR": "alice", - "sites": ["https://example.com/"], - "PWD": "hunter2", - }], - }), - 15 | 16 => serde_json::json!({ - "STATUS": 0, - "Entries": [{ - "code": "111111", - "username": "alice", - "source": "totp", - "domain": "example.com", - }], - }), - 14 => { - return serde_json::to_vec(&serde_json::json!({ - "ok": true, - "code": 0, - "payload": { - "canFillOneTimeCodes": true, - "scanForOTPURI": false, - }, - })) - .expect("failed to encode capabilities response") - } - _ => serde_json::json!({ - "STATUS": 3, - "Entries": [], - }), - }; - - let encrypted = encrypt_payload(&shared_key, &response_payload); - serde_json::to_vec(&serde_json::json!({ - "ok": true, - "code": 0, - "payload": { - "SMSG": { - "TID": "alice", - "SDATA": encrypted, - }, - }, - })) - .expect("failed to encode response") - }); - - run_with_temp_home(|home| { - let _ = write_session_config(home, port, DEFAULT_SHARED_KEY_BYTES.as_slice(), true); - - let rust_pw = run_rust_cli(home, &["--json", "pw", "get", "example.com", "alice"]); - let deno_pw = run_deno_cli(home, &["--json", "pw", "get", "example.com", "alice"]); - - assert_eq!(rust_pw.status, 0, "rust pw failed: {rust_pw:#?}"); - assert_eq!(deno_pw.status, 0, "deno pw failed: {deno_pw:#?}"); - - let rust_pw_payload = parse_json_output(&rust_pw); - let deno_pw_payload = parse_json_output(&deno_pw); - - assert_eq!(rust_pw_payload["payload"], deno_pw_payload["payload"]); - }); - - handle.join().expect("daemon failed"); -} - -#[test] -#[serial] -fn parity_command_matrix_matches_legacy() { - if !has_deno() { - return; - } - - let shared_key = DEFAULT_SHARED_KEY_BYTES.to_vec(); - let (port, handle) = spawn_fake_daemon(16, move |request, _step| { - let command = request.get("cmd").and_then(Value::as_i64).unwrap_or(-1); - if command == 2 { - let message = request - .get("msg") - .and_then(|value| value.get("PAKE")) - .and_then(Value::as_str) - .and_then(|candidate| general_purpose::STANDARD.decode(candidate).ok()) - .and_then(|payload| serde_json::from_slice::(&payload).ok()); - let request_msg = message - .as_ref() - .and_then(|value| value.get("MSG")) - .and_then(Value::as_i64) - .unwrap_or_default(); - - let response_payload = if request_msg == 2 { - serde_json::json!({ - "TID": "alice", - "MSG": 3, - "A": "AQ==", - "s": "AQ==", - "B": "AQ==", - "PROTO": [1], - "VER": "1.0", - "ErrCode": 1, - "HAMK": "AQ==", - }) - } else { - serde_json::json!({ - "TID": "alice", - "MSG": 1, - "A": "AQ==", - "s": "AQ==", - "B": "AQ==", - "PROTO": [1], - "VER": "1.0", - "ErrCode": 0, - }) - }; - - return serde_json::to_vec(&serde_json::json!({ - "ok": true, - "code": 0, - "payload": { - "PAKE": general_purpose::STANDARD.encode(serde_json::to_vec(&response_payload).unwrap()), - }, - })) - .expect("failed to encode auth response"); - } - - let response_payload = match command { - 4 => serde_json::json!({ - "STATUS": 0, - "Entries": [{ - "USR": "alice", - "sites": ["https://example.com/"], - "PWD": "secret", - }], - }), - 5 => serde_json::json!({ - "STATUS": 0, - "Entries": [{ - "USR": "alice", - "sites": ["https://example.com/"], - "PWD": "hunter2", - }], - }), - 16 => serde_json::json!({ - "STATUS": 0, - "Entries": [{ - "code": "111111", - "username": "alice", - "source": "totp", - "domain": "example.com", - }], - }), - 17 => serde_json::json!({ - "STATUS": 0, - "Entries": [{ - "code": "222222", - "username": "alice", - "source": "totp", - "domain": "example.com", - }], - }), - 14 => { - return serde_json::to_vec(&serde_json::json!({ - "ok": true, - "code": 0, - "payload": { - "canFillOneTimeCodes": true, - "scanForOTPURI": false, - }, - })) - .expect("failed to encode capabilities response") - } - _ => serde_json::json!({ - "STATUS": 3, - "Entries": [], - }), - }; - - let encrypted = encrypt_payload(&shared_key, &response_payload); - serde_json::to_vec(&serde_json::json!({ - "ok": true, - "code": 0, - "payload": { - "SMSG": { - "TID": "alice", - "SDATA": encrypted, - }, - }, - })) - .expect("failed to encode response") - }); - - let cases: &[CommandCase] = &[ - CommandCase { - name: "status-without-session", - rust_args: &["--json", "status"], - deno_args: &["--json", "status"], - require_session: false, - expect_code: 0, - }, - CommandCase { - name: "auth-request", - rust_args: &["--json", "auth", "request"], - deno_args: &["--json", "auth", "request"], - require_session: true, - expect_code: 0, - }, - CommandCase { - name: "auth-response-pin-mismatch", - rust_args: &[ - "--json", - "auth", - "response", - "--pin", - "123456", - "--salt", - "AQ==", - "--server_key", - "AQ==", - "--client_key", - "AQ==", - "--username", - "alice", - ], - deno_args: &[ - "--json", - "auth", - "response", - "--pin", - "123456", - "--salt", - "AQ==", - "--serverKey", - "AQ==", - "--clientKey", - "AQ==", - "--username", - "alice", - ], - require_session: true, - expect_code: 9, - }, - CommandCase { - name: "auth-response-pin-mismatch-short-flags", - rust_args: &[ - "--json", - "auth", - "response", - "-p", - "123456", - "-s", - "AQ==", - "--serverKey", - "AQ==", - "--clientKey", - "AQ==", - "-u", - "alice", - ], - deno_args: &[ - "--json", - "auth", - "response", - "--pin", - "123456", - "--salt", - "AQ==", - "--serverKey", - "AQ==", - "--clientKey", - "AQ==", - "--username", - "alice", - ], - require_session: true, - expect_code: 9, - }, - CommandCase { - name: "pw-list", - rust_args: &["--json", "pw", "list", "example.com"], - deno_args: &["--json", "pw", "list", "example.com"], - require_session: true, - expect_code: 0, - }, - CommandCase { - name: "pw-get", - rust_args: &["--json", "pw", "get", "example.com", "alice"], - deno_args: &["--json", "pw", "get", "example.com", "alice"], - require_session: true, - expect_code: 0, - }, - CommandCase { - name: "invalid-url-blocked-before-daemon", - rust_args: &["--json", "pw", "list", "bad host"], - deno_args: &["--json", "pw", "list", "bad host"], - require_session: false, - expect_code: 1, - }, - CommandCase { - name: "auth-logout", - rust_args: &["--json", "auth", "logout"], - deno_args: &["--json", "auth", "logout"], - require_session: true, - expect_code: 0, - }, - ]; - - run_with_temp_home(|home| { - let mut session_configured = false; - - for case in cases { - if case.require_session && !session_configured { - let _ = write_session_config(home, port, DEFAULT_SHARED_KEY_BYTES.as_slice(), true); - session_configured = true; - } - - let rust = run_rust_cli(home, case.rust_args); - let deno = run_deno_cli(home, case.deno_args); - - assert_eq!(rust.status, deno.status, "{} status mismatch", case.name); - assert_eq!(rust.status, case.expect_code, "{} code mismatch", case.name); - - let rust_payload = parse_json_from_output(&rust); - let deno_payload = parse_json_from_output(&deno); - - assert_eq!( - rust_payload["code"], deno_payload["code"], - "{} code envelope mismatch", - case.name - ); - assert_eq!( - rust_payload["ok"].as_bool(), - deno_payload["ok"].as_bool(), - "{} ok mismatch", - case.name - ); - assert_eq!( - rust_payload["code"], case.expect_code, - "{} expected code mismatch", - case.name - ); - - match case.name { - "status-without-session" => { - assert_eq!(rust_payload["payload"]["daemon"]["host"], "127.0.0.1"); - assert_eq!( - rust_payload["payload"]["daemon"]["host"], - deno_payload["payload"]["daemon"]["host"] - ); - assert_eq!(rust_payload["payload"]["session"]["authenticated"], false); - } - "auth-request" => { - assert!(rust_payload["payload"]["salt"].is_string()); - assert!(rust_payload["payload"]["serverKey"].is_string()); - assert!(rust_payload["payload"]["clientKey"].is_string()); - assert!(rust_payload["payload"]["username"].is_string()); - } - "auth-response-pin-mismatch" => { - assert_eq!(rust_payload["code"], deno_payload["code"]); - let rust_error = rust_payload["error"].as_str().unwrap_or("").to_string(); - let deno_error = deno_payload["error"].as_str().unwrap_or("").to_string(); - assert!(rust_error.contains("Incorrect"), "{}", case.name); - assert!(deno_error.contains("Incorrect"), "{}", case.name); - } - "auth-response-pin-mismatch-short-flags" => { - assert_eq!(rust_payload["code"], deno_payload["code"]); - let rust_error = rust_payload["error"].as_str().unwrap_or("").to_string(); - let deno_error = deno_payload["error"].as_str().unwrap_or("").to_string(); - assert!(rust_error.contains("Incorrect"), "{}", case.name); - assert!(deno_error.contains("Incorrect"), "{}", case.name); - } - "pw-list" | "pw-get" => { - assert_eq!( - rust_payload["payload"], deno_payload["payload"], - "{} payload mismatch", - case.name - ); - } - "invalid-url-blocked-before-daemon" => { - let rust_error = rust_payload["error"].as_str().unwrap_or("").to_string(); - let deno_error = deno_payload["error"].as_str().unwrap_or("").to_string(); - assert!(rust_error.contains("Invalid URL"), "{}", case.name); - assert!(deno_error.contains("Invalid URL"), "{}", case.name); - } - "auth-logout" => { - assert_eq!( - rust_payload["payload"]["status"], deno_payload["payload"]["status"], - "{} payload mismatch", - case.name - ); - assert_eq!(rust_payload["payload"]["status"], "logged out"); - } - _ => {} - } - } - }); - - handle.join().expect("daemon failed"); -} - -#[test] -#[serial] -fn deprecated_legacy_commands_emit_stderr_warning() { - // Regression for issue #9: every CLI subcommand routed through the - // legacy daemon path must announce its deprecation on stderr so that - // pinned scripts get a migration signal before the daemon is removed. - run_with_temp_home(|home| { - let pw = run_rust_cli(home, &["pw", "list", "https://example.com"]); - assert!( - pw.stderr.contains("legacy daemon path"), - "`apw pw` must emit the deprecation warning on stderr; got stderr=\"{}\"", - pw.stderr - ); - - let auth = run_rust_cli(home, &["auth", "--pin", "12ab"]); - assert!( - auth.stderr.contains("legacy daemon path"), - "`apw auth` must emit the deprecation warning; got stderr=\"{}\"", - auth.stderr - ); - }); -} diff --git a/rust/tests/native_app_e2e.rs b/rust/tests/native_app_e2e.rs index 91d7305..71122d1 100644 --- a/rust/tests/native_app_e2e.rs +++ b/rust/tests/native_app_e2e.rs @@ -428,7 +428,7 @@ fn wait_for_status(fixture: &NativeAppFixture) -> Value { let status = run_apw(fixture, &["status", "--json"], &[]); if status.status == 0 { let payload = parse_success(&status); - if payload["payload"]["app"]["service"]["running"] == true { + if payload["payload"]["service"]["running"] == true { return payload; } } @@ -443,7 +443,7 @@ fn wait_for_status(fixture: &NativeAppFixture) -> Value { fn wait_for_socket_transport(fixture: &NativeAppFixture) -> Value { for _ in 0..20 { let payload = wait_for_status(fixture); - if payload["payload"]["app"]["service"]["live"]["transport"] == "unix_socket" { + if payload["payload"]["service"]["live"]["transport"] == "unix_socket" { return payload; } thread::sleep(Duration::from_millis(200)); @@ -451,7 +451,7 @@ fn wait_for_socket_transport(fixture: &NativeAppFixture) -> Value { let payload = wait_for_status(fixture); assert_eq!( - payload["payload"]["app"]["service"]["live"]["transport"], "unix_socket", + payload["payload"]["service"]["live"]["transport"], "unix_socket", "{payload:#?}" ); payload @@ -498,12 +498,9 @@ fn app_install_copies_packaged_bundle_and_updates_status() { .exists()); let status_payload = wait_for_status(&fixture); - assert_eq!(status_payload["payload"]["app"]["installed"], true); - assert_eq!(status_payload["payload"]["app"]["bundleVersion"], "2.0.0"); - assert_eq!( - status_payload["payload"]["app"]["service"]["running"], - false - ); + assert_eq!(status_payload["payload"]["installed"], true); + assert_eq!(status_payload["payload"]["bundleVersion"], "2.0.0"); + assert_eq!(status_payload["payload"]["service"]["running"], false); } #[test] @@ -560,13 +557,13 @@ fn launch_status_and_login_work_over_socket() { ); let status_payload = wait_for_socket_transport(&fixture); - assert_eq!(status_payload["payload"]["app"]["service"]["running"], true); + assert_eq!(status_payload["payload"]["service"]["running"], true); assert_eq!( - status_payload["payload"]["app"]["service"]["live"]["serviceStatus"], + status_payload["payload"]["service"]["live"]["serviceStatus"], "running" ); assert_eq!( - status_payload["payload"]["app"]["service"]["live"]["transport"], + status_payload["payload"]["service"]["live"]["transport"], "unix_socket" ); diff --git a/rust/tests/security_regressions.rs b/rust/tests/security_regressions.rs index eba9f64..5e48ec7 100644 --- a/rust/tests/security_regressions.rs +++ b/rust/tests/security_regressions.rs @@ -54,29 +54,6 @@ fn repo_root() -> &'static Path { .expect("rust crate should live under repo root") } -fn write_launch_failure_config(home: &Path, last_launch_error: &str) { - let config = serde_json::json!({ - "schema": 1, - "port": 10_000, - "host": "127.0.0.1", - "username": "", - "sharedKey": "", - "runtimeMode": "auto", - "lastLaunchStatus": "failed", - "lastLaunchError": last_launch_error, - "lastLaunchStrategy": "direct", - "secretSource": "file", - "createdAt": Utc::now().to_rfc3339(), - }); - - fs::create_dir_all(home.join(".apw")).expect("failed to create config directory"); - fs::write( - home.join(".apw/config.json"), - serde_json::to_vec_pretty(&config).expect("failed to serialize config"), - ) - .expect("failed to write config"); -} - fn write_fallback_provider_config(home: &Path, provider_path: &str) { let config = serde_json::json!({ "schema": 1, @@ -190,44 +167,17 @@ fn threat_model_documents_current_v2_security_boundary() { #[test] #[serial] -fn command_invalid_pin_is_rejected_without_network() { +fn login_invalid_url_rejected_before_broker_dependency() { with_temp_home(|home| { - let (status, stdout, stderr) = run_command(home, &["--json", "auth", "--pin", "12ab"]); + let (status, stdout, stderr) = run_command(home, &["--json", "login", "ftp://example.com"]); assert_eq!( status, 2, "status={status}, stdout={stdout}, stderr={stderr}" ); let output = parse_json_output(&stderr); assert_eq!(output["code"], 2); - assert!(output["error"] - .as_str() - .unwrap_or("") - .contains("PIN must be exactly 6 digits.")); - }); -} - -#[test] -#[serial] -fn command_invalid_url_rejected_before_auth_dependency() { - with_temp_home(|home| { - let (status, stdout, stderr) = run_command(home, &["--json", "pw", "list", "bad host"]); - assert_eq!( - status, 1, - "status={status}, stdout={stdout}, stderr={stderr}" - ); - let output = parse_json_output(&stderr); - assert_eq!(output["code"], 1); assert_eq!(output["ok"], false); - assert!( - output["error"] - .as_str() - .unwrap_or("") - .contains("Invalid URL") - || output["error"] - .as_str() - .unwrap_or("") - .contains("Invalid URL host.") - ); + assert!(output["error"].as_str().unwrap_or("").contains("https URL")); }); } @@ -242,24 +192,26 @@ fn status_json_has_stable_shape() { ); let output = parse_json_output(&stdout); assert_eq!(output["ok"], true); - assert_eq!(output["payload"]["releaseLine"]["target"], "v2.0.0"); - assert!(output["payload"]["app"].is_object()); - assert_eq!(output["payload"]["app"]["installed"], false); - assert!(output["payload"]["daemon"]["host"].is_string()); - assert!(output["payload"]["daemon"]["port"].is_u64()); - assert!(output["payload"]["host"].is_object()); - assert!(output["payload"]["host"]["status"].is_null()); - assert!(output["payload"]["host"]["bundleVersion"].is_null()); - assert!(output["payload"]["host"]["connectedAt"].is_null()); - assert!(output["payload"]["host"]["lastError"].is_null()); - assert!(output["payload"]["bridge"].is_object()); - assert!(output["payload"]["bridge"]["status"].is_null()); - assert!(output["payload"]["bridge"]["browser"].is_null()); - assert!(output["payload"]["bridge"]["connectedAt"].is_null()); - assert!(output["payload"]["bridge"]["lastError"].is_null()); - assert_eq!(output["payload"]["session"]["authenticated"], false); - assert!(output["payload"]["session"]["createdAt"].is_string()); - assert!(output["payload"]["session"]["expired"].is_boolean()); + assert_eq!(output["payload"]["installed"], false); + assert!(output["payload"]["bundlePath"].is_string()); + assert!(output["payload"]["executablePath"].is_string()); + assert!(output["payload"]["socketPath"].is_string()); + assert!(output["payload"]["credentialsPath"].is_string()); + assert!(output["payload"]["brokerLogPath"].is_string()); + assert_eq!(output["payload"]["externalFallback"]["configured"], false); + assert_eq!( + output["payload"]["externalFallback"]["loginFlag"], + "--external-fallback" + ); + assert_eq!( + output["payload"]["service"]["transportContract"], + "typed_json_envelope" + ); + assert!(output["payload"]["service"]["requestTimeoutMs"].is_u64()); + assert!(output["payload"]["daemon"].is_null()); + assert!(output["payload"]["host"].is_null()); + assert!(output["payload"]["bridge"].is_null()); + assert!(output["payload"]["session"].is_null()); }); } @@ -405,47 +357,26 @@ fn status_binary_with_nonexistent_home_directory_isolated() { #[test] #[serial] -fn pw_list_reports_failed_launch_state_before_invalid_session() { +fn status_json_reports_native_app_surface_after_command_failure() { with_temp_home(|home| { - write_launch_failure_config(home, "helper test failure"); - let (status, stdout, stderr) = run_command(home, &["--json", "pw", "list", "example.com"]); - assert_eq!( - status, 103, - "status={status}, stdout={stdout}, stderr={stderr}" - ); - let output = parse_json_output(&stderr); - assert_eq!(output["code"], 103); - assert_eq!(output["ok"], false); - let error = output["error"].as_str().unwrap_or_default(); - assert!(error.contains("helper test failure")); - assert!(error.contains("daemon.preflight.status=")); - }); -} - -#[test] -#[serial] -fn status_json_preserves_failed_launch_metadata_after_command_failure() { - with_temp_home(|home| { - write_launch_failure_config( - home, - "Helper process was terminated by SIGKILL (Code Signature Constraint Violation).", - ); - let (status, stdout, stderr) = run_command(home, &["status", "--json"]); assert_eq!( status, 0, "status={status}, stdout={stdout}, stderr={stderr}" ); let initial = parse_json_output(&stdout); - assert_eq!(initial["payload"]["daemon"]["runtimeMode"], "auto"); - assert_eq!(initial["payload"]["daemon"]["lastLaunchStatus"], "failed"); - assert_eq!(initial["payload"]["daemon"]["lastLaunchStrategy"], "direct"); + assert!(initial["payload"]["bundlePath"].is_string()); + assert_eq!(initial["payload"]["service"]["transport"], "unix_socket"); + assert_eq!( + initial["payload"]["service"]["transportContract"], + "typed_json_envelope" + ); - let (pw_status, pw_stdout, pw_stderr) = - run_command(home, &["--json", "pw", "list", "example.com"]); + let (login_status, login_stdout, login_stderr) = + run_command(home, &["--json", "login", "ftp://example.com"]); assert_eq!( - pw_status, 103, - "status={pw_status}, stdout={pw_stdout}, stderr={pw_stderr}" + login_status, 2, + "status={login_status}, stdout={login_stdout}, stderr={login_stderr}" ); let (status_after, stdout_after, stderr_after) = run_command(home, &["status", "--json"]); @@ -454,12 +385,11 @@ fn status_json_preserves_failed_launch_metadata_after_command_failure() { "status={status_after}, stdout={stdout_after}, stderr={stderr_after}" ); let after = parse_json_output(&stdout_after); - assert_eq!(after["payload"]["daemon"]["runtimeMode"], "auto"); - assert_eq!(after["payload"]["daemon"]["lastLaunchStatus"], "failed"); + assert!(after["payload"]["daemon"].is_null()); + assert_eq!(after["payload"]["service"]["transport"], "unix_socket"); assert_eq!( - after["payload"]["daemon"]["lastLaunchError"], - "Helper process was terminated by SIGKILL (Code Signature Constraint Violation)." + after["payload"]["externalFallback"]["loginFlag"], + "--external-fallback" ); - assert_eq!(after["payload"]["session"]["authenticated"], false); }); } diff --git a/scripts/release-bootstrap.sh b/scripts/release-bootstrap.sh index 5e028b9..be713c3 100755 --- a/scripts/release-bootstrap.sh +++ b/scripts/release-bootstrap.sh @@ -290,7 +290,6 @@ cargo fmt --manifest-path "$CARGO_MANIFEST" -- --check cargo clippy --manifest-path "$CARGO_MANIFEST" --all-targets -- -D warnings if [[ "$SKIP_TESTS" -eq 0 ]]; then cargo test --manifest-path "$CARGO_MANIFEST" - cargo test --manifest-path "$CARGO_MANIFEST" --test legacy_parity cargo test --manifest-path "$CARGO_MANIFEST" --test security_regressions fi @@ -309,10 +308,8 @@ printf '\n[5/9] Health check release binary...\n' "$BIN_PATH" status --json if [[ "$HOST_SMOKE" -eq 1 ]]; then - printf '\n[6/9] Running browser host smoke...\n' - "$ROOT_DIR/scripts/browser-host-smoke.sh" \ - --bin "$BIN_PATH" \ - --pw-domain "$HOST_SMOKE_PW_DOMAIN" + printf '\n[6/9] Browser host smoke is archived for v2.1.0.\n' + echo "Use legacy/scripts/browser-host-smoke.sh only for explicit historical audits." fi printf '\n[7/9] Creating tag %s...\n' "$TAG"