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/MIGRATION_AND_PARITY.md b/docs/MIGRATION_AND_PARITY.md index 194392d..ab0fdb8 100644 --- a/docs/MIGRATION_AND_PARITY.md +++ b/docs/MIGRATION_AND_PARITY.md @@ -11,18 +11,13 @@ Release reference version: `v2.0.0` - Supported v2 implementation: [`rust/`](../rust/) + `native-app/` - Archived implementation: [`legacy/deno/`](../legacy/deno/) - 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 that remains in-tree is migration-only and + is no longer exposed through the active CLI contract -## 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 +25,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,7 +76,8 @@ cargo clippy --manifest-path rust/Cargo.toml --all-targets -- -D warnings cargo test --manifest-path rust/Cargo.toml --all-targets ``` -Legacy parity harness: +Legacy parity harness for retained status-shape compatibility and removed-command +regression coverage: ```bash cargo test --manifest-path rust/Cargo.toml --test legacy_parity diff --git a/docs/NATIVE_MIGRATION.md b/docs/NATIVE_MIGRATION.md index 2250c06..e6ea2e1 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 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..5751849 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 diff --git a/rust/src/cli.rs b/rust/src/cli.rs index f135eed..21a0f1c 100644 --- a/rust/src/cli.rs +++ b/rust/src/cli.rs @@ -1,70 +1,13 @@ 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, }; -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 +71,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 +95,10 @@ 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,38 +139,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)] @@ -345,47 +158,6 @@ pub struct HostDoctorArgs { 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)] @@ -398,13 +170,10 @@ pub struct VersionCommand {} pub async fn run(mut manager: ApplePasswordManager, 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::Version(args) => run_version(args, cli.json), } @@ -499,48 +268,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 => { @@ -560,59 +287,6 @@ fn run_host(args: HostCommand, cli_json: bool) -> Result<(), APWError> { 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 +375,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 +415,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 +516,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,115 +523,17 @@ 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"), + fn legacy_daemon_commands_are_rejected() { + for args in [ + &["apw", "auth"][..], + &["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:?}" + ); } } @@ -1066,17 +575,6 @@ mod tests { } } - #[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/client.rs b/rust/src/client.rs index f11aaa4..ac5a847 100644 --- a/rust/src/client.rs +++ b/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/rust/src/daemon.rs index a7a4718..c598640 100644 --- a/rust/src/daemon.rs +++ b/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/rust/src/host.rs index 61b5d6a..f524250 100644 --- a/rust/src/host.rs +++ b/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/main.rs b/rust/src/main.rs index 3b4624c..9248a8e 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -1,16 +1,24 @@ use clap::Parser; mod cli; +// Retained legacy modules still power status/parity diagnostics until #47 +// archives the browser-helper and native-host runtime internals. +#[allow(dead_code)] mod client; +#[allow(dead_code)] mod daemon; mod doctor; mod error; +#[allow(dead_code)] mod host; mod logging; mod native_app; +#[allow(dead_code)] mod secrets; +#[allow(dead_code)] mod srp; mod types; +#[allow(dead_code)] mod utils; use cli::{run, Cli}; @@ -21,9 +29,7 @@ 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(); @@ -51,40 +57,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/srp.rs b/rust/src/srp.rs index 0163985..ae4754c 100644 --- a/rust/src/srp.rs +++ b/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/rust/src/types.rs b/rust/src/types.rs index 77bd9e2..d5f73b9 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -682,7 +682,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 +717,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..19ede6f 100644 --- a/rust/src/utils.rs +++ b/rust/src/utils.rs @@ -176,7 +176,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 +419,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 +486,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 +564,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 +626,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() { diff --git a/rust/tests/legacy_parity.rs b/rust/tests/legacy_parity.rs index b04370e..62f2981 100644 --- a/rust/tests/legacy_parity.rs +++ b/rust/tests/legacy_parity.rs @@ -1,22 +1,9 @@ -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, @@ -76,116 +63,11 @@ fn run_deno_cli(home: &Path, args: &[&str]) -> CommandOutput { } } -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, @@ -205,60 +87,23 @@ where 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() { +fn removed_legacy_daemon_commands_are_not_active_contract() { run_with_temp_home(|home| { for args in [ - &["auth", "logout"][..], - &["pw", "list", "bad host"][..], - &["start", "--dry-run"][..], + &["auth"][..], + &["pw", "list", "example.com"][..], + &["otp", "list", "example.com"][..], + &["start"][..], ] { let output = run_rust_cli(home, args); - assert!( - output.stderr.contains("legacy daemon path"), - "{args:?} did not emit deprecation warning: {:?}", - output.stderr + assert_ne!( + output.status, 0, + "removed legacy command unexpectedly succeeded: {args:?}" ); assert!( - output.stderr.contains("v2.1.0"), - "{args:?} did not include removal milestone: {:?}", + output.stderr.contains("unrecognized subcommand"), + "removed legacy command should be rejected by clap: {args:?}, stderr={:?}", output.stderr ); } @@ -266,593 +111,31 @@ fn legacy_daemon_commands_emit_deprecation_warning() { } #[test] -#[serial] -fn parity_auth_request_shape_matches_legacy() { +fn parity_status_output_with_no_session_is_shape_compatible() { 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"]); + 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:#?}"); - 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["ok"], deno_payload["ok"]); 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 + 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/rust/tests/security_regressions.rs b/rust/tests/security_regressions.rs index eba9f64..3d075ce 100644 --- a/rust/tests/security_regressions.rs +++ b/rust/tests/security_regressions.rs @@ -190,44 +190,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")); }); } @@ -403,25 +376,6 @@ fn status_binary_with_nonexistent_home_directory_isolated() { assert!(status.1.contains("\"ok\":true")); } -#[test] -#[serial] -fn pw_list_reports_failed_launch_state_before_invalid_session() { - 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() { @@ -441,11 +395,11 @@ fn status_json_preserves_failed_launch_metadata_after_command_failure() { assert_eq!(initial["payload"]["daemon"]["lastLaunchStatus"], "failed"); assert_eq!(initial["payload"]["daemon"]["lastLaunchStrategy"], "direct"); - 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"]);