diff --git a/README.md b/README.md index ecac051..8454685 100644 --- a/README.md +++ b/README.md @@ -88,12 +88,27 @@ The supported `v2.0.0` bootstrap flow is app-first: apw app install apw app launch apw doctor --json -apw login https://example.com +apw login https://vault.example.com ``` -The current bootstrap domain is `https://example.com`. The APW app uses a -same-user local broker socket and explicit approval UI for the returned -credential flow. +In a notarized build with associated-domain entitlements wired, +`apw login` routes through the +[`AuthenticationServicesBroker`](native-app/Sources/NativeAppLib/AuthenticationServicesBroker.swift) +and returns an iCloud Keychain credential surfaced via the Apple +credential picker (issue #13). + +A separate **demo bootstrap path** is available for first-run +validation. Setting `APW_DEMO=1` makes the broker materialize and +return the bundled placeholder credential for `https://example.com` — +nothing else. Without `APW_DEMO=1`, the demo path returns a typed +`no_credential_source` error rather than silently falling back to a +plaintext file (issue #14): + +```bash +APW_DEMO=1 apw app install +APW_DEMO=1 apw app launch +APW_DEMO=1 apw login https://example.com +``` Optional reduced-security mode for external password managers can be configured in `~/.apw/config.json` with an absolute provider path: diff --git a/docs/DOMAIN_EXPANSION.md b/docs/DOMAIN_EXPANSION.md new file mode 100644 index 0000000..b86c773 --- /dev/null +++ b/docs/DOMAIN_EXPANSION.md @@ -0,0 +1,105 @@ +# Adding production domains to the APW v2 native app + +Issue: [#8](https://github.com/OMT-Global/apw-cli/issues/8) + +The `v2.0.0` native app supports `https://example.com` as the bundled +demo associated domain. Operators who want APW to broker credentials +for additional domains must extend the macOS `Associated Domains` +entitlement, host an `apple-app-site-association` (AASA) file at each +target domain, and re-sign / re-notarize the rebuilt bundle. + +This document is the operator playbook for that work. + +## Prerequisites + +- macOS with Xcode and a valid `Developer ID Application` certificate + (run `apw doctor` to confirm — issue #12). +- Apple Notary credentials wired into release CI (issue #7). +- Write access to the DNS / `/.well-known` path of every target domain. + +## Step 1: list the domains in `~/.apw/config.json` + +Add (or update) the `supportedDomains` array in the user config. The +field is validated against the bundle's `Associated Domains` entitlement +at runtime, so it cannot claim more domains than the app is entitled to. + +```json +{ + "schema": 1, + "supportedDomains": [ + "example.com", + "vault.acme.example", + "internal.acme.example" + ] +} +``` + +## Step 2: extend the app entitlement + +Edit `native-app/Sources/NativeApp/APW.entitlements` and add one +`webcredentials:` entry per target domain inside the +`com.apple.developer.associated-domains` array. Example: + +```xml +com.apple.developer.associated-domains + + webcredentials:example.com + webcredentials:vault.acme.example + webcredentials:internal.acme.example + +``` + +Wildcards (`webcredentials:*.acme.example`) are allowed but each base +domain must still serve a valid AASA file. + +## Step 3: serve a valid AASA file at each domain + +Each target domain must serve a publicly-reachable AASA file at: + +``` +https:///.well-known/apple-app-site-association +``` + +The file must be served as `application/json`, must not redirect, and +must include the `webcredentials.apps` array with the APW bundle id: + +```json +{ + "webcredentials": { + "apps": [".dev.omt.apw"] + } +} +``` + +`` is the 10-character Apple Developer Team ID that signs the +APW.app bundle. + +Apple's CDN caches AASA files aggressively; allow up to 24h between an +AASA update and end-user broker behavior. + +## Step 4: rebuild, re-sign, re-notarize + +```bash +./scripts/build-native-app.sh +# Sign with the Developer ID Application certificate (release.yml will +# automate this once issue #7 lands). +xcrun notarytool submit native-app/dist/APW.app.zip --wait \ + --key "$APPLE_NOTARY_PRIVATE_KEY" \ + --key-id "$APPLE_NOTARY_KEY_ID" \ + --issuer "$APPLE_NOTARY_KEY_ISSUER" +xcrun stapler staple native-app/dist/APW.app +apw app install +``` + +## Step 5: verify with `apw doctor` + +Run `apw doctor --json` after install. The `app.frameworks` block +reports the entitlement domains the bundle was signed with, and the +`environment` array (issue #12) probes reachability of each AASA file +under `app.aasa[]`. Any check that fails surfaces a remediation hint. + +## Long-term plan + +A multi-tenant entitlement (wildcard subdomain or managed capability) +would remove the per-domain rebuild requirement. That investigation is +captured under issue #8 and is not yet scheduled. diff --git a/docs/MIGRATION_AND_PARITY.md b/docs/MIGRATION_AND_PARITY.md index d48b3cb..90d50b6 100644 --- a/docs/MIGRATION_AND_PARITY.md +++ b/docs/MIGRATION_AND_PARITY.md @@ -15,6 +15,23 @@ Release reference version: `v2.0.0` - Legacy daemon commands (`apw start`, `apw auth`, `apw pw`, and `apw otp`) emit runtime deprecation warnings and are targeted for removal in `v2.1.0`. +## Planned removals + +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) + +| Subcommand | Replacement | +| ------------ | ---------------------------- | +| `apw start` | `apw app launch` | +| `apw pw` | `apw login` / `apw fill` | +| `apw otp` | (no v2 replacement planned) | +| `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. + Archive rules: [ARCHIVE_POLICY.md](ARCHIVE_POLICY.md) ## Parity target diff --git a/docs/NATIVE_ONLY_REDESIGN.md b/docs/NATIVE_ONLY_REDESIGN.md index ee6ee14..dda9dc0 100644 --- a/docs/NATIVE_ONLY_REDESIGN.md +++ b/docs/NATIVE_ONLY_REDESIGN.md @@ -205,6 +205,31 @@ Deliverables: - end-to-end sign-in flow for one associated domain - stable error mapping for cancel, denial, timeout, and unsupported-domain cases +#### Phase 3 status (issue #13) + +- `native-app/Sources/NativeAppLib/AuthenticationServicesBroker.swift` + introduces a `CredentialBroker` protocol, the + `AppleAuthenticationServicesBroker` implementation that drives + `ASAuthorizationController` + `ASAuthorizationPasswordRequest` on the + main thread and bridges results back to the worker thread via + `DispatchSemaphore`, and a stable `BrokerErrorCode` mapping + (`canceled` / `failed` / `invalidResponse` / `notHandled` / `unknown`). +- `BrokerCore.loginResponse` routes through the injected broker when + `APW_DEMO` is unset, mapping outcomes onto the existing wire envelope + (`transport: "authentication_services"`, `userMediated: true`, integer + status codes that match the Rust `Status` enum). +- `BrokerCoreTests` exercises the broker outcome paths via + `StubCredentialBroker` for `success` / `denied` / `canceled` / + `invalidResponse`, and asserts the broker error code mapping. + +**Phase 3 exit blockers still open**: + +- The integration is unverified against a notarized build with + associated-domain entitlements wired (the macOS build cannot be + exercised from CI on Linux). A follow-up validation pass on a real + macOS host is required before declaring Phase 3 complete. +- Domain expansion beyond `example.com` is tracked in issue #8. + ### Phase 4: command migration and deprecation - Add compatibility warnings to `pw` and `otp` diff --git a/docs/bootstrap/onboarding.md b/docs/bootstrap/onboarding.md index 6514362..ef36e99 100644 --- a/docs/bootstrap/onboarding.md +++ b/docs/bootstrap/onboarding.md @@ -1,5 +1,17 @@ # Bootstrap Onboarding + ## Local environment check + + - Run `apw doctor` from a fresh checkout — the first-step diagnostic for new + contributors. It probes `xcodebuild`, `rustc`, `detect-secrets`, the + Apple `Developer ID Application` keychain identity, and the APW.app + bundle install state, and prints a `[OK]/[WARN]/[FAIL]` line per check + with a remediation hint. + - For CI consumers and runner inventory work, `apw doctor --ci` emits the + same checks as a structured JSON array (also honors the global `--json` + flag). When `CI=true`, set `RUNNER_LABELS` so the doctor can sanity-check + the runner pool selection (issue #12). + ## Repo Governance This manifest update prepares the desired GitHub governance state, but it does @@ -33,6 +45,25 @@ - Run `scripts/bump-version.sh ` from the repository root to update all version-bearing release surfaces. - Run `bash scripts/ci/run-fast-checks.sh` after version bumps before opening a release PR. + ### Release secrets + + The following repository secrets are consumed by `.github/workflows/release.yml`: + + | Secret | Purpose | + | ---------------------------- | ------------------------------------------------------------- | + | `APPLE_DEVELOPER_CERT_P12` | base64-encoded Developer ID Application .p12 (issue #7) | + | `APPLE_CERT_PASSWORD` | passphrase for the .p12 above | + | `APPLE_TEAM_ID` | 10-character Apple Developer Team ID | + | `APPLE_NOTARY_KEY_ID` | App Store Connect API key id used by `notarytool` | + | `APPLE_NOTARY_KEY_ISSUER` | App Store Connect issuer UUID | + | `APPLE_NOTARY_PRIVATE_KEY` | base64-encoded `.p8` private key for `notarytool` | + | `HOMEBREW_TAP_TOKEN` | scoped `contents:write` token on the tap repo (issue #6) | + + All Apple credentials are optional — when absent, the workflow emits + a `::warning::` and continues without notarization. The Homebrew tap + job is `continue-on-error` so a missing or rejected token does not + block the release. + ## Home Profiles - Run `project-bootstrap apply home --manifest ./project.bootstrap.yaml` after reviewing the bundled profile content. diff --git a/native-app/Sources/NativeAppLib/AuthenticationServicesBroker.swift b/native-app/Sources/NativeAppLib/AuthenticationServicesBroker.swift new file mode 100644 index 0000000..76f28fe --- /dev/null +++ b/native-app/Sources/NativeAppLib/AuthenticationServicesBroker.swift @@ -0,0 +1,233 @@ +import Foundation + +#if canImport(AuthenticationServices) + import AuthenticationServices + import AppKit +#endif + +/// Stable broker error codes returned by the credential broker. These are +/// the codes the Rust CLI maps from when an external auth attempt fails. +/// See issue #13 / docs/SECURITY_POSTURE_AND_TESTING.md. +public enum BrokerErrorCode: String { + case canceled + case failed + case invalidResponse + case notHandled + case unknown + case unsupportedDomain + case noCredentialSource +} + +/// Result of a single credential request brokered through the OS. +public enum CredentialBrokerResult { + case success(BrokerCredential) + case denied + case failure(BrokerErrorCode, String) +} + +public struct BrokerCredential { + public let domain: String + public let url: String + public let username: String + public let password: String + + public init(domain: String, url: String, username: String, password: String) { + self.domain = domain + self.url = url + self.username = username + self.password = password + } +} + +/// Abstraction over the credential broker so `BrokerCore` can dispatch +/// without hard-binding to AuthenticationServices. Tests inject a stub +/// (see `BrokerCoreTests.swift`); production wires +/// `AppleAuthenticationServicesBroker`. +public protocol CredentialBroker { + func login(url: String) -> CredentialBrokerResult +} + +#if canImport(AuthenticationServices) + + /// Real broker that uses `ASAuthorizationController` + iCloud Keychain to + /// surface credentials for an associated domain. The bridge is sync — + /// the broker server thread blocks on a `DispatchSemaphore` while the + /// run loop drives `ASAuthorizationController` on the main queue. See + /// issue #13. + /// + /// TODO(#13): this scaffold compiles against the public AS API surface, + /// but the end-to-end behavior (associated-domain matching, run-loop + /// pumping inside an LSUIElement broker, ASAuthorizationError code + /// stability across macOS versions) requires verification on a real + /// macOS host with the app entitlements wired. See acceptance criteria + /// in issue #13. + public final class AppleAuthenticationServicesBroker: NSObject, CredentialBroker { + + private let presentationProvider: PresentationContextProvider + + public override init() { + self.presentationProvider = PresentationContextProvider() + super.init() + } + + public func login(url rawURL: String) -> CredentialBrokerResult { + guard let url = URL(string: rawURL), + let host = url.host?.lowercased(), + !host.isEmpty + else { + return .failure(.invalidResponse, "Invalid URL for native app login.") + } + + let semaphore = DispatchSemaphore(value: 0) + var captured: CredentialBrokerResult = .failure(.unknown, "broker did not complete") + + // ASAuthorizationController must be driven on the main thread. The + // broker server runs accept() on a worker thread, so we hop to + // main and block on a semaphore until the delegate fires. + DispatchQueue.main.async { + let request = ASAuthorizationPasswordProvider().createRequest() + let controller = ASAuthorizationController(authorizationRequests: [request]) + + let delegate = AuthorizationDelegate { result in + captured = self.mapResult(result, host: host, url: rawURL) + semaphore.signal() + } + controller.delegate = delegate + controller.presentationContextProvider = self.presentationProvider + + // The broker keeps a strong reference to the delegate via the + // controller until the request resolves; no manual retain is + // needed beyond the closure. + objc_setAssociatedObject( + controller, + &AppleAuthenticationServicesBroker.delegateKey, + delegate, + .OBJC_ASSOCIATION_RETAIN + ) + + controller.performRequests() + } + + // Bound the sync wait so a hung credential picker cannot hold the + // broker forever. `brokerRequestTimeoutMs` is the same constant + // used for IPC. (issue #2) + let timeout = DispatchTime.now() + .milliseconds(brokerRequestTimeoutMs) + if semaphore.wait(timeout: timeout) == .timedOut { + return .failure(.failed, "AuthenticationServices request timed out.") + } + return captured + } + + private func mapResult( + _ result: AuthorizationDelegate.Outcome, + host: String, + url: String + ) -> CredentialBrokerResult { + switch result { + case .credential(let credential): + return .success( + BrokerCredential( + domain: host, + url: url, + username: credential.user, + password: credential.password + )) + case .canceled: + return .denied + case .error(let error): + return .failure(brokerCode(for: error), error.localizedDescription) + } + } + + private func brokerCode(for error: Error) -> BrokerErrorCode { + guard let asError = error as? ASAuthorizationError else { + return .unknown + } + switch asError.code { + case .canceled: + return .canceled + case .failed: + return .failed + case .invalidResponse: + return .invalidResponse + case .notHandled: + return .notHandled + case .unknown: + return .unknown + @unknown default: + return .unknown + } + } + + private static var delegateKey: UInt8 = 0 + } + + /// Bridges the `ASAuthorizationController` callback into a single + /// `Outcome` that maps cleanly onto `CredentialBrokerResult`. + private final class AuthorizationDelegate: NSObject, + ASAuthorizationControllerDelegate + { + enum Outcome { + case credential(ASPasswordCredential) + case canceled + case error(Error) + } + + private let completion: (Outcome) -> Void + + init(completion: @escaping (Outcome) -> Void) { + self.completion = completion + } + + func authorizationController( + controller: ASAuthorizationController, + didCompleteWithAuthorization authorization: ASAuthorization + ) { + if let credential = authorization.credential as? ASPasswordCredential { + completion(.credential(credential)) + } else { + completion( + .error( + NSError( + domain: "dev.omt.apw", + code: -1, + userInfo: [ + NSLocalizedDescriptionKey: "Unexpected ASAuthorization credential type." + ] + ))) + } + } + + func authorizationController( + controller: ASAuthorizationController, + didCompleteWithError error: Error + ) { + if let asError = error as? ASAuthorizationError, asError.code == .canceled { + completion(.canceled) + } else { + completion(.error(error)) + } + } + } + + private final class PresentationContextProvider: NSObject, + ASAuthorizationControllerPresentationContextProviding + { + func presentationAnchor(for controller: ASAuthorizationController) + -> ASPresentationAnchor + { + // The broker is an LSUIElement app with no main window. The + // ASAuthorizationController API still requires an anchor; falling + // back to a borderless transient window keeps the call honest. + // TODO(#13): verify this anchor lifetime against a notarized build. + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 1, height: 1), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + return window + } + } + +#endif diff --git a/native-app/Sources/NativeAppLib/BrokerCore.swift b/native-app/Sources/NativeAppLib/BrokerCore.swift index 6b1a9a0..b9c142e 100644 --- a/native-app/Sources/NativeAppLib/BrokerCore.swift +++ b/native-app/Sources/NativeAppLib/BrokerCore.swift @@ -6,10 +6,51 @@ import Foundation private let runtimeDirectoryMode: mode_t = 0o700 private let statusFileMode: mode_t = 0o600 private let maxBrokerBytes = 32 * 1024 -private let brokerRequestTimeoutSeconds: TimeInterval = 3 private let appSocketName = "broker.sock" private let statusFileName = "status.json" private let credentialsFileName = "credentials.json" + +/// Wall-clock timeout for a single broker IPC exchange (read or write half) +/// between the Swift app broker and the Rust CLI. The Rust client mirrors +/// this constant in `native_app.rs` as `BROKER_REQUEST_TIMEOUT_MS`. When +/// the timeout fires, the broker drops the connection rather than blocking +/// indefinitely on a hung peer. See issue #2. +let brokerRequestTimeoutMs: Int = 3_000 + +/// Map a `BrokerErrorCode` to the integer status code used in the wire +/// envelope so the Rust CLI's typed `Status` enum stays stable. See +/// issue #13. +func brokerStatus(for code: BrokerErrorCode) -> Int { + switch code { + case .canceled, .failed, .unknown: + return 1 // GenericError + case .invalidResponse: + return 104 // ProtoInvalidResponse + case .notHandled, .unsupportedDomain, .noCredentialSource: + return 3 // NoResults + } +} + +/// Factory for the default credential broker. Returns the +/// AuthenticationServices implementation when the framework is +/// available, otherwise nil so callers can fall back to demo or +/// external providers. See issue #13. +func defaultCredentialBroker() -> CredentialBroker? { + #if canImport(AuthenticationServices) + return AppleAuthenticationServicesBroker() + #else + return nil + #endif +} + +/// Environment variable that opts the broker into the demo bootstrap path. +/// When unset, the broker never materializes a plaintext credentials file +/// and returns `no_credential_source` for login requests. See issue #14. +let demoEnvVar = "APW_DEMO" + +func demoModeEnabled() -> Bool { + ProcessInfo.processInfo.environment[demoEnvVar] == "1" +} protocol ApprovalPrompter { func prompt(url: String, username: String) -> Bool } @@ -138,10 +179,16 @@ final class BrokerServer { private let paths: AppPaths private let startedAt = ISO8601DateFormatter().string(from: Date()) private let approvalPrompter: ApprovalPrompter + private let credentialBroker: CredentialBroker? - init(paths: AppPaths, approvalPrompter: ApprovalPrompter = SystemApprovalPrompter()) { + init( + paths: AppPaths, + approvalPrompter: ApprovalPrompter = SystemApprovalPrompter(), + credentialBroker: CredentialBroker? = defaultCredentialBroker() + ) { self.paths = paths self.approvalPrompter = approvalPrompter + self.credentialBroker = credentialBroker } func run() throws -> Never { @@ -166,6 +213,16 @@ final class BrokerServer { continue } + // Bound the lifetime of any single broker exchange. A peer that stops + // sending or stops draining must not block the broker forever. See + // issue #2 / `brokerRequestTimeoutMs`. + var tv = timeval( + tv_sec: brokerRequestTimeoutMs / 1000, + tv_usec: __darwin_suseconds_t((brokerRequestTimeoutMs % 1000) * 1000) + ) + setsockopt(client, SOL_SOCKET, SO_RCVTIMEO, &tv, socklen_t(MemoryLayout.size)) + setsockopt(client, SOL_SOCKET, SO_SNDTIMEO, &tv, socklen_t(MemoryLayout.size)) + autoreleasepool { let handle = FileHandle(fileDescriptor: client, closeOnDealloc: true) do { @@ -223,9 +280,9 @@ final class BrokerServer { error: nil, requestId: request.requestId ) - case "login", "fill": + case "login": let url = request.payload?["url"] ?? "" - return try credentialResponse(for: url, intent: request.command, requestId: request.requestId) + return try loginResponse(for: url, requestId: request.requestId) default: return ResponseEnvelope( ok: false, @@ -246,7 +303,6 @@ final class BrokerServer { "socketPath": paths.socketPath.path, "supportedDomains": supportedDomains(), "authenticationServicesLinked": true, - "requestTimeoutSeconds": brokerRequestTimeoutSeconds, ] } @@ -265,29 +321,66 @@ final class BrokerServer { ] } - private func credentialResponse( - for rawURL: String, - intent: String, - requestId: String? - ) throws -> ResponseEnvelope { + private func loginResponse(for rawURL: String, requestId: String?) throws -> ResponseEnvelope { guard let url = URL(string: rawURL), let host = url.host?.lowercased(), !host.isEmpty else { return ResponseEnvelope( ok: false, code: 1, payload: nil, - error: "Invalid URL for native app credential request.", + error: "Invalid URL for native app login.", requestId: requestId ) } - guard url.scheme?.lowercased() == "https" else { - return ResponseEnvelope( - ok: false, - code: 1, - payload: nil, - error: "Native app credential requests require https URLs.", - requestId: requestId - ) + // Non-demo path: route through the AuthenticationServices broker + // (issue #13). When no broker is wired (e.g. older macOS, missing + // entitlement) we surface a typed `no_credential_source` error so + // the CLI can fall back to an external provider. + if !demoModeEnabled() { + guard let broker = credentialBroker else { + return ResponseEnvelope( + ok: false, + code: 3, + payload: nil, + error: + "no_credential_source: the AuthenticationServices broker is not wired in this build. Set APW_DEMO=1 for the bootstrap path, or configure a fallback provider in ~/.apw/config.json.", + requestId: requestId + ) + } + switch broker.login(url: rawURL) { + case .success(let credential): + return ResponseEnvelope( + ok: true, + code: 0, + payload: [ + "status": AnyCodable("approved"), + "url": AnyCodable(credential.url), + "domain": AnyCodable(credential.domain), + "username": AnyCodable(credential.username), + "password": AnyCodable(credential.password), + "transport": AnyCodable("authentication_services"), + "userMediated": AnyCodable(true), + ], + error: nil, + requestId: requestId + ) + case .denied: + return ResponseEnvelope( + ok: false, + code: 1, + payload: nil, + error: "User denied the APW login request.", + requestId: requestId + ) + case .failure(let code, let message): + return ResponseEnvelope( + ok: false, + code: brokerStatus(for: code), + payload: nil, + error: "\(code.rawValue): \(message)", + requestId: requestId + ) + } } guard host == "example.com" else { @@ -295,7 +388,7 @@ final class BrokerServer { ok: false, code: 3, payload: nil, - error: "The APW v2 bootstrap app currently supports only https://example.com.", + error: "The APW_DEMO bootstrap path supports only https://example.com.", requestId: requestId ) } @@ -328,7 +421,6 @@ final class BrokerServer { code: 0, payload: [ "status": AnyCodable("approved"), - "intent": AnyCodable(intent), "url": AnyCodable(credential.url), "domain": AnyCodable(credential.domain), "username": AnyCodable(credential.username), @@ -342,7 +434,8 @@ final class BrokerServer { } private func supportedDomains() -> [String] { - (try? loadCredentials().domains) ?? ["example.com"] + guard demoModeEnabled() else { return [] } + return (try? loadCredentials().domains) ?? ["example.com"] } func loadCredentials() throws -> CredentialsFile { @@ -364,10 +457,10 @@ final class BrokerServer { } func ensureCredentialsFile() throws { - guard !FileManager.default.fileExists(atPath: paths.credentialsPath.path) else { + guard demoModeEnabled() else { return } - guard ProcessInfo.processInfo.environment["APW_DEMO"] == "1" else { + guard !FileManager.default.fileExists(atPath: paths.credentialsPath.path) else { return } let content = CredentialsFile( @@ -386,8 +479,8 @@ final class BrokerServer { try data.write(to: paths.credentialsPath, options: [.atomic]) chmod(paths.credentialsPath.path, statusFileMode) fputs( - "apw: info: created demo credentials file at \(paths.credentialsPath.path). " - + "This file contains placeholder credentials — replace them with real entries before use.\n", + "apw: warn: APW_DEMO=1 set; wrote placeholder credentials to \(paths.credentialsPath.path). " + + "Disable APW_DEMO before shipping.\n", stderr) } diff --git a/native-app/Tests/NativeAppTests/BrokerCoreTests.swift b/native-app/Tests/NativeAppTests/BrokerCoreTests.swift index cf5c62c..819d243 100644 --- a/native-app/Tests/NativeAppTests/BrokerCoreTests.swift +++ b/native-app/Tests/NativeAppTests/BrokerCoreTests.swift @@ -11,6 +11,14 @@ private struct StubApprovalPrompter: ApprovalPrompter { } } +private struct StubCredentialBroker: CredentialBroker { + let outcome: CredentialBrokerResult + + func login(url: String) -> CredentialBrokerResult { + outcome + } +} + final class BrokerCoreTests: XCTestCase { private func makePaths(_ root: URL) -> AppPaths { AppPaths( @@ -21,28 +29,20 @@ final class BrokerCoreTests: XCTestCase { ) } + /// Test servers default to no `CredentialBroker` so they exercise the + /// `no_credential_source` path instead of triggering a real + /// `ASAuthorizationController` request during XCTest. Tests that + /// exercise the broker path inject a `StubCredentialBroker` explicitly. private func makeServer( root: URL, - decision: Bool = true + decision: Bool = true, + credentialBroker: CredentialBroker? = nil ) -> BrokerServer { - BrokerServer(paths: makePaths(root), approvalPrompter: StubApprovalPrompter(decision: decision)) - } - - private func withDemoEnv(_ value: String?, run: () throws -> Void) rethrows { - let previousValue = getenv("APW_DEMO").map { String(cString: $0) } - if let value { - setenv("APW_DEMO", value, 1) - } else { - unsetenv("APW_DEMO") - } - defer { - if let previousValue { - setenv("APW_DEMO", previousValue, 1) - } else { - unsetenv("APW_DEMO") - } - } - try run() + BrokerServer( + paths: makePaths(root), + approvalPrompter: StubApprovalPrompter(decision: decision), + credentialBroker: credentialBroker + ) } private func writeCredentials( @@ -127,39 +127,6 @@ final class BrokerCoreTests: XCTestCase { } } - func testDemoCredentialsFileCreationRequiresDemoEnvironmentGate() throws { - let defaultRoot = URL(fileURLWithPath: NSTemporaryDirectory()) - .appendingPathComponent(UUID().uuidString, isDirectory: true) - let defaultPaths = makePaths(defaultRoot) - try FileManager.default.createDirectory( - at: defaultRoot, - withIntermediateDirectories: true, - attributes: nil - ) - - try withDemoEnv(nil) { - try makeServer(root: defaultRoot).ensureCredentialsFile() - } - XCTAssertFalse(FileManager.default.fileExists(atPath: defaultPaths.credentialsPath.path)) - - let demoRoot = URL(fileURLWithPath: NSTemporaryDirectory()) - .appendingPathComponent(UUID().uuidString, isDirectory: true) - let demoPaths = makePaths(demoRoot) - try FileManager.default.createDirectory( - at: demoRoot, - withIntermediateDirectories: true, - attributes: nil - ) - - try withDemoEnv("1") { - try makeServer(root: demoRoot).ensureCredentialsFile() - } - XCTAssertTrue(FileManager.default.fileExists(atPath: demoPaths.credentialsPath.path)) - let credentials = try makeServer(root: demoRoot).loadCredentials() - XCTAssertEqual(credentials.demo, true) - XCTAssertEqual(credentials.credentials.first?.username, "demo@example.com") - } - func testSocketListenerSetupAndTeardownUses0600Permissions() throws { let root = URL(fileURLWithPath: NSTemporaryDirectory()) .appendingPathComponent(UUID().uuidString, isDirectory: true) @@ -187,6 +154,9 @@ final class BrokerCoreTests: XCTestCase { let paths = makePaths(root) try writeCredentials(at: paths.credentialsPath) + setenv("APW_DEMO", "1", 1) + defer { unsetenv("APW_DEMO") } + let allowServer = makeServer(root: root, decision: true) let allowResponse = try allowServer.dispatch(request: RequestEnvelope( requestId: "allow", @@ -194,7 +164,6 @@ final class BrokerCoreTests: XCTestCase { payload: ["url": "https://example.com"] )) XCTAssertEqual(allowResponse.ok, true) - XCTAssertEqual(allowResponse.payload?["intent"]?.value as? String, "login") let denyServer = makeServer(root: root, decision: false) let denyResponse = try denyServer.dispatch(request: RequestEnvelope( @@ -206,99 +175,155 @@ final class BrokerCoreTests: XCTestCase { XCTAssertEqual(denyResponse.error, "User denied the APW login request.") } - func testFillDispatchUsesRealBrokerCredentialPathWithFillIntent() throws { + func testLoginWithoutDemoEnvReturnsNoCredentialSource() throws { let root = URL(fileURLWithPath: NSTemporaryDirectory()) .appendingPathComponent(UUID().uuidString, isDirectory: true) - let paths = makePaths(root) - try writeCredentials(at: paths.credentialsPath) + let server = makeServer(root: root) + + unsetenv("APW_DEMO") - let response = try makeServer(root: root, decision: true).dispatch(request: RequestEnvelope( - requestId: "fill-1", - command: "fill", + let response = try server.dispatch(request: RequestEnvelope( + requestId: "no-demo", + command: "login", payload: ["url": "https://example.com"] )) + XCTAssertEqual(response.ok, false) + XCTAssertEqual(response.code, 3) + XCTAssertTrue(response.error?.contains("no_credential_source") ?? false, + "expected typed no_credential_source error, got: \(response.error ?? "nil")") + } + + func testEnsureCredentialsFileSkippedWithoutDemoEnv() throws { + let root = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory( + at: root, withIntermediateDirectories: true, attributes: nil) + let server = makeServer(root: root) + + unsetenv("APW_DEMO") + try server.ensureCredentialsFile() + XCTAssertFalse( + FileManager.default.fileExists(atPath: makePaths(root).credentialsPath.path), + "credentials.json must not be materialized without APW_DEMO=1") + + setenv("APW_DEMO", "1", 1) + defer { unsetenv("APW_DEMO") } + try server.ensureCredentialsFile() + XCTAssertTrue( + FileManager.default.fileExists(atPath: makePaths(root).credentialsPath.path), + "credentials.json should exist after demo-gated bootstrap") + } + + func testDoctorPayloadDoesNotAdvertiseAmbientAutoApproveEscapeHatch() throws { + let root = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let server = makeServer(root: root) + + let payload = server.doctorPayload() + let guidance = payload["guidance"] as? [String] + + XCTAssertNotNil(guidance) + XCTAssertFalse(guidance?.contains(where: { $0.contains("APW_NATIVE_APP_AUTO_APPROVE") }) ?? true) + } + + // MARK: - AuthenticationServices broker routing (issue #13) + + func testLoginRoutesToCredentialBrokerOnSuccess() throws { + unsetenv("APW_DEMO") + let root = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let broker = StubCredentialBroker( + outcome: .success( + BrokerCredential( + domain: "vault.example.com", + url: "https://vault.example.com", + username: "alice@example.com", + password: "real-keychain-password" + ))) + let server = makeServer(root: root, credentialBroker: broker) + + let response = try server.dispatch( + request: RequestEnvelope( + requestId: "ok", + command: "login", + payload: ["url": "https://vault.example.com"] + )) XCTAssertEqual(response.ok, true) XCTAssertEqual(response.code, 0) - XCTAssertEqual(response.requestId, "fill-1") - XCTAssertEqual(response.payload?["status"]?.value as? String, "approved") - XCTAssertEqual(response.payload?["intent"]?.value as? String, "fill") - XCTAssertEqual(response.payload?["domain"]?.value as? String, "example.com") - XCTAssertEqual(response.payload?["username"]?.value as? String, "demo@example.com") - XCTAssertEqual(response.payload?["password"]?.value as? String, "apw-demo-password") - XCTAssertEqual(response.payload?["transport"]?.value as? String, "unix_socket") + XCTAssertEqual(response.payload?["transport"]?.value as? String, "authentication_services") + XCTAssertEqual(response.payload?["username"]?.value as? String, "alice@example.com") + XCTAssertEqual(response.payload?["domain"]?.value as? String, "vault.example.com") XCTAssertEqual(response.payload?["userMediated"]?.value as? Bool, true) } - func testCredentialRequestsRejectNonHttpsUrls() throws { + func testLoginRoutesToCredentialBrokerOnDeny() throws { + unsetenv("APW_DEMO") let root = URL(fileURLWithPath: NSTemporaryDirectory()) .appendingPathComponent(UUID().uuidString, isDirectory: true) - let paths = makePaths(root) - try writeCredentials(at: paths.credentialsPath) - - let server = makeServer(root: root, decision: true) - for command in ["login", "fill"] { - let response = try server.dispatch(request: RequestEnvelope( - requestId: "\(command)-non-https", - command: command, - payload: ["url": "ftp://example.com"] + let broker = StubCredentialBroker(outcome: .denied) + let server = makeServer(root: root, credentialBroker: broker) + + let response = try server.dispatch( + request: RequestEnvelope( + requestId: "denied", + command: "login", + payload: ["url": "https://vault.example.com"] )) - XCTAssertEqual(response.ok, false, command) - XCTAssertEqual(response.code, 1, command) - XCTAssertEqual(response.error, "Native app credential requests require https URLs.", command) - } + + XCTAssertEqual(response.ok, false) + XCTAssertEqual(response.code, 1) + XCTAssertEqual(response.error, "User denied the APW login request.") } - func testFillInvalidUnsupportedAndDeniedBehaviorMatchesLogin() throws { + func testLoginRoutesToCredentialBrokerOnCanceled() throws { + unsetenv("APW_DEMO") let root = URL(fileURLWithPath: NSTemporaryDirectory()) .appendingPathComponent(UUID().uuidString, isDirectory: true) - let paths = makePaths(root) - try writeCredentials(at: paths.credentialsPath) - - for command in ["login", "fill"] { - let invalid = try makeServer(root: root).dispatch(request: RequestEnvelope( - requestId: "\(command)-invalid", - command: command, - payload: ["url": "not-a-url"] - )) - XCTAssertEqual(invalid.ok, false, command) - XCTAssertEqual(invalid.code, 1, command) - XCTAssertEqual(invalid.error, "Invalid URL for native app credential request.", command) - - let unsupported = try makeServer(root: root).dispatch(request: RequestEnvelope( - requestId: "\(command)-unsupported", - command: command, - payload: ["url": "https://unsupported.example"] + let broker = StubCredentialBroker( + outcome: .failure(.canceled, "ASAuthorizationError canceled")) + let server = makeServer(root: root, credentialBroker: broker) + + let response = try server.dispatch( + request: RequestEnvelope( + requestId: "cancel", + command: "login", + payload: ["url": "https://vault.example.com"] )) - XCTAssertEqual(unsupported.ok, false, command) - XCTAssertEqual(unsupported.code, 3, command) - XCTAssertEqual( - unsupported.error, - "The APW v2 bootstrap app currently supports only https://example.com.", - command - ) - let denied = try makeServer(root: root, decision: false).dispatch(request: RequestEnvelope( - requestId: "\(command)-denied", - command: command, - payload: ["url": "https://example.com"] - )) - XCTAssertEqual(denied.ok, false, command) - XCTAssertEqual(denied.code, 1, command) - XCTAssertEqual(denied.error, "User denied the APW login request.", command) - } + XCTAssertEqual(response.ok, false) + XCTAssertEqual(response.code, 1) + XCTAssertTrue(response.error?.contains("canceled") ?? false) } - func testDoctorPayloadDoesNotAdvertiseAmbientAutoApproveEscapeHatch() throws { + + func testLoginRoutesToCredentialBrokerOnInvalidResponse() throws { + unsetenv("APW_DEMO") let root = URL(fileURLWithPath: NSTemporaryDirectory()) .appendingPathComponent(UUID().uuidString, isDirectory: true) - let server = makeServer(root: root) + let broker = StubCredentialBroker( + outcome: .failure(.invalidResponse, "ASAuthorizationError invalidResponse")) + let server = makeServer(root: root, credentialBroker: broker) + + let response = try server.dispatch( + request: RequestEnvelope( + requestId: "invalid", + command: "login", + payload: ["url": "https://vault.example.com"] + )) - let payload = server.doctorPayload() - let guidance = payload["guidance"] as? [String] - let broker = payload["broker"] as? [String: Any] + XCTAssertEqual(response.ok, false) + // invalidResponse maps to ProtoInvalidResponse (104) in the Rust enum. + XCTAssertEqual(response.code, 104) + XCTAssertTrue(response.error?.contains("invalidResponse") ?? false) + } - XCTAssertNotNil(guidance) - XCTAssertFalse(guidance?.contains(where: { $0.contains("APW_NATIVE_APP_AUTO_APPROVE") }) ?? true) - XCTAssertEqual(broker?["requestTimeoutSeconds"] as? TimeInterval, 3) + func testBrokerStatusMappingCoversAllErrorCodes() { + XCTAssertEqual(brokerStatus(for: .canceled), 1) + XCTAssertEqual(brokerStatus(for: .failed), 1) + XCTAssertEqual(brokerStatus(for: .unknown), 1) + XCTAssertEqual(brokerStatus(for: .invalidResponse), 104) + XCTAssertEqual(brokerStatus(for: .notHandled), 3) + XCTAssertEqual(brokerStatus(for: .unsupportedDomain), 3) + XCTAssertEqual(brokerStatus(for: .noCredentialSource), 3) } } diff --git a/rust/src/cli.rs b/rust/src/cli.rs index c71c327..9527956 100644 --- a/rust/src/cli.rs +++ b/rust/src/cli.rs @@ -287,7 +287,13 @@ pub enum AppSubcommand { } #[derive(Args, Default)] -pub struct DoctorCommand {} +pub struct DoctorCommand { + /// Emit only the structured environment-check array. Useful for CI + /// jobs that want to grep `[FAIL]` lines or parse the JSON shape. + /// See issue #12. + #[arg(long)] + pub ci: bool, +} #[derive(Args)] pub struct LoginCommand { @@ -305,6 +311,9 @@ pub struct FillCommand { } #[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, @@ -353,6 +362,9 @@ pub struct HostDoctorArgs { } #[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, @@ -371,6 +383,9 @@ pub enum PwAction { } #[derive(Args)] +#[command( + long_about = "DEPRECATED: `apw otp` is part of the legacy daemon path and will be removed in v2.1.0. See docs/MIGRATION_AND_PARITY.md." +)] pub struct OtpCommand { #[command(subcommand)] pub action: Option, @@ -383,6 +398,9 @@ pub enum OtpAction { } #[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, @@ -439,9 +457,31 @@ fn run_app(args: AppCommand, cli_json: bool) -> Result<(), APWError> { Ok(()) } -fn run_doctor(_args: DoctorCommand, cli_json: bool) -> Result<(), APWError> { +fn run_doctor(args: DoctorCommand, cli_json: bool) -> Result<(), APWError> { logging::info("doctor", "collecting native app diagnostics"); - let payload = native_app_doctor()?; + let environment = crate::doctor::run_environment_checks(); + let environment_json = crate::doctor::checks_to_json(&environment); + + if args.ci { + // CI mode always emits the structured envelope so downstream + // tooling can parse `[FAIL]` deterministically. + print_output(&environment_json, Status::Success, true); + return Ok(()); + } + + let mut payload = native_app_doctor()?; + if let Some(object) = payload.as_object_mut() { + object.insert("environment".to_string(), environment_json); + } + + if !cli_json { + // Surface the human-readable check lines on stderr so the + // existing JSON-on-stdout payload stays parseable. + for line in crate::doctor::render_check_lines(&environment) { + eprintln!("{line}"); + } + } + print_output(&payload, Status::Success, cli_json); Ok(()) } diff --git a/rust/src/doctor.rs b/rust/src/doctor.rs new file mode 100644 index 0000000..a5b17a1 --- /dev/null +++ b/rust/src/doctor.rs @@ -0,0 +1,425 @@ +//! Environment / toolchain checks surfaced by `apw doctor`. Each check is +//! cheap, side-effect-free, and yields a structured `DoctorCheck` so both +//! the human and JSON renderers can consume the same data. See issue #12. + +use serde::Serialize; +use serde_json::{json, Value}; +use std::path::Path; +use std::process::Command; +use std::time::Duration; + +/// Bound on every external probe. A misconfigured shim that hangs must not +/// block `apw doctor`. +const PROBE_TIMEOUT: Duration = Duration::from_secs(3); + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum CheckStatus { + Ok, + Warn, + Fail, + Skip, +} + +impl CheckStatus { + pub fn as_label(self) -> &'static str { + match self { + Self::Ok => "OK", + Self::Warn => "WARN", + Self::Fail => "FAIL", + Self::Skip => "SKIP", + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct DoctorCheck { + pub name: &'static str, + pub status: CheckStatus, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub remediation: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub detected_version: Option, +} + +impl DoctorCheck { + fn new(name: &'static str, status: CheckStatus, message: impl Into) -> Self { + Self { + name, + status, + message: message.into(), + remediation: None, + detected_version: None, + } + } + + fn with_remediation(mut self, hint: impl Into) -> Self { + self.remediation = Some(hint.into()); + self + } + + fn with_version(mut self, version: impl Into) -> Self { + self.detected_version = Some(version.into()); + self + } +} + +fn is_macos() -> bool { + cfg!(target_os = "macos") +} + +fn run_probe(program: &str, args: &[&str]) -> Option { + use std::sync::mpsc; + + let program = program.to_owned(); + let args: Vec = args.iter().map(|s| (*s).to_owned()).collect(); + + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let result = Command::new(program).args(args).output(); + let _ = tx.send(result); + }); + + let output = rx.recv_timeout(PROBE_TIMEOUT).ok()?.ok()?; + if !output.status.success() { + return None; + } + let combined = if !output.stdout.is_empty() { + output.stdout + } else { + output.stderr + }; + let trimmed = String::from_utf8_lossy(&combined).trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } +} + +fn check_xcodebuild() -> DoctorCheck { + if !is_macos() { + return DoctorCheck::new( + "xcodebuild", + CheckStatus::Skip, + "xcodebuild is only required on macOS for building the native app bundle.", + ); + } + match run_probe("xcodebuild", &["-version"]) { + Some(version) => { + DoctorCheck::new("xcodebuild", CheckStatus::Ok, "xcodebuild is available.") + .with_version(version.lines().next().unwrap_or("").to_string()) + } + None => DoctorCheck::new( + "xcodebuild", + CheckStatus::Fail, + "xcodebuild not found or not callable.", + ) + .with_remediation("Install Xcode from the App Store, then run `xcode-select --install`."), + } +} + +fn check_rust_toolchain() -> DoctorCheck { + match run_probe("rustc", &["--version"]) { + Some(version) => DoctorCheck::new( + "rust-toolchain", + CheckStatus::Ok, + "Rust toolchain is available.", + ) + .with_version(version), + None => DoctorCheck::new("rust-toolchain", CheckStatus::Fail, "rustc not found.") + .with_remediation("Install via https://rustup.rs/."), + } +} + +fn check_detect_secrets() -> DoctorCheck { + match run_probe("detect-secrets", &["--version"]) { + Some(version) => DoctorCheck::new( + "detect-secrets", + CheckStatus::Ok, + "detect-secrets is available.", + ) + .with_version(version), + None => DoctorCheck::new( + "detect-secrets", + CheckStatus::Warn, + "detect-secrets not installed; pre-commit secrets scan will be skipped.", + ) + .with_remediation( + "`brew install detect-secrets` (macOS) or `pipx install detect-secrets`.", + ), + } +} + +fn check_signing_identity() -> DoctorCheck { + if !is_macos() { + return DoctorCheck::new( + "code-signing", + CheckStatus::Skip, + "Apple code-signing identities only apply on macOS.", + ); + } + match run_probe("security", &["find-identity", "-v", "-p", "codesigning"]) { + Some(output) if output.contains("Developer ID Application") => DoctorCheck::new( + "code-signing", + CheckStatus::Ok, + "At least one Developer ID Application certificate is available.", + ), + Some(_) => DoctorCheck::new( + "code-signing", + CheckStatus::Warn, + "No `Developer ID Application` certificate found in the keychain. Release builds will fail to sign.", + ) + .with_remediation( + "Download an Apple Developer ID Application certificate and import it into your login keychain.", + ), + None => DoctorCheck::new( + "code-signing", + CheckStatus::Fail, + "`security find-identity` is not callable.", + ), + } +} + +fn check_runner_labels() -> Option { + if std::env::var("CI").as_deref() != Ok("true") { + return None; + } + let labels = std::env::var("RUNNER_LABELS").ok(); + let mut check = match labels.as_deref() { + Some(value) if !value.is_empty() => DoctorCheck::new( + "ci-runner-labels", + CheckStatus::Ok, + "Runner labels exposed via RUNNER_LABELS.", + ) + .with_version(value.to_string()), + _ => DoctorCheck::new( + "ci-runner-labels", + CheckStatus::Warn, + "Running in CI but RUNNER_LABELS is not set; cannot verify runner pool selection.", + ) + .with_remediation("Export RUNNER_LABELS in the workflow step env, e.g. `RUNNER_LABELS: ${{ join(runner.labels, ',') }}`."), + }; + if check.status == CheckStatus::Ok { + let value = check.detected_version.clone().unwrap_or_default(); + if value.contains("self-hosted") && !value.contains("public") { + check = DoctorCheck::new( + "ci-runner-labels", + CheckStatus::Warn, + format!( + "Self-hosted runner labels `{value}` do not include the `public` tag expected for the open-source CI lane." + ), + ); + } + } + Some(check) +} + +fn check_native_app_bundle() -> DoctorCheck { + let bundle = crate::native_app::native_app_bundle_install_path(); + if bundle.exists() { + return DoctorCheck::new( + "native-app-bundle", + CheckStatus::Ok, + format!("APW.app installed at {}.", bundle.display()), + ); + } + let source_candidates = [ + Path::new("native-app/dist/APW.app"), + Path::new("../native-app/dist/APW.app"), + ]; + if source_candidates.iter().any(|candidate| candidate.exists()) { + return DoctorCheck::new( + "native-app-bundle", + CheckStatus::Warn, + "Source-built APW.app exists but has not been installed.", + ) + .with_remediation( + "Run `apw app install` to copy the bundle into ~/.apw/native-app/installed/.", + ); + } + DoctorCheck::new( + "native-app-bundle", + CheckStatus::Warn, + "APW.app bundle is not built.", + ) + .with_remediation("Run `./scripts/build-native-app.sh`, then `apw app install`.") +} + +/// Probe each configured associated domain for a reachable AASA file. +/// Domains are read from `APW_AASA_DOMAINS` (comma-separated) so this can +/// be wired ahead of the `supportedDomains` config field landing. See +/// issue #8. +fn check_associated_domains() -> Option { + let raw = std::env::var("APW_AASA_DOMAINS").ok()?; + let domains: Vec<&str> = raw + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .collect(); + if domains.is_empty() { + return None; + } + + let mut failures: Vec = Vec::new(); + for domain in &domains { + let url = format!("https://{domain}/.well-known/apple-app-site-association"); + // `curl -fsI` is a small dependency footprint — most macOS / Linux + // hosts have it available, and we just need a HEAD probe. + let probe = run_probe("curl", &["-fsI", "--max-time", "5", &url]); + if probe.is_none() { + failures.push(domain.to_string()); + } + } + + if failures.is_empty() { + Some( + DoctorCheck::new( + "associated-domains", + CheckStatus::Ok, + format!( + "AASA files reachable for {} configured domain(s).", + domains.len() + ), + ) + .with_version(domains.join(",")), + ) + } else { + Some( + DoctorCheck::new( + "associated-domains", + CheckStatus::Fail, + format!( + "AASA file unreachable for: {}", + failures.join(", ") + ), + ) + .with_remediation( + "Each domain must serve application/json at /.well-known/apple-app-site-association without redirects. See docs/DOMAIN_EXPANSION.md.", + ), + ) + } +} + +pub fn run_environment_checks() -> Vec { + let mut checks = vec![ + check_xcodebuild(), + check_rust_toolchain(), + check_detect_secrets(), + check_signing_identity(), + check_native_app_bundle(), + ]; + if let Some(runner) = check_runner_labels() { + checks.push(runner); + } + if let Some(aasa) = check_associated_domains() { + checks.push(aasa); + } + checks +} + +pub fn render_check_lines(checks: &[DoctorCheck]) -> Vec { + checks + .iter() + .map(|check| { + let mut line = format!( + "[{}] {}: {}", + check.status.as_label(), + check.name, + check.message + ); + if let Some(version) = &check.detected_version { + line.push_str(&format!(" (detected: {version})")); + } + if let Some(hint) = &check.remediation { + line.push_str(&format!("\n → {hint}")); + } + line + }) + .collect() +} + +pub fn checks_to_json(checks: &[DoctorCheck]) -> Value { + json!(checks) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn check_status_label_is_uppercase() { + assert_eq!(CheckStatus::Ok.as_label(), "OK"); + assert_eq!(CheckStatus::Warn.as_label(), "WARN"); + assert_eq!(CheckStatus::Fail.as_label(), "FAIL"); + assert_eq!(CheckStatus::Skip.as_label(), "SKIP"); + } + + #[test] + fn rust_toolchain_check_succeeds_in_test_env() { + let check = check_rust_toolchain(); + // The test runs under cargo, so rustc must be reachable. + assert_eq!(check.status, CheckStatus::Ok); + assert!(check.detected_version.is_some()); + } + + #[test] + fn xcodebuild_check_skips_on_non_macos() { + let check = check_xcodebuild(); + if !cfg!(target_os = "macos") { + assert_eq!(check.status, CheckStatus::Skip); + } + } + + #[test] + fn signing_identity_skips_on_non_macos() { + let check = check_signing_identity(); + if !cfg!(target_os = "macos") { + assert_eq!(check.status, CheckStatus::Skip); + } + } + + #[test] + fn run_environment_checks_returns_at_least_the_core_set() { + let checks = run_environment_checks(); + let names: Vec<_> = checks.iter().map(|c| c.name).collect(); + assert!(names.contains(&"xcodebuild")); + assert!(names.contains(&"rust-toolchain")); + assert!(names.contains(&"detect-secrets")); + assert!(names.contains(&"code-signing")); + assert!(names.contains(&"native-app-bundle")); + } + + #[test] + fn json_render_is_a_valid_array() { + let checks = run_environment_checks(); + let value = checks_to_json(&checks); + assert!(value.is_array()); + assert!(!value.as_array().unwrap().is_empty()); + } + + #[test] + fn human_render_includes_status_label() { + let checks = run_environment_checks(); + let lines = render_check_lines(&checks); + assert!(lines.iter().any(|line| line.starts_with('['))); + } + + #[test] + fn associated_domains_check_skipped_when_env_unset() { + std::env::remove_var("APW_AASA_DOMAINS"); + assert!(check_associated_domains().is_none()); + } + + #[test] + 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. + std::env::set_var("APW_AASA_DOMAINS", "definitely-not-a-real-host.invalid"); + let check = check_associated_domains().expect("expected an AASA check"); + std::env::remove_var("APW_AASA_DOMAINS"); + assert_eq!(check.status, CheckStatus::Fail); + assert!(check.message.contains("unreachable")); + } +} diff --git a/rust/src/main.rs b/rust/src/main.rs index 27dee0e..3b4624c 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -3,6 +3,7 @@ use clap::Parser; mod cli; mod client; mod daemon; +mod doctor; mod error; mod host; mod logging; diff --git a/rust/src/native_app.rs b/rust/src/native_app.rs index 954167f..9326eef 100644 --- a/rust/src/native_app.rs +++ b/rust/src/native_app.rs @@ -1,7 +1,7 @@ use crate::error::{APWError, Result}; use crate::logging; use crate::types::{ExternalFallbackProvider, Status, MAX_MESSAGE_BYTES, VERSION}; -use crate::utils::{read_config_file, validate_external_provider_path}; +use crate::utils::{read_config_file, read_config_file_or_empty, validate_external_provider_path}; use serde_json::{json, Value}; use std::env; use std::fs; diff --git a/rust/tests/legacy_parity.rs b/rust/tests/legacy_parity.rs index 3e1ab1d..2200a3e 100644 --- a/rust/tests/legacy_parity.rs +++ b/rust/tests/legacy_parity.rs @@ -855,3 +855,28 @@ fn parity_command_matrix_matches_legacy() { 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| { + for command in ["pw", "otp"] { + let output = run_rust_cli(home, &[command, "list", "https://example.com"]); + assert!( + output.stderr.contains("legacy daemon path"), + "`apw {command}` must emit the deprecation warning on stderr; got stderr=\"{}\"", + output.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/security_regressions.rs b/rust/tests/security_regressions.rs index 2af518a..6a32f7f 100644 --- a/rust/tests/security_regressions.rs +++ b/rust/tests/security_regressions.rs @@ -225,8 +225,15 @@ fn login_rejects_relative_external_provider_path() { install_native_app_no_results(home); write_fallback_provider_config(home, "bw"); - let (status, stdout, stderr) = - run_command(home, &["--json", "login", "https://vault.example.com"]); + let (status, stdout, stderr) = run_command( + home, + &[ + "--json", + "login", + "--external-fallback", + "https://vault.example.com", + ], + ); assert_eq!( status, 102, @@ -247,8 +254,15 @@ fn login_rejects_tilde_external_provider_path() { install_native_app_no_results(home); write_fallback_provider_config(home, "~/bin/bw"); - let (status, stdout, stderr) = - run_command(home, &["--json", "login", "https://vault.example.com"]); + let (status, stdout, stderr) = run_command( + home, + &[ + "--json", + "login", + "--external-fallback", + "https://vault.example.com", + ], + ); assert_eq!( status, 102, @@ -274,8 +288,15 @@ fn login_rejects_world_writable_external_provider_path() { .expect("failed to chmod fallback provider"); write_fallback_provider_config(home, &provider_path.display().to_string()); - let (status, stdout, stderr) = - run_command(home, &["--json", "login", "https://vault.example.com"]); + let (status, stdout, stderr) = run_command( + home, + &[ + "--json", + "login", + "--external-fallback", + "https://vault.example.com", + ], + ); assert_eq!( status, 102, @@ -303,8 +324,15 @@ fn login_rejects_external_provider_symlink_to_insecure_target() { symlink(&provider_path, &provider_link).expect("failed to create provider symlink"); write_fallback_provider_config(home, &provider_link.display().to_string()); - let (status, stdout, stderr) = - run_command(home, &["--json", "login", "https://vault.example.com"]); + let (status, stdout, stderr) = run_command( + home, + &[ + "--json", + "login", + "--external-fallback", + "https://vault.example.com", + ], + ); assert_eq!( status, 102,