diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1aeac12..761b8dc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -48,6 +48,8 @@ jobs: run: cargo build --manifest-path rust/Cargo.toml --release - name: Build native app bundle + env: + APW_SPARKLE_PUBLIC_ED_KEY: ${{ vars.APW_SPARKLE_PUBLIC_ED_KEY || '' }} run: ./scripts/build-native-app.sh - name: Build local source tarball for Homebrew smoke @@ -100,6 +102,8 @@ jobs: ./rust/target/release/apw status --json - name: Build native app bundle + env: + APW_SPARKLE_PUBLIC_ED_KEY: ${{ vars.APW_SPARKLE_PUBLIC_ED_KEY || '' }} run: ./scripts/build-native-app.sh - name: Homebrew smoke install from source tarball @@ -142,6 +146,36 @@ jobs: - name: Package release archive run: ./scripts/package-release-archive.sh "${GITHUB_REF_NAME}" + - name: Prepare signed Sparkle appcast + id: sparkle_appcast + env: + SPARKLE_GENERATE_APPCAST: ${{ vars.SPARKLE_GENERATE_APPCAST || '' }} + run: | + if [ -z "$SPARKLE_GENERATE_APPCAST" ]; then + echo "::warning::SPARKLE_GENERATE_APPCAST is not configured; skipping Sparkle appcast publication." + echo "available=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + app_zip="dist/APW.app-${GITHUB_REF_NAME}.zip" + release_notes="dist/APW.app-${GITHUB_REF_NAME}.md" + + ditto -c -k --keepParent native-app/dist/APW.app "$app_zip" + { + echo "# APW ${GITHUB_REF_NAME}" + echo + echo "See the GitHub release notes for this tag." + } > "$release_notes" + + ./scripts/prepare-sparkle-appcast.sh \ + --archive "$app_zip" \ + --release-notes "$release_notes" \ + --updates-dir dist/sparkle-updates \ + --generate-appcast "$SPARKLE_GENERATE_APPCAST" + + cp dist/sparkle-updates/appcast.xml dist/appcast.xml + echo "available=true" >> "$GITHUB_OUTPUT" + - name: Validate release archive contents run: | archive="dist/apw-macos-${GITHUB_REF_NAME}.tar.gz" @@ -155,6 +189,14 @@ jobs: with: files: dist/apw-macos-${{ github.ref_name }}.tar.gz + - name: Publish Sparkle appcast assets + if: steps.sparkle_appcast.outputs.available == 'true' + uses: softprops/action-gh-release@v2 + with: + files: | + dist/APW.app-${{ github.ref_name }}.zip + dist/appcast.xml + publish-homebrew-tap: name: Publish Homebrew tap PR needs: release diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index b6de7cd..08f1646 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -67,6 +67,12 @@ tar -xzf apw-macos-vX.Y.Z.tar.gz After `apw app install`, the CLI copies `APW.app` into `~/.apw/native-app/installed/APW.app`. +## In-app updates + +APW.app's planned in-app update channel uses a signed Sparkle appcast. The +contract, managed disable key, security-update marker, and release validation +requirements are documented in [IN_APP_UPDATES.md](IN_APP_UPDATES.md). + ## Homebrew APW uses a **formula-plus-app-install** Homebrew model for the v2 line. The @@ -235,6 +241,7 @@ cargo test --manifest-path rust/Cargo.toml --all-targets cargo test --manifest-path rust/Cargo.toml --test native_app_e2e cargo build --manifest-path rust/Cargo.toml --release ./scripts/build-native-app.sh +./scripts/ci/validate-appcast-contract.sh ``` Optional parity and release helpers: diff --git a/docs/IN_APP_UPDATES.md b/docs/IN_APP_UPDATES.md new file mode 100644 index 0000000..395887d --- /dev/null +++ b/docs/IN_APP_UPDATES.md @@ -0,0 +1,152 @@ +# In-app update contract + +APW.app will use Sparkle 2 for in-app updates. The release channel is security +sensitive because the app broker mediates credential access, so APW uses the +standard macOS updater instead of a custom downloader and swapper. + +Issue: #57 + +## Decision + +Use Sparkle 2 as the updater framework for APW.app. + +Rationale: + +- Sparkle is the established macOS updater for Developer ID distributed apps. +- Sparkle supports EdDSA-signed update archives and Apple code signing checks. +- Sparkle can mark critical updates distinctly from ordinary maintenance + updates. +- Sparkle keeps the update installer and relaunch behavior out of APW broker + code, reducing the amount of security-sensitive custom code. + +APW should not add a custom updater unless Sparkle cannot satisfy a release +blocker that is documented with a replacement threat model. + +## Stable feed + +The production appcast URL is: + +```text +https://github.com/OMT-Global/apw-cli/releases/latest/download/appcast.xml +``` + +This URL is controlled by the project repository and resolves to the appcast +asset attached to the latest GitHub release. APW.app should set this URL in +`Info.plist` with `SUFeedURL` once Sparkle is linked into the native app. +`scripts/build-native-app.sh` renders those keys when +`APW_SPARKLE_PUBLIC_ED_KEY` is set, so release automation can package a bundle +with the real public key without committing placeholder update-trust material. + +The appcast contract is represented by +`packaging/sparkle/appcast.template.xml`. The template is not a production +appcast and must not be uploaded with placeholder signatures or lengths. + +## Required Sparkle settings + +When the runtime integration lands, APW.app must set these keys: + +```text +SUFeedURL=https://github.com/OMT-Global/apw-cli/releases/latest/download/appcast.xml +SUPublicEDKey= +SUVerifyUpdateBeforeExtraction=true +SURequireSignedFeed=true +SUEnableAutomaticChecks=true +SUAllowsAutomaticUpdates=false +SUAutomaticallyUpdate=false +``` + +`SUVerifyUpdateBeforeExtraction` requires EdDSA signing and validates the update +archive before extraction. `SURequireSignedFeed` requires Sparkle 2.9 or newer +and ensures the appcast and release notes are signed before update metadata is +trusted. + +## Release signing requirements + +Every APW.app update must be published as a Developer ID signed and notarized +archive. Before publishing the appcast, the release job must verify: + +```bash +codesign --deep --strict --verify APW.app +spctl --assess --type execute --verbose APW.app +xcrun stapler validate APW.app +``` + +The release archive, release notes, and appcast must be signed with Sparkle's +EdDSA key. The private EdDSA key must stay in release automation secrets or a +release keychain and must never be committed to this repository. + +Sparkle appcast preparation should use the checked helper: + +```bash +./scripts/prepare-sparkle-appcast.sh \ + --archive dist/APW.app.zip \ + --release-notes dist/APW.app.release.md \ + --updates-dir dist/sparkle-updates \ + --generate-appcast /path/to/Sparkle/bin/generate_appcast +``` + +The helper copies the signed/notarized archive and release notes into the +updates directory, runs Sparkle's `generate_appcast`, and fails if the resulting +appcast does not contain EdDSA signatures or does not reference the release +archive. Private EdDSA key material stays with Sparkle's configured signing +environment, such as Keychain-backed release automation. + +Tagged releases run this helper when the release runner has the +`SPARKLE_GENERATE_APPCAST` repository variable set to Sparkle's +`generate_appcast` executable. When configured, the release attaches +`appcast.xml` and the signed `APW.app` update archive to the GitHub release so +the stable `/releases/latest/download/appcast.xml` feed URL resolves to a +signed appcast. When the variable is absent, release automation emits a warning +and skips appcast publication rather than inventing unsigned update metadata. +Release runners should also set `APW_SPARKLE_PUBLIC_ED_KEY` to the public EdDSA +key paired with the appcast signing key before enabling runtime update checks. + +## Managed update control + +Enterprise administrators can disable user-driven update checks with this +managed preference: + +```text +Domain: dev.omt.apw +Key: com.omt.apw.updatesDisabled +Type: Boolean +``` + +When this key is `true`, APW.app must not start Sparkle automatic checks or +manual user-driven update checks. The broker should still report its installed +version through `apw status --json` and `apw doctor --json` so fleet tooling can +inventory stale installations. + +APW.app reads this managed key at runtime and reports the current +`updatesDisabled` state plus the configured feed URL in the `inAppUpdates` +status payload. APW.app links Sparkle through Swift Package Manager and starts +`SPUStandardUpdaterController` only after this managed policy allows update +checks. + +## Security update surfacing + +Security updates must be distinct from cosmetic or maintenance updates. + +Use all of the following for security releases: + +- title starts with `APW Security Update` +- appcast item contains top-level `sparkle:criticalUpdate` +- release notes contain a `Security` section before other changes +- appcast item links to the GitHub release notes for the exact tag + +Critical update status is reserved for credential-broker security fixes, +signing/notarization failures, or vulnerabilities that can affect credential +confidentiality, integrity, or update trust. + +## Validation + +Run the contract check with: + +```bash +./scripts/ci/validate-appcast-contract.sh +./scripts/test-prepare-sparkle-appcast.sh +``` + +The fast PR check runs the same validator so changes to the appcast template, +security-update wording, MDM key, Sparkle security settings, or appcast +preparation helper fail before release automation drifts. diff --git a/docs/SECURITY_POSTURE_AND_TESTING.md b/docs/SECURITY_POSTURE_AND_TESTING.md index c6b4f14..a2addd8 100644 --- a/docs/SECURITY_POSTURE_AND_TESTING.md +++ b/docs/SECURITY_POSTURE_AND_TESTING.md @@ -62,8 +62,14 @@ cargo test --manifest-path rust/Cargo.toml --test legacy_parity cargo test --manifest-path rust/Cargo.toml --test native_app_e2e cargo build --manifest-path rust/Cargo.toml --release ./scripts/build-native-app.sh +./scripts/ci/validate-appcast-contract.sh ``` +In-app updates must follow the signed Sparkle appcast contract in +[IN_APP_UPDATES.md](IN_APP_UPDATES.md). Release automation must not publish an +appcast until the APW.app archive passes code-signing, Gatekeeper, and +notarization staple validation. + ## Security-focused regression coverage The Rust test suite covers: @@ -77,6 +83,7 @@ The Rust test suite covers: - native app diagnostics and `APW_DEMO=1` bootstrap credential file initialization - end-to-end v2 app install, launch, status, doctor, and login flows - direct-exec fallback, unsupported-domain handling, denial handling, and malformed broker response mapping +- signed appcast contract requirements for the future APW.app in-app update channel - external fallback provider path hardening, including relative paths, `~`, world-writable executables, and symlink targets - diagnostic-bundle redaction and fail-closed aborts when staged diagnostics look diff --git a/native-app/Package.resolved b/native-app/Package.resolved new file mode 100644 index 0000000..d4c2188 --- /dev/null +++ b/native-app/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "sparkle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sparkle-project/Sparkle", + "state" : { + "revision" : "6276ba2b404829d139c45ff98427cf90e2efc59b", + "version" : "2.9.2" + } + } + ], + "version" : 2 +} diff --git a/native-app/Package.swift b/native-app/Package.swift index 5121c38..f2b16f5 100644 --- a/native-app/Package.swift +++ b/native-app/Package.swift @@ -11,9 +11,15 @@ let package = Package( .library(name: "NativeAppLib", targets: ["NativeAppLib"]), .executable(name: "APW", targets: ["APW"]), ], + dependencies: [ + .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.9.0"), + ], targets: [ .target( name: "NativeAppLib", + dependencies: [ + .product(name: "Sparkle", package: "Sparkle"), + ], path: "Sources/NativeAppLib", resources: [ .process("Resources"), diff --git a/native-app/Sources/NativeAppLib/AuthenticationServicesBroker.swift b/native-app/Sources/NativeAppLib/AuthenticationServicesBroker.swift index 76f28fe..0e8c171 100644 --- a/native-app/Sources/NativeAppLib/AuthenticationServicesBroker.swift +++ b/native-app/Sources/NativeAppLib/AuthenticationServicesBroker.swift @@ -154,7 +154,7 @@ public protocol CredentialBroker { return .notHandled case .unknown: return .unknown - @unknown default: + default: return .unknown } } diff --git a/native-app/Sources/NativeAppLib/BrokerCore.swift b/native-app/Sources/NativeAppLib/BrokerCore.swift index d04e461..8cc1af2 100644 --- a/native-app/Sources/NativeAppLib/BrokerCore.swift +++ b/native-app/Sources/NativeAppLib/BrokerCore.swift @@ -9,6 +9,9 @@ private let maxBrokerBytes = 32 * 1024 private let appSocketName = "broker.sock" private let statusFileName = "status.json" private let credentialsFileName = "credentials.json" +let appcastFeedURL = "https://github.com/OMT-Global/apw-cli/releases/latest/download/appcast.xml" +let managedUpdatePreferenceDomain = "dev.omt.apw" +let updatesDisabledPreferenceKey = "com.omt.apw.updatesDisabled" /// 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 @@ -51,6 +54,13 @@ let demoEnvVar = "APW_DEMO" func demoModeEnabled() -> Bool { ProcessInfo.processInfo.environment[demoEnvVar] == "1" } + +func managedUpdatesDisabled( + defaults: UserDefaults = .standard +) -> Bool { + defaults.bool(forKey: updatesDisabledPreferenceKey) +} + protocol ApprovalPrompter { func prompt(url: String, username: String) -> Bool } @@ -272,15 +282,21 @@ final class BrokerServer { private let startedAt = ISO8601DateFormatter().string(from: Date()) private let approvalPrompter: ApprovalPrompter private let credentialBroker: CredentialBroker? + private let updatePolicyDefaults: UserDefaults + private let updateRuntime: InAppUpdateRuntime init( paths: AppPaths, approvalPrompter: ApprovalPrompter = SystemApprovalPrompter(), - credentialBroker: CredentialBroker? = defaultCredentialBroker() + credentialBroker: CredentialBroker? = defaultCredentialBroker(), + updatePolicyDefaults: UserDefaults = .standard, + updateRuntime: InAppUpdateRuntime? = nil ) { self.paths = paths self.approvalPrompter = approvalPrompter self.credentialBroker = credentialBroker + self.updatePolicyDefaults = updatePolicyDefaults + self.updateRuntime = updateRuntime ?? APWInAppUpdateRuntime(defaults: updatePolicyDefaults) } func run() throws -> Never { @@ -297,7 +313,7 @@ final class BrokerServer { "serviceStatus": "running", "pid": getpid(), "transport": "unix_socket", - ]) + ].merging(startUpdateRuntimeStatus(), uniquingKeysWith: { _, new in new })) while true { let client = accept(descriptor, nil, nil) @@ -395,6 +411,12 @@ final class BrokerServer { "socketPath": paths.socketPath.path, "supportedDomains": supportedDomains(), "authenticationServicesLinked": true, + "inAppUpdates": [ + "feedURL": appcastFeedURL, + "managedPreferenceDomain": managedUpdatePreferenceDomain, + "managedDisableKey": updatesDisabledPreferenceKey, + "updatesDisabled": managedUpdatesDisabled(defaults: updatePolicyDefaults), + ], ] } @@ -633,7 +655,13 @@ final class BrokerServer { } } - private func writeStatus(extra: [String: Any]) throws { + func startUpdateRuntimeStatus() -> [String: Any] { + [ + "updateRuntimeState": updateRuntime.startIfAllowed().rawValue, + ] + } + + func writeStatus(extra: [String: Any]) throws { var payload = statusPayload() for (key, value) in extra { payload[key] = value diff --git a/native-app/Sources/NativeAppLib/InAppUpdateRuntime.swift b/native-app/Sources/NativeAppLib/InAppUpdateRuntime.swift new file mode 100644 index 0000000..bbceee2 --- /dev/null +++ b/native-app/Sources/NativeAppLib/InAppUpdateRuntime.swift @@ -0,0 +1,70 @@ +import Foundation + +#if canImport(Sparkle) + import Sparkle +#endif + +enum InAppUpdateRuntimeState: String { + case disabledByManagedPolicy = "disabled_by_managed_policy" + case sparkleUnavailable = "sparkle_unavailable" + case starting = "starting" +} + +protocol InAppUpdateRuntime { + func startIfAllowed() -> InAppUpdateRuntimeState +} + +final class APWInAppUpdateRuntime: InAppUpdateRuntime { + private let defaults: UserDefaults + + init( + defaults: UserDefaults = .standard + ) { + self.defaults = defaults + } + + func startIfAllowed() -> InAppUpdateRuntimeState { + guard !managedUpdatesDisabled(defaults: defaults) else { + return .disabledByManagedPolicy + } + + #if canImport(Sparkle) + SparkleRuntimeController.shared.start() + return .starting + #else + return .sparkleUnavailable + #endif + } +} + +#if canImport(Sparkle) + private final class SparkleRuntimeController { + static let shared = SparkleRuntimeController() + + @MainActor private var updaterController: SPUStandardUpdaterController? + + private init() {} + + func start() { + if Thread.isMainThread { + MainActor.assumeIsolated { + self.startOnMainActor() + } + } else { + DispatchQueue.main.async { + self.startOnMainActor() + } + } + } + + @MainActor private func startOnMainActor() { + if updaterController == nil { + updaterController = SPUStandardUpdaterController( + startingUpdater: true, + updaterDelegate: nil, + userDriverDelegate: nil + ) + } + } + } +#endif diff --git a/native-app/Tests/NativeAppTests/BrokerCoreTests.swift b/native-app/Tests/NativeAppTests/BrokerCoreTests.swift index ad42d85..edd8e08 100644 --- a/native-app/Tests/NativeAppTests/BrokerCoreTests.swift +++ b/native-app/Tests/NativeAppTests/BrokerCoreTests.swift @@ -19,6 +19,20 @@ private struct StubCredentialBroker: CredentialBroker { } } +private final class StubUpdateRuntime: InAppUpdateRuntime { + private(set) var startCount = 0 + let state: InAppUpdateRuntimeState + + init(state: InAppUpdateRuntimeState = .starting) { + self.state = state + } + + func startIfAllowed() -> InAppUpdateRuntimeState { + startCount += 1 + return state + } +} + final class BrokerCoreTests: XCTestCase { private func makePaths(_ root: URL) -> AppPaths { AppPaths( @@ -36,15 +50,26 @@ final class BrokerCoreTests: XCTestCase { private func makeServer( root: URL, decision: Bool = true, - credentialBroker: CredentialBroker? = nil + credentialBroker: CredentialBroker? = nil, + updatePolicyDefaults: UserDefaults = .standard, + updateRuntime: InAppUpdateRuntime? = nil ) -> BrokerServer { BrokerServer( paths: makePaths(root), approvalPrompter: StubApprovalPrompter(decision: decision), - credentialBroker: credentialBroker + credentialBroker: credentialBroker, + updatePolicyDefaults: updatePolicyDefaults, + updateRuntime: updateRuntime ) } + private func makeUpdatePolicyDefaults() throws -> UserDefaults { + let suiteName = "dev.omt.apw.tests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defaults.removePersistentDomain(forName: suiteName) + return defaults + } + private func writeCredentials( at path: URL, mode: Int = 0o600, @@ -244,6 +269,69 @@ final class BrokerCoreTests: XCTestCase { XCTAssertFalse(guidance?.contains(where: { $0.contains("APW_NATIVE_APP_AUTO_APPROVE") }) ?? true) } + func testStatusReportsManagedInAppUpdatePolicy() throws { + let root = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let defaults = try makeUpdatePolicyDefaults() + defaults.set(true, forKey: updatesDisabledPreferenceKey) + let server = makeServer(root: root, updatePolicyDefaults: defaults) + + let response = try server.dispatch(request: RequestEnvelope( + requestId: "status", + command: "status", + payload: nil + )) + let updates = try XCTUnwrap(response.payload?["inAppUpdates"]?.value as? [String: Any]) + + XCTAssertEqual(updates["feedURL"] as? String, appcastFeedURL) + XCTAssertEqual(updates["managedPreferenceDomain"] as? String, managedUpdatePreferenceDomain) + XCTAssertEqual(updates["managedDisableKey"] as? String, updatesDisabledPreferenceKey) + XCTAssertEqual(updates["updatesDisabled"] as? Bool, true) + } + + func testDoctorReportsManagedInAppUpdatePolicy() throws { + let root = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let defaults = try makeUpdatePolicyDefaults() + defaults.set(true, forKey: updatesDisabledPreferenceKey) + let server = makeServer(root: root, updatePolicyDefaults: defaults) + + let payload = server.doctorPayload() + let broker = try XCTUnwrap(payload["broker"] as? [String: Any]) + let updates = try XCTUnwrap(broker["inAppUpdates"] as? [String: Any]) + + XCTAssertEqual(updates["updatesDisabled"] as? Bool, true) + XCTAssertEqual(updates["managedDisableKey"] as? String, updatesDisabledPreferenceKey) + } + + func testManagedUpdatesDefaultToEnabledWhenPreferenceUnset() throws { + let defaults = try makeUpdatePolicyDefaults() + + XCTAssertEqual(managedUpdatesDisabled(defaults: defaults), false) + } + + func testRunStartsUpdateRuntimeAndPersistsState() throws { + let root = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let updateRuntime = StubUpdateRuntime(state: .starting) + let server = makeServer(root: root, updateRuntime: updateRuntime) + + try FileManager.default.createDirectory( + at: root, + withIntermediateDirectories: true, + attributes: nil + ) + try server.writeStatus(extra: [ + "serviceStatus": "running", + ].merging(server.startUpdateRuntimeStatus(), uniquingKeysWith: { _, new in new })) + + let data = try Data(contentsOf: makePaths(root).statusPath) + let payload = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + + XCTAssertEqual(updateRuntime.startCount, 1) + XCTAssertEqual(payload["updateRuntimeState"] as? String, "starting") + } + // MARK: - AuthenticationServices broker routing (issue #13) func testLoginRoutesToCredentialBrokerOnSuccess() throws { diff --git a/packaging/sparkle/appcast.template.xml b/packaging/sparkle/appcast.template.xml new file mode 100644 index 0000000..1e74632 --- /dev/null +++ b/packaging/sparkle/appcast.template.xml @@ -0,0 +1,23 @@ + + + + APW.app Updates + https://github.com/OMT-Global/apw-cli/releases + Signed APW.app update feed. + en + + APW 2.0.0 Security Update + https://github.com/OMT-Global/apw-cli/releases/tag/v2.0.0 + 2.0.0 + 2.0.0 + https://github.com/OMT-Global/apw-cli/releases/tag/v2.0.0 + Tue, 01 Jan 2030 00:00:00 +0000 + + + + + diff --git a/rust/src/daemon.rs b/rust/src/daemon.rs index a7a4718..6444268 100644 --- a/rust/src/daemon.rs +++ b/rust/src/daemon.rs @@ -15,7 +15,7 @@ use serde_json::{json, Value}; use std::collections::HashMap; use std::fs; use std::future::Future; -use std::io::ErrorKind; +use std::io::{Error as IoError, ErrorKind, Result as IoResult}; #[cfg(target_os = "macos")] use std::os::fd::AsRawFd; #[cfg(unix)] @@ -1870,12 +1870,14 @@ async fn start_browser_daemon_inner(options: DaemonOptions, host: String) -> Res async fn read_native_host_frame(stream: &mut UnixStream) -> Result> { let mut length = [0_u8; 4]; - stream.read_exact(&mut length).await.map_err(|error| { - APWError::new( - Status::ProcessNotRunning, - format!("Failed reading native host frame header: {error}"), - ) - })?; + read_native_host_exact(stream, &mut length) + .await + .map_err(|error| { + APWError::new( + Status::ProcessNotRunning, + format!("Failed reading native host frame header: {error}"), + ) + })?; let payload_length = u32::from_le_bytes(length) as usize; if payload_length == 0 || payload_length > MAX_HELPER_PAYLOAD { return Err(APWError::new( @@ -1885,15 +1887,37 @@ async fn read_native_host_frame(stream: &mut UnixStream) -> Result> { } let mut payload = vec![0_u8; payload_length]; - stream.read_exact(&mut payload).await.map_err(|error| { - APWError::new( - Status::ProcessNotRunning, - format!("Failed reading native host frame: {error}"), - ) - })?; + read_native_host_exact(stream, &mut payload) + .await + .map_err(|error| { + APWError::new( + Status::ProcessNotRunning, + format!("Failed reading native host frame: {error}"), + ) + })?; Ok(payload) } +async fn read_native_host_exact(stream: &mut UnixStream, mut buffer: &mut [u8]) -> IoResult<()> { + while !buffer.is_empty() { + match stream.read(buffer).await { + Ok(0) => { + return Err(IoError::new( + ErrorKind::UnexpectedEof, + "native host stream closed", + )); + } + Ok(bytes_read) => { + let (_, remaining) = buffer.split_at_mut(bytes_read); + buffer = remaining; + } + Err(error) if error.kind() == ErrorKind::Interrupted => continue, + Err(error) => return Err(error), + } + } + Ok(()) +} + async fn write_native_host_frame(stream: &mut UnixStream, payload: &[u8]) -> Result<()> { if payload.len() > MAX_HELPER_PAYLOAD { return Err(APWError::new( @@ -2599,7 +2623,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() == ErrorKind::Interrupted => continue, + Err(error) => panic!("failed receiving daemon response: {error}"), + } + }; serde_json::from_slice(&buffer[..size]).unwrap() } diff --git a/scripts/build-native-app.sh b/scripts/build-native-app.sh index d88ebff..5fedd26 100755 --- a/scripts/build-native-app.sh +++ b/scripts/build-native-app.sh @@ -10,15 +10,22 @@ APP_DIR="$DIST_DIR/$APP_NAME" CONTENTS_DIR="$APP_DIR/Contents" MACOS_DIR="$CONTENTS_DIR/MacOS" RESOURCES_DIR="$CONTENTS_DIR/Resources" +FRAMEWORKS_DIR="$CONTENTS_DIR/Frameworks" PLIST_PATH="$CONTENTS_DIR/Info.plist" EXECUTABLE_PATH="$PACKAGE_DIR/.build/release/$EXECUTABLE_NAME" VERSION="$(awk -F ' = ' '$1 == "version" { gsub(/"/, "", $2); print $2; exit }' "$ROOT_DIR/rust/Cargo.toml")" +PLIST_RENDERER="$ROOT_DIR/scripts/render-native-app-info-plist.sh" if [[ -z "$VERSION" ]]; then echo "Unable to determine APW version from rust/Cargo.toml" >&2 exit 1 fi +if [[ ! -x "$PLIST_RENDERER" ]]; then + echo "Expected Info.plist renderer not found or not executable: $PLIST_RENDERER" >&2 + exit 1 +fi + swift build --package-path "$PACKAGE_DIR" -c release rm -rf "$APP_DIR" @@ -31,32 +38,26 @@ if [[ -n "$RESOURCE_BUNDLE" ]]; then cp -R "$RESOURCE_BUNDLE" "$RESOURCES_DIR/$(basename "$RESOURCE_BUNDLE")" fi -cat >"$PLIST_PATH" < - - - - CFBundleDevelopmentRegion - en - CFBundleExecutable - $EXECUTABLE_NAME - CFBundleIdentifier - dev.omt.apw - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - APW - CFBundlePackageType - APPL - CFBundleShortVersionString - $VERSION - CFBundleVersion - $VERSION - LSUIElement - - - -EOF +if otool -L "$MACOS_DIR/$EXECUTABLE_NAME" | grep -q '@rpath/Sparkle.framework/'; then + SPARKLE_FRAMEWORK="$(find "$PACKAGE_DIR/.build" -path '*/release/Sparkle.framework' -type d | head -n 1 || true)" + if [[ -z "$SPARKLE_FRAMEWORK" ]]; then + echo "APW links Sparkle.framework but SwiftPM did not produce a release framework." >&2 + exit 1 + fi + mkdir -p "$FRAMEWORKS_DIR" + if command -v ditto >/dev/null 2>&1; then + ditto "$SPARKLE_FRAMEWORK" "$FRAMEWORKS_DIR/Sparkle.framework" + else + cp -R "$SPARKLE_FRAMEWORK" "$FRAMEWORKS_DIR/" + fi + if command -v install_name_tool >/dev/null 2>&1; then + if ! otool -l "$MACOS_DIR/$EXECUTABLE_NAME" | grep -q '@loader_path/../Frameworks'; then + install_name_tool -add_rpath '@loader_path/../Frameworks' "$MACOS_DIR/$EXECUTABLE_NAME" + fi + fi +fi + +"$PLIST_RENDERER" "$PLIST_PATH" "$VERSION" "$EXECUTABLE_NAME" if command -v codesign >/dev/null 2>&1; then if ! codesign -s - --force --deep "$APP_DIR" 2>/dev/null; then diff --git a/scripts/ci/run-fast-checks.sh b/scripts/ci/run-fast-checks.sh index 46dacc7..9f3f8c6 100755 --- a/scripts/ci/run-fast-checks.sh +++ b/scripts/ci/run-fast-checks.sh @@ -41,6 +41,9 @@ while IFS= read -r -d '' script; do done < <(find .github/scripts scripts -type f -name '*.sh' -print0) ./scripts/test-render-homebrew-formula.sh +./scripts/test-render-native-app-info-plist.sh +./scripts/test-prepare-sparkle-appcast.sh +./scripts/ci/validate-appcast-contract.sh ./scripts/test-extended-validation-config.sh echo "APW fast checks passed." diff --git a/scripts/ci/validate-appcast-contract.sh b/scripts/ci/validate-appcast-contract.sh new file mode 100755 index 0000000..b609cfa --- /dev/null +++ b/scripts/ci/validate-appcast-contract.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +DOC_PATH="$ROOT_DIR/docs/IN_APP_UPDATES.md" +TEMPLATE_PATH="$ROOT_DIR/packaging/sparkle/appcast.template.xml" +PREPARE_SCRIPT="$ROOT_DIR/scripts/prepare-sparkle-appcast.sh" +PREPARE_TEST="$ROOT_DIR/scripts/test-prepare-sparkle-appcast.sh" +PLIST_RENDERER="$ROOT_DIR/scripts/render-native-app-info-plist.sh" +PLIST_RENDERER_TEST="$ROOT_DIR/scripts/test-render-native-app-info-plist.sh" +RELEASE_WORKFLOW="$ROOT_DIR/.github/workflows/release.yml" +BROKER_CORE="$ROOT_DIR/native-app/Sources/NativeAppLib/BrokerCore.swift" +UPDATE_RUNTIME="$ROOT_DIR/native-app/Sources/NativeAppLib/InAppUpdateRuntime.swift" +NATIVE_PACKAGE="$ROOT_DIR/native-app/Package.swift" +BUILD_NATIVE_APP="$ROOT_DIR/scripts/build-native-app.sh" +FEED_URL="https://github.com/OMT-Global/apw-cli/releases/latest/download/appcast.xml" +MDM_KEY="com.omt.apw.updatesDisabled" +MDM_DOMAIN="dev.omt.apw" + +require_file() { + if [ ! -f "$1" ]; then + echo "Missing required appcast contract file: $1" >&2 + exit 1 + fi +} + +require_pattern() { + file="$1" + pattern="$2" + description="$3" + if ! grep -Eq "$pattern" "$file"; then + echo "Missing appcast contract requirement in $file: $description" >&2 + exit 1 + fi +} + +require_file "$DOC_PATH" +require_file "$TEMPLATE_PATH" +require_file "$PREPARE_SCRIPT" +require_file "$PREPARE_TEST" +require_file "$PLIST_RENDERER" +require_file "$PLIST_RENDERER_TEST" +require_file "$RELEASE_WORKFLOW" +require_file "$BROKER_CORE" +require_file "$UPDATE_RUNTIME" +require_file "$NATIVE_PACKAGE" +require_file "$BUILD_NATIVE_APP" + +require_pattern "$DOC_PATH" "Sparkle 2" "Sparkle 2 decision" +require_pattern "$DOC_PATH" "$FEED_URL" "stable project-controlled feed URL" +require_pattern "$DOC_PATH" "SUFeedURL" "Info.plist feed key" +require_pattern "$DOC_PATH" "SUPublicEDKey" "Sparkle EdDSA public key" +require_pattern "$DOC_PATH" "SUVerifyUpdateBeforeExtraction=true" "pre-extraction update verification" +require_pattern "$DOC_PATH" "SURequireSignedFeed=true" "signed appcast enforcement" +require_pattern "$DOC_PATH" "$MDM_KEY" "managed preference to disable updates" +require_pattern "$DOC_PATH" "$MDM_DOMAIN" "managed preference domain" +require_pattern "$DOC_PATH" "codesign --deep --strict --verify APW\\.app" "codesign release gate" +require_pattern "$DOC_PATH" "spctl --assess --type execute --verbose APW\\.app" "Gatekeeper release gate" +require_pattern "$DOC_PATH" "xcrun stapler validate APW\\.app" "notarization staple release gate" +require_pattern "$DOC_PATH" "sparkle:criticalUpdate" "security update appcast marker" +require_pattern "$DOC_PATH" "prepare-sparkle-appcast\\.sh" "release appcast preparation helper" +require_pattern "$DOC_PATH" "generate_appcast" "Sparkle appcast generation tool" +require_pattern "$DOC_PATH" "SPARKLE_GENERATE_APPCAST" "release runner generate_appcast configuration" +require_pattern "$DOC_PATH" "APW_SPARKLE_PUBLIC_ED_KEY" "release runner Sparkle public key configuration" +require_pattern "$DOC_PATH" "SPUStandardUpdaterController" "runtime Sparkle updater controller" + +require_pattern "$TEMPLATE_PATH" "xmlns:sparkle=\"http://www\\.andymatuschak\\.org/xml-namespaces/sparkle\"" "Sparkle namespace" +require_pattern "$TEMPLATE_PATH" "APW [0-9]+\\.[0-9]+\\.[0-9]+ Security Update" "security update title" +require_pattern "$TEMPLATE_PATH" "[0-9]+\\.[0-9]+\\.[0-9]+" "machine version" +require_pattern "$TEMPLATE_PATH" "sparkle:releaseNotesLink sparkle:edSignature=" "signed release notes link" +require_pattern "$TEMPLATE_PATH" "/dev/null 2>&1; then + xmllint --noout "$TEMPLATE_PATH" +fi + +echo "Appcast contract validation passed." diff --git a/scripts/prepare-sparkle-appcast.sh b/scripts/prepare-sparkle-appcast.sh new file mode 100755 index 0000000..866110d --- /dev/null +++ b/scripts/prepare-sparkle-appcast.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: ./scripts/prepare-sparkle-appcast.sh --archive PATH --release-notes PATH --updates-dir DIR --generate-appcast PATH [--feed-url URL] + +Prepare a Sparkle updates directory and run Sparkle's generate_appcast tool. +The tool is expected to sign archives, release notes, and the appcast using +Sparkle's configured EdDSA key material. Do not pass private keys on this +script's command line. + +Options: + --archive PATH Signed/notarized APW.app update archive. + --release-notes PATH Markdown release notes for this archive. + --updates-dir DIR Directory holding Sparkle update archives. + --generate-appcast PATH Path to Sparkle's generate_appcast executable. + --feed-url URL Feed URL; default is APW's production appcast URL. + -h, --help Show this help. +USAGE +} + +FEED_URL="https://github.com/OMT-Global/apw-cli/releases/latest/download/appcast.xml" +ARCHIVE_PATH="" +RELEASE_NOTES_PATH="" +UPDATES_DIR="" +GENERATE_APPCAST="" + +while [ "$#" -gt 0 ]; do + case "$1" in + --archive) + ARCHIVE_PATH="${2:-}" + shift 2 + ;; + --release-notes) + RELEASE_NOTES_PATH="${2:-}" + shift 2 + ;; + --updates-dir) + UPDATES_DIR="${2:-}" + shift 2 + ;; + --generate-appcast) + GENERATE_APPCAST="${2:-}" + shift 2 + ;; + --feed-url) + FEED_URL="${2:-}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +fail() { + echo "prepare-sparkle-appcast: $*" >&2 + exit 1 +} + +[ -n "$ARCHIVE_PATH" ] || fail "--archive is required" +[ -n "$RELEASE_NOTES_PATH" ] || fail "--release-notes is required" +[ -n "$UPDATES_DIR" ] || fail "--updates-dir is required" +[ -n "$GENERATE_APPCAST" ] || fail "--generate-appcast is required" + +case "$FEED_URL" in + https://*) ;; + *) fail "--feed-url must be an https URL" ;; +esac + +[ -f "$ARCHIVE_PATH" ] || fail "archive not found: $ARCHIVE_PATH" +[ -f "$RELEASE_NOTES_PATH" ] || fail "release notes not found: $RELEASE_NOTES_PATH" +[ -x "$GENERATE_APPCAST" ] || fail "generate_appcast is not executable: $GENERATE_APPCAST" + +archive_name="$(basename "$ARCHIVE_PATH")" +case "$archive_name" in + *.zip|*.dmg|*.tar|*.tar.gz|*.tar.xz|*.aar) ;; + *) fail "archive must be a Sparkle-supported update archive: $archive_name" ;; +esac + +feed_file="$(basename "$FEED_URL")" +[ -n "$feed_file" ] || fail "unable to derive appcast file name from --feed-url" + +mkdir -p "$UPDATES_DIR" +cp "$ARCHIVE_PATH" "$UPDATES_DIR/$archive_name" +cp "$RELEASE_NOTES_PATH" "$UPDATES_DIR/$archive_name.md" + +"$GENERATE_APPCAST" "$UPDATES_DIR" + +appcast_path="$UPDATES_DIR/$feed_file" +[ -f "$appcast_path" ] || fail "generate_appcast did not create $appcast_path" + +if ! grep -Eq ']*sparkle:edSignature=' "$appcast_path"; then + fail "$appcast_path does not contain a signed Sparkle update enclosure" +fi + +if grep -q 'sparkle:releaseNotesLink' "$appcast_path" && + ! grep -Eq 'sparkle:releaseNotesLink[^>]*sparkle:edSignature=' "$appcast_path"; then + fail "$appcast_path contains unsigned Sparkle release notes" +fi + +if grep -q '&2 + exit 2 +fi + +OUTPUT_PATH="$1" +VERSION="$2" +EXECUTABLE_NAME="$3" +SPARKLE_FEED_URL="https://github.com/OMT-Global/apw-cli/releases/latest/download/appcast.xml" +SPARKLE_PUBLIC_ED_KEY="${APW_SPARKLE_PUBLIC_ED_KEY:-}" + +mkdir -p "$(dirname "$OUTPUT_PATH")" + +{ + cat < + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $EXECUTABLE_NAME + CFBundleIdentifier + dev.omt.apw + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + APW + CFBundlePackageType + APPL + CFBundleShortVersionString + $VERSION + CFBundleVersion + $VERSION + LSUIElement + +EOF + + if [ -n "$SPARKLE_PUBLIC_ED_KEY" ]; then + cat <SUFeedURL + $SPARKLE_FEED_URL + SUPublicEDKey + $SPARKLE_PUBLIC_ED_KEY + SUVerifyUpdateBeforeExtraction + + SURequireSignedFeed + + SUEnableAutomaticChecks + + SUAllowsAutomaticUpdates + + SUAutomaticallyUpdate + +EOF + fi + + cat <<'EOF' + + +EOF +} >"$OUTPUT_PATH" diff --git a/scripts/test-prepare-sparkle-appcast.sh b/scripts/test-prepare-sparkle-appcast.sh new file mode 100755 index 0000000..750218e --- /dev/null +++ b/scripts/test-prepare-sparkle-appcast.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/apw-sparkle-test.XXXXXX")" +trap 'rm -rf "$WORK_DIR"' EXIT + +archive="$WORK_DIR/APW.app.zip" +notes="$WORK_DIR/APW.app.release.md" +updates="$WORK_DIR/updates" +fake_generate="$WORK_DIR/generate_appcast" + +printf 'fake notarized archive\n' >"$archive" +cat >"$notes" <<'NOTES' +# APW 2.0.0 Security Update + +## Security + +- Exercise signed Sparkle appcast generation. +NOTES + +cat >"$fake_generate" <<'FAKE' +#!/usr/bin/env bash +set -euo pipefail + +updates_dir="$1" +cat >"$updates_dir/appcast.xml" < + + + + APW 2.0.0 Security Update + https://github.com/OMT-Global/apw-cli/releases/tag/v2.0.0 + + + + + +XML +FAKE +chmod +x "$fake_generate" + +"$ROOT_DIR/scripts/prepare-sparkle-appcast.sh" \ + --archive "$archive" \ + --release-notes "$notes" \ + --updates-dir "$updates" \ + --generate-appcast "$fake_generate" + +[ -f "$updates/APW.app.zip" ] +[ -f "$updates/APW.app.zip.md" ] +[ -f "$updates/appcast.xml" ] +grep -q 'sparkle:edSignature="signed"' "$updates/appcast.xml" +grep -q 'sparkle:edSignature="notes-signed"' "$updates/appcast.xml" + +unsigned_notes_generate="$WORK_DIR/generate_unsigned_notes_appcast" +cat >"$unsigned_notes_generate" <<'FAKE' +#!/usr/bin/env bash +set -euo pipefail + +updates_dir="$1" +cat >"$updates_dir/appcast.xml" < + + + + APW 2.0.0 Security Update + https://github.com/OMT-Global/apw-cli/releases/tag/v2.0.0 + + + + +XML +FAKE +chmod +x "$unsigned_notes_generate" + +if "$ROOT_DIR/scripts/prepare-sparkle-appcast.sh" \ + --archive "$archive" \ + --release-notes "$notes" \ + --updates-dir "$WORK_DIR/unsigned-notes" \ + --generate-appcast "$unsigned_notes_generate" \ + >"$WORK_DIR/unsigned-notes.out" 2>"$WORK_DIR/unsigned-notes.err"; then + echo "prepare-sparkle-appcast accepted unsigned release notes." >&2 + exit 1 +fi +grep -q "unsigned Sparkle release notes" "$WORK_DIR/unsigned-notes.err" + +missing_security_notes="$WORK_DIR/APW.app.no-security.md" +cat >"$missing_security_notes" <<'NOTES' +# APW 2.0.0 Update + +## Changes + +- Missing the security section required for critical updates. +NOTES + +if "$ROOT_DIR/scripts/prepare-sparkle-appcast.sh" \ + --archive "$archive" \ + --release-notes "$missing_security_notes" \ + --updates-dir "$WORK_DIR/missing-security" \ + --generate-appcast "$fake_generate" \ + >"$WORK_DIR/missing-security.out" 2>"$WORK_DIR/missing-security.err"; then + echo "prepare-sparkle-appcast accepted critical update notes without a Security section." >&2 + exit 1 +fi +grep -q "critical Sparkle updates require a Security section" "$WORK_DIR/missing-security.err" + +echo "Sparkle appcast preparation test passed." diff --git a/scripts/test-render-native-app-info-plist.sh b/scripts/test-render-native-app-info-plist.sh new file mode 100755 index 0000000..39c3aa9 --- /dev/null +++ b/scripts/test-render-native-app-info-plist.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WORK_DIR="$(mktemp -d)" +cleanup() { + rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +base_plist="$WORK_DIR/base/Info.plist" +sparkle_plist="$WORK_DIR/sparkle/Info.plist" +feed_url="https://github.com/OMT-Global/apw-cli/releases/latest/download/appcast.xml" + +"$ROOT_DIR/scripts/render-native-app-info-plist.sh" "$base_plist" "9.9.9" "APW" +grep -q "CFBundleShortVersionString" "$base_plist" +grep -q "9.9.9" "$base_plist" +if grep -q "SUPublicEDKey" "$base_plist"; then + echo "Sparkle public key must not be rendered without APW_SPARKLE_PUBLIC_ED_KEY." >&2 + exit 1 +fi + +APW_SPARKLE_PUBLIC_ED_KEY="test-ed25519-public-key" \ + "$ROOT_DIR/scripts/render-native-app-info-plist.sh" "$sparkle_plist" "9.9.9" "APW" + +grep -q "SUFeedURL" "$sparkle_plist" +grep -q "$feed_url" "$sparkle_plist" +grep -q "SUPublicEDKey" "$sparkle_plist" +grep -q "test-ed25519-public-key" "$sparkle_plist" +grep -q "SUVerifyUpdateBeforeExtraction" "$sparkle_plist" +grep -q "SURequireSignedFeed" "$sparkle_plist" +grep -q "SUAllowsAutomaticUpdates" "$sparkle_plist" +grep -q "" "$sparkle_plist" + +if command -v plutil >/dev/null 2>&1; then + plutil -lint "$base_plist" >/dev/null + plutil -lint "$sparkle_plist" >/dev/null +fi + +echo "Native app Info.plist renderer test passed."