diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f15441a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..26af077 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +* text=auto eol=lf + +*.icns binary +*.png binary +*.zip binary diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..f2f844e --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,10 @@ +# Default owner and security-sensitive release surfaces. +* @s1korrrr +/Sources/DevScopeCore/ @s1korrrr +/Sources/DevScope/Infrastructure/ @s1korrrr +/Sources/DevScope/Stores/ @s1korrrr +/config/ @s1korrrr +/script/ @s1korrrr +/.github/workflows/ @s1korrrr +/SECURITY.md @s1korrrr +/PRIVACY.md @s1korrrr diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..e7c7ff3 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +custom: + - https://buymeacoffee.com/s1korrrr diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..3a9b9ed --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,53 @@ +name: Bug report +description: Report a reproducible DevScope defect without sensitive process data. +title: "[Bug]: " +labels: + - bug +body: + - type: markdown + attributes: + value: >- + Do not report security vulnerabilities here. Redact usernames, commands, + process arguments, project names, credentials, and private paths. + - type: input + id: version + attributes: + label: DevScope version or commit + placeholder: 0.1.0 or commit SHA + validations: + required: true + - type: input + id: environment + attributes: + label: macOS and Mac architecture + placeholder: macOS 15.x, Apple silicon + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behavior + validations: + required: true + - type: textarea + id: reproduce + attributes: + label: Minimal reproduction + description: Use only fixture processes or automation data you can safely modify. + validations: + required: true + - type: checkboxes + id: checks + attributes: + label: Safety checks + options: + - label: I removed secrets and sensitive process, command, and path data. + required: true + - label: This is not a vulnerability report. + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..a6bc2fc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Security vulnerability + url: https://github.com/rsitech-ai/devscope/security/advisories/new + about: Report suspected vulnerabilities privately. Do not open a public issue. + - name: Support and privacy guidance + url: https://github.com/rsitech-ai/devscope/blob/main/SUPPORT.md + about: Read the support and data-redaction guidance before sharing diagnostics. diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml new file mode 100644 index 0000000..b716764 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -0,0 +1,38 @@ +name: Feature request +description: Propose a focused improvement to DevScope. +title: "[Feature]: " +labels: + - enhancement +body: + - type: textarea + id: problem + attributes: + label: Problem + description: What user problem is not handled today? + validations: + required: true + - type: textarea + id: outcome + attributes: + label: Desired outcome + description: Describe the result rather than a required implementation. + validations: + required: true + - type: textarea + id: safety + attributes: + label: Security, privacy, and destructive-action impact + description: Note any new data access, process control, automation mutation, or network behavior. + validations: + required: true + - type: dropdown + id: distribution + attributes: + label: Distribution surface + options: + - Full-feature direct download + - Sandbox-compatible build + - Both, with explicitly different behavior + - Not applicable + validations: + required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..ac74e78 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,37 @@ +## Outcome + + + +## Related issue + + + +## Change type + +- [ ] Bug fix +- [ ] Feature +- [ ] Documentation +- [ ] CI, packaging, or maintenance +- [ ] Security or privacy hardening + +## Risk and boundaries + + + +## Breaking-change impact + + + +## Verification + +- [ ] Added or updated focused tests for behavior changes +- [ ] `./script/check_open_source_readiness.sh` +- [ ] `swift test -Xswiftc -warnings-as-errors` +- [ ] `swift build -c release -Xswiftc -warnings-as-errors` +- [ ] Relevant exact-app or package smoke completed +- [ ] Public docs and changelog updated when guarantees changed +- [ ] No secrets, private paths, unredacted logs, generated bundles, or unrelated changes included + +## Evidence + + diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..fab0b1b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + labels: + - dependencies + - github-actions diff --git a/.github/workflows/release-gates.yml b/.github/workflows/release-gates.yml new file mode 100644 index 0000000..4582750 --- /dev/null +++ b/.github/workflows/release-gates.yml @@ -0,0 +1,82 @@ +name: Release Gates + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: release-gates-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + macos: + name: macOS ${{ matrix.architecture }} + strategy: + fail-fast: false + matrix: + include: + - runner: macos-26 + architecture: arm64 + - runner: macos-26-intel + architecture: x86_64 + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + + steps: + - name: Check out source + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Record Apple toolchain + run: | + xcodebuild -version + swift --version + + - name: Validate shell scripts + run: bash -n script/*.sh + + - name: Validate public-source readiness + run: ./script/check_open_source_readiness.sh + + - name: Verify process runner under constrained executor + env: + LIBDISPATCH_COOPERATIVE_POOL_STRICT: "1" + run: | + swift test -Xswiftc -warnings-as-errors \ + --filter SystemAutomationCommandRunnerTests/testExecutionTimeoutTerminatesTheSpawnedProcessGroupAndFailsClosed + swift test -Xswiftc -warnings-as-errors \ + --filter SystemAutomationCommandRunnerTests/testCancellationKillsPipeHoldingDescendantAfterLeaderExits + + - name: Run tests with warnings as errors + run: swift test -Xswiftc -warnings-as-errors + + - name: Build Release with warnings as errors + run: swift build -c release -Xswiftc -warnings-as-errors + + - name: Build and validate sandbox release bundle + env: + DEVSCOPE_DIST_DIR: ${{ runner.temp }}/devscope-release + run: | + app_path="$(./script/build_release_bundle.sh)" + DEVSCOPE_REQUIRE_SANDBOX=1 ./script/validate_release_bundle.sh "$app_path" + file "$app_path/Contents/MacOS/DevScope" + + - name: Build and validate full-access release bundle + env: + DEVSCOPE_DIST_DIR: ${{ runner.temp }}/devscope-full-access + DEVSCOPE_ENTITLEMENTS: ${{ github.workspace }}/config/DevScopeDeveloperID.entitlements + run: | + app_path="$(./script/build_release_bundle.sh)" + DEVSCOPE_REQUIRE_SANDBOX=0 \ + DEVSCOPE_REQUIRE_APPLE_EVENTS=1 \ + ./script/validate_release_bundle.sh "$app_path" + file "$app_path/Contents/MacOS/DevScope" + + - name: Verify tracked files stayed clean + run: git diff --exit-code diff --git a/.github/workflows/secret-scan.yml b/.github/workflows/secret-scan.yml new file mode 100644 index 0000000..0ef1549 --- /dev/null +++ b/.github/workflows/secret-scan.yml @@ -0,0 +1,45 @@ +name: Secret Scan + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: secret-scan-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + gitleaks: + name: Full-history Gitleaks scan + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Check out full history + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Download checksum-pinned Gitleaks CLI + env: + GITLEAKS_VERSION: 8.30.1 + GITLEAKS_LINUX_X64_SHA256: 551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb + run: | + archive="$RUNNER_TEMP/gitleaks.tar.gz" + curl --fail --location --silent --show-error \ + "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \ + --output "$archive" + echo "$GITLEAKS_LINUX_X64_SHA256 $archive" | sha256sum --check --strict + tar -xzf "$archive" -C "$RUNNER_TEMP" gitleaks + "$RUNNER_TEMP/gitleaks" version + + - name: Scan complete repository history + run: | + "$RUNNER_TEMP/gitleaks" git --redact --no-banner --no-color . diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..73d38a2 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,19 @@ +title = "DevScope Gitleaks configuration" + +[extend] +useDefault = true + +[[allowlists]] +description = "Synthetic credential-redaction fixtures in security regression tests" +condition = "AND" +regexTarget = "line" +targetRules = ["curl-auth-header", "curl-auth-user"] +paths = [ + '''(^|/)Tests/DevScopeCoreTests/ProcessPresentationTests\.swift$''', + '''(^|/)Tests/DevScopeCoreTests/AutomationManagerTests\.swift$''', +] +regexes = [ + '''SYNTH_[A-Z0-9_]+''', + '''curl\s+(?:-u|--user)\s+(?:'user:curl secret'|user:curlsecret|user:curl --header-shaped secret;stillsecret)''', + '''Authorization: secret-value''', +] diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f88eb86 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,45 @@ +# Changelog + +All notable changes to DevScope are documented here. The format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and releases use +[Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Changed + +- Recorded Rafal Sikora as copyright owner and RSI Tech as the public + maintainer, with `rsitech.ai` and `info@rsitech.ai` as the project routes. +- Updated release evidence to distinguish the installed Developer ID identity + from the still-missing notarization credentials and Keychain profile. + +## [0.1.0-preview.1] - 2026-07-20 + +### Added + +- Apache-2.0 licensing and an explicit open-source publication contract. +- Contribution, governance, security, privacy, support, and conduct policies. +- Deterministic public-source readiness validation. +- Installation, update, uninstall, troubleshooting, and maintainer release procedures. +- CI validation for both sandbox and full-access bundle policies. +- Fresh-history publication under the `rsitech-ai` GitHub organization. +- A reproducible checksummed community-preview packager with an embedded + unnotarized-build warning. + +### Fixed + +- Recovery-cache payload limits now account for JSON escaping and reject oversized encoded metadata before writing, so every accepted save remains readable. +- Sensitive HTTP header redaction now removes complete Digest, AWS signature, proxy-authorization, and cookie values. +- Partial filesystem mutations now expose exact local recovery locations with Copy Path and Reveal in Finder actions. +- Table context-menu command copying now updates the same durable redacted recovery payload as the command bar. +- Development staging now re-signs the completed app bundle so Info.plist and resources are covered. +- Release app bundles now embed Apache-2.0 and third-party notice files. + +### Added + +- Native macOS running-activity inspection with process hierarchy and live resource metrics. +- Local process actions with explicit TERM, tree-termination, and force-kill boundaries. +- Automation inventory and management for supported local launchd, login-item, and cron sources. +- Optional on-device Foundation Models enrichment with deterministic scanner facts as authority. +- Local recovery for bounded copied-command and export data. +- Universal local and sandbox validation paths for Apple silicon and Intel slices. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..43ae4ac --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,31 @@ +# Code of Conduct + +## Our commitment + +We commit to a welcoming, harassment-free project for everyone, regardless of +background, identity, experience, education, appearance, or ability. + +## Expected behavior + +- Be respectful, specific, and constructive. +- Assume good intent while addressing the actual technical or community impact. +- Accept correction, protect private information, and give credit. +- Keep discussion relevant to the project and its users. + +## Unacceptable behavior + +Harassment, threats, discriminatory language, sexualized attention, deliberate +intimidation, doxxing, sustained disruption, and retaliation are not tolerated. + +## Reporting and enforcement + +For ordinary moderation, contact the maintainers through a repository discussion +or issue. For a private report, email `info@rsitech.ai` with the subject +`DevScope conduct report`, or use GitHub's private vulnerability-reporting flow +described in [SECURITY.md](SECURITY.md) and prefix the report title with +`Conduct report`. Do not include more personal data than necessary. + +Maintainers may edit or remove contributions and may temporarily or permanently +restrict participation when behavior is unsafe or persistently disruptive. +Reports will be handled as privately as the investigation permits. This policy +applies in project spaces and when someone publicly represents the project. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..a645af6 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,48 @@ +# Contributing to DevScope + +Thank you for helping improve DevScope. This project accepts focused bug fixes, +tests, documentation, accessibility improvements, and maintainable product work. +RSI Tech maintains the project; see or contact +`info@rsitech.ai` for project matters that do not belong in a public issue. + +## Development environment + +- macOS 14 or newer +- A current stable Xcode toolchain with Swift 6 support +- Git and a POSIX-compatible shell + +Clone the repository, then run: + +```bash +./script/check_open_source_readiness.sh +swift test -Xswiftc -warnings-as-errors +swift build -c release -Xswiftc -warnings-as-errors +./script/build_and_run.sh --verify +``` + +The application is a Swift package. `DevScopeCore` holds testable domain and +system logic, while the `DevScope` executable composes macOS services and SwiftUI. +See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the boundaries. + +## Safety rules + +- Never commit secrets, credentials, provisioning profiles, private keys, or unredacted user logs. +- Never test termination behavior against a user's real work. Use an isolated fixture process you created and can safely stop. +- Keep process facts deterministic. On-device model output may enrich presentation but must not control destructive actions. +- Preserve confirmation, authorization, and result-verification boundaries around process and automation mutations. +- Add a focused regression test for behavior changes and one realistic smoke check when system integration changes. +- Keep generated bundles, archives, derived data, and local release evidence out of Git. + +## Pull requests + +1. Open an issue first for large behavior or architecture changes. +2. Keep the change scoped and explain the user-facing outcome and risk. +3. Include tests and update public documentation when behavior or guarantees change. +4. Complete the pull-request checklist and wait for required checks and review. +5. Do not include unrelated formatting or generated-file churn. + +By contributing, you agree that your contribution is licensed under the +project's [Apache License 2.0](LICENSE), consistent with section 5 of that license. + +Security vulnerabilities must not be filed as public issues. Follow +[SECURITY.md](SECURITY.md). diff --git a/GOVERNANCE.md b/GOVERNANCE.md new file mode 100644 index 0000000..45caea0 --- /dev/null +++ b/GOVERNANCE.md @@ -0,0 +1,27 @@ +# Governance + +DevScope is currently a maintainer-led project. + +## Roles and decisions + +- Rafal Sikora is the copyright owner. +- RSI Tech is the public project, release, and security maintainer. +- `@s1korrrr` is the maintainer's operational GitHub account. +- Public and confidential project contact: `info@rsitech.ai`. +- Project website: . +- Contributors propose changes through issues and pull requests. +- Maintainers decide by technical merit, product safety, compatibility, and long-term maintenance cost. +- Material architecture, security, privacy, or product-scope changes require an explicit maintainer decision recorded in the pull request or repository documentation. + +No contributor has an automatic right to merge, publish, sign, notarize, or make +a release. Security fixes may be developed privately until coordinated disclosure. + +## Releases + +A release requires passing source, test, package, and review gates. Publishing +source does not imply that a downloadable binary is Developer ID signed, +notarized, App Store reviewed, or supported on untested hardware. Release notes +must state those boundaries accurately. + +If the maintainer group grows, this document will be updated before granting +additional merge or release authority. diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000..47fd559 --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,49 @@ +# Privacy + +DevScope is a local-first macOS process and automation inspection tool. It does +not require an account, operate a project backend, include advertising or +analytics SDKs, or intentionally send process data to the project maintainer. + +## Data processed on the Mac + +Depending on enabled features and macOS permissions, DevScope may read: + +- running-process identifiers, names, commands, executable paths, parent/child relationships, resource usage, and working-directory hints; +- supported local automation metadata, such as launchd definitions, login items, and user cron entries; +- user preferences and bounded local history needed for graphs, favorites, watch state, and workflow presentation; +- the last command or export copied through DevScope, stored in a bounded recovery file in Application Support so it can be restored after relaunch. + +Process commands and paths can contain sensitive project names, arguments, or +local usernames. Review exports and screenshots before sharing them. + +## Optional on-device intelligence + +When Apple Foundation Models are available and the feature is enabled, DevScope +may use the operating system's on-device model APIs to improve process names or +workflow notes. Model output is presentation-only; deterministic scanner facts +remain authoritative and model output does not authorize destructive actions. + +## Storage and deletion + +DevScope stores preferences and bounded support data in the current user's macOS +Library containers/Application Support locations. Preferences can be reset in +the app where offered. Removing DevScope's related Application Support and +preferences data deletes local app state; uninstalling the app alone may leave +those standard macOS support files in place. + +## Network behavior + +Core scanning and automation features do not require a project-operated network +service. User-invoked support actions can open the documented Buy Me a Coffee +website in the default browser. macOS or Apple frameworks may have their own +system behavior and policies. + +## Permissions and control + +DevScope does not use privileged APIs. macOS controls which processes and files +the current user may inspect or change. Full Disk Access may improve visibility +for protected metadata, but it does not bypass normal process ownership or +authorization rules. App Sandbox builds have a substantially reduced feature set. + +For a security issue, follow [SECURITY.md](SECURITY.md). For other privacy +questions, open a repository issue without attaching sensitive process data. diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..3f40276 --- /dev/null +++ b/Package.swift @@ -0,0 +1,29 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "DevScope", + platforms: [ + .macOS(.v14) + ], + products: [ + .library(name: "DevScopeCore", targets: ["DevScopeCore"]), + .executable(name: "DevScope", targets: ["DevScope"]) + ], + targets: [ + .target(name: "DevScopeCore"), + .executableTarget( + name: "DevScope", + dependencies: ["DevScopeCore"] + ), + .testTarget( + name: "DevScopeCoreTests", + dependencies: ["DevScopeCore"] + ), + .testTarget( + name: "DevScopeTests", + dependencies: ["DevScope", "DevScopeCore"] + ) + ] +) diff --git a/Resources/AppIcon.svg b/Resources/AppIcon.svg new file mode 100644 index 0000000..9ccb48f --- /dev/null +++ b/Resources/AppIcon.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Resources/PrivacyInfo.xcprivacy b/Resources/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..534bd86 --- /dev/null +++ b/Resources/PrivacyInfo.xcprivacy @@ -0,0 +1,21 @@ + + + + + NSPrivacyTracking + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + + diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..785edc7 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,34 @@ +# Security Policy + +## Supported versions + +Security fixes target the latest code on `main` and the most recent published +release. Older or unofficial builds may not receive fixes. + +## Report a vulnerability privately + +Do not open a public issue for a suspected vulnerability. Use the repository's +GitHub **Security** tab and choose **Report a vulnerability**: + + + +Include the affected version or commit, macOS version, reproducible steps, +impact, and any suggested mitigation. Remove secrets, real process arguments, +private paths, and unrelated personal data. If private vulnerability reporting +is unavailable, open a minimal public issue asking the maintainer to establish a +private contact channel; do not disclose the vulnerability there. If GitHub's +private reporting flow is unavailable, email `info@rsitech.ai` with the subject +`DevScope security report`. Do not send secrets that are not required to +reproduce the issue. + +RSI Tech will assess reports on a best-effort basis, coordinate a fix and +disclosure when appropriate, and credit reporters who want attribution. This is +not an emergency-response service and no response-time guarantee is offered. + +## Security boundaries + +- DevScope runs with the current macOS user's authority and does not provide privilege escalation. +- Process and automation actions are security-sensitive. Confirmation, scope, authorization, and post-action verification must remain explicit. +- Optional Foundation Models output is never trusted to authorize a destructive action. +- Local logs, exports, commands, and paths may contain sensitive information and must be redacted before sharing. +- A local ad-hoc build is not a production distribution artifact. Public binaries require Developer ID signing, hardened runtime, notarization, stapling, and Gatekeeper validation. diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 0000000..2d7c13d --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,19 @@ +# Support + +DevScope is maintained on a best-effort basis without a support SLA. + +RSI Tech maintains the public project at . Public and +confidential project contact: `info@rsitech.ai`. + +- Use [GitHub Issues](https://github.com/rsitech-ai/devscope/issues) for reproducible bugs and focused feature requests. +- Use [GitHub Discussions](https://github.com/rsitech-ai/devscope/discussions) for usage questions when Discussions are enabled. +- Follow [SECURITY.md](SECURITY.md) for private vulnerability reports. +- Read [PRIVACY.md](PRIVACY.md) before attaching logs, screenshots, exports, commands, or local paths. + +When filing a bug, include the DevScope version or commit, macOS version, Mac +architecture, expected and actual behavior, and minimal reproduction steps. +Redact usernames, process arguments, project names, credentials, and private +paths. Do not ask contributors to run destructive commands against real work. + +The full-feature product currently targets direct macOS distribution. Sandbox +validation does not imply feature parity with the direct-download build. diff --git a/Sources/DevScope/App/AutomationComposition.swift b/Sources/DevScope/App/AutomationComposition.swift new file mode 100644 index 0000000..7e98880 --- /dev/null +++ b/Sources/DevScope/App/AutomationComposition.swift @@ -0,0 +1,136 @@ +import Darwin +import DevScopeCore +import Foundation + +@MainActor +enum DevScopeComposition { + struct Stores { + let processStore: ProcessStore + let automationStore: AutomationStore + let automationNotifier: AutomationNotifier + } + + static func make() -> Stores { + let currentUID = getuid() + let homeDirectory = FileManager.default.homeDirectoryForCurrentUser.standardizedFileURL + let applicationSupport = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first?.appending(path: "DevScope", directoryHint: .isDirectory) + ?? homeDirectory.appending(path: "Library/Application Support/DevScope", directoryHint: .isDirectory) + let transactionRoot = applicationSupport.appending( + path: "AutomationTransactions", + directoryHint: .isDirectory + ) + let recoveryRoot = applicationSupport.appending( + path: "AutomationRecovery", + directoryHint: .isDirectory + ) + let launchAgentsRoot = homeDirectory.appending( + path: "Library/LaunchAgents", + directoryHint: .isDirectory + ) + + let runner = SystemAutomationCommandRunner() + let fileSystem = LocalAutomationFileSystem() + try? fileSystem.createDirectory(transactionRoot, permissions: 0o700) + try? fileSystem.createDirectory(recoveryRoot, permissions: 0o700) + let processScanner = SystemProcessScanner() + let legacyListing = OSACommandLegacyLoginItemAdapter(runner: runner) + let sources: [any AutomationSource] = [ + LaunchdAutomationSource( + fileSystem: fileSystem, + currentUID: currentUID, + roots: LaunchdAutomationSource.defaultRoots(homeDirectory: homeDirectory), + runtimeStateProvider: LaunchctlAutomationRuntimeStateProvider(runner: runner) + ), + BackgroundTaskAutomationSource(runner: runner), + LegacyLoginItemAutomationSource(adapter: legacyListing, currentUID: currentUID), + CronAutomationSource( + commandRunner: runner, + currentUID: currentUID, + currentUsername: currentUsername(for: currentUID) + ), + ] + let inventoryService = AutomationInventoryService( + sources: sources, + minimumRefreshInterval: 60 + ) + let executor = AutomationExecutorRouter( + launchd: LaunchdAutomationExecutor( + runner: runner, + guiUID: currentUID, + fileSystem: fileSystem + ), + cron: CronAutomationExecutor( + runner: runner, + fileSystem: fileSystem, + currentUID: currentUID, + processSnapshot: { + try processScanner.snapshot(includeCurrentDirectories: false) + } + ), + legacy: LegacyLoginItemAutomationExecutor( + runner: runner, + listing: legacyListing, + currentUID: currentUID + ) + ) + let recoverableSources = AutomationRecoverableSourceProvider( + runner: runner, + fileSystem: fileSystem, + legacyListing: legacyListing, + root: transactionRoot, + currentUID: currentUID + ) + let authority = AutomationAuthorityContextBuilder( + fileSystem: fileSystem, + currentUID: currentUID, + launchAgentsRoot: launchAgentsRoot, + transactionRoot: transactionRoot + ) + let manager = AutomationManager( + fileSystem: fileSystem, + executor: executor, + capabilityContext: { record in + try authority.context(for: record) + }, + destinationContext: { record, destination in + try authority.context(for: record, destination: destination) + }, + recoverableSource: { record in + try await recoverableSources.capture(record) + }, + refresh: { + await inventoryService.refreshAfterCurrent() + }, + refreshProcesses: { + try processScanner.snapshot(includeCurrentDirectories: false) + }, + backupDirectory: recoveryRoot, + currentUID: currentUID + ) + + return Stores( + processStore: ProcessStore(scanner: processScanner), + automationStore: AutomationStore( + inventoryService: inventoryService, + manager: manager, + capabilityDecisionProvider: AutomationAuthorityCapabilityDecisionProvider( + authority: authority + ), + destinationProvider: AutomationManagementDestinationProvider( + transactionRoot: transactionRoot + ) + ), + automationNotifier: AutomationNotifier() + ) + } + + private static func currentUsername(for uid: uid_t) -> String { + guard let passwordEntry = getpwuid(uid), let name = passwordEntry.pointee.pw_name else { + return NSUserName() + } + return String(cString: name) + } +} diff --git a/Sources/DevScope/App/AutomationDocumentPanel.swift b/Sources/DevScope/App/AutomationDocumentPanel.swift new file mode 100644 index 0000000..459a640 --- /dev/null +++ b/Sources/DevScope/App/AutomationDocumentPanel.swift @@ -0,0 +1,124 @@ +import AppKit +import Darwin +import DevScopeCore +import Foundation +import UniformTypeIdentifiers + +struct AutomationImportedDocument: Identifiable { + let id = UUID() + let sourceURL: URL + let data: Data +} + +@MainActor +final class AutomationDocumentPanel { + static let maximumImportBytes = 2 * 1_024 * 1_024 + + static func validatedImportData(at url: URL) throws -> Data { + let descriptor = Darwin.open( + url.path, + O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK + ) + guard descriptor >= 0 else { throw currentPOSIXError() } + let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: true) + defer { try? handle.close() } + + var before = stat() + guard fstat(descriptor, &before) == 0 else { throw currentPOSIXError() } + guard (before.st_mode & mode_t(S_IFMT)) == mode_t(S_IFREG) else { + throw CocoaError(.fileReadCorruptFile) + } + guard before.st_size >= 0, + before.st_size <= off_t(maximumImportBytes) else { + throw CocoaError(.fileReadTooLarge) + } + + var data = Data() + while data.count <= maximumImportBytes { + let remaining = maximumImportBytes + 1 - data.count + guard let chunk = try handle.read(upToCount: min(64 * 1_024, remaining)), + !chunk.isEmpty else { break } + data.append(chunk) + } + guard data.count <= maximumImportBytes else { throw CocoaError(.fileReadTooLarge) } + + var after = stat() + guard fstat(descriptor, &after) == 0 else { throw currentPOSIXError() } + guard before.st_dev == after.st_dev, + before.st_ino == after.st_ino, + before.st_size == after.st_size, + before.st_mtimespec.tv_sec == after.st_mtimespec.tv_sec, + before.st_mtimespec.tv_nsec == after.st_mtimespec.tv_nsec, + data.count == Int(after.st_size) else { + throw CocoaError(.fileReadCorruptFile) + } + return data + } + + private static func currentPOSIXError() -> POSIXError { + POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + + static func writeExport(_ artifact: AutomationExportArtifact, to url: URL) throws { + let fileManager = FileManager.default + let temporaryURL = url.deletingLastPathComponent() + .appendingPathComponent(".devscope-export-\(UUID().uuidString)") + guard fileManager.createFile( + atPath: temporaryURL.path, + contents: nil, + attributes: [.posixPermissions: 0o600] + ) else { + throw CocoaError(.fileWriteUnknown) + } + defer { try? fileManager.removeItem(at: temporaryURL) } + + let handle = try FileHandle(forWritingTo: temporaryURL) + do { + try handle.write(contentsOf: artifact.data) + try handle.synchronize() + try handle.close() + } catch { + try? handle.close() + throw error + } + + if fileManager.fileExists(atPath: url.path) { + _ = try fileManager.replaceItemAt( + url, + withItemAt: temporaryURL, + backupItemName: nil, + options: [.usingNewMetadataOnly] + ) + } else { + try fileManager.moveItem(at: temporaryURL, to: url) + } + try fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + let attributes = try fileManager.attributesOfItem(atPath: url.path) + guard (attributes[.posixPermissions] as? NSNumber)?.intValue == 0o600 else { + throw CocoaError(.fileWriteNoPermission) + } + } + + func openImport() throws -> AutomationImportedDocument? { + let panel = NSOpenPanel() + panel.title = "Import Automation" + panel.prompt = "Preview Import" + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + panel.allowedContentTypes = [.propertyList, .plainText, .json] + guard panel.runModal() == .OK, let url = panel.url else { return nil } + return AutomationImportedDocument( + sourceURL: url, + data: try Self.validatedImportData(at: url) + ) + } + + func save(_ artifact: AutomationExportArtifact) throws -> Bool { + let panel = NSSavePanel() + panel.title = artifact.isRedacted ? "Save Redacted Automation Export" : "Save Unredacted Automation Export" + panel.nameFieldStringValue = artifact.suggestedFilename + guard panel.runModal() == .OK, let url = panel.url else { return false } + try Self.writeExport(artifact, to: url) + return true + } +} diff --git a/Sources/DevScope/App/DevScopeApp.swift b/Sources/DevScope/App/DevScopeApp.swift new file mode 100644 index 0000000..eb09dbb --- /dev/null +++ b/Sources/DevScope/App/DevScopeApp.swift @@ -0,0 +1,66 @@ +import AppKit +import SwiftUI + +@main +struct DevScopeApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + @StateObject private var processStore: ProcessStore + @StateObject private var automationStore: AutomationStore + @StateObject private var automationNotifier: AutomationNotifier + + init() { + let composition = DevScopeComposition.make() + _processStore = StateObject(wrappedValue: composition.processStore) + _automationStore = StateObject(wrappedValue: composition.automationStore) + _automationNotifier = StateObject(wrappedValue: composition.automationNotifier) + } + + var body: some Scene { + Window("DevScope", id: "main") { + ContentView( + store: processStore, + automationStore: automationStore, + automationNotifier: automationNotifier + ) + .frame(minWidth: 1120, minHeight: 620) + } + .windowResizability(.contentSize) + .commands { + CommandGroup(replacing: .appSettings) { + SettingsLink { + Text("Settings...") + } + .keyboardShortcut(",", modifiers: [.command]) + } + + CommandGroup(after: .appInfo) { + Button("Refresh Current Workspace") { + NotificationCenter.default.post(name: .devScopeRefreshRequested, object: nil) + } + .keyboardShortcut("r", modifiers: [.command]) + + Button("Restore Last Copy") { + NotificationCenter.default.post(name: .devScopeRestoreLastCopyRequested, object: nil) + } + .keyboardShortcut("e", modifiers: [.command, .shift]) + } + } + + Settings { + SettingsView(automationNotifier: automationNotifier) + } + } +} + +final class AppDelegate: NSObject, NSApplicationDelegate { + func applicationDidFinishLaunching(_ notification: Notification) { + NSApp.setActivationPolicy(.regular) + NSApp.activate(ignoringOtherApps: true) + } +} + +extension Notification.Name { + static let devScopeRefreshRequested = Notification.Name("devScopeRefreshRequested") + static let devScopeRestoreLastCopyRequested = Notification.Name( + "devScopeRestoreLastCopyRequested") +} diff --git a/Sources/DevScope/App/DevScopeIntents.swift b/Sources/DevScope/App/DevScopeIntents.swift new file mode 100644 index 0000000..05ebc11 --- /dev/null +++ b/Sources/DevScope/App/DevScopeIntents.swift @@ -0,0 +1,62 @@ +import AppIntents +import AppKit +import Foundation + +struct OpenDevScopeIntent: AppIntent { + static let title: LocalizedStringResource = "Open DevScope" + static let description = IntentDescription("Open DevScope to inspect local apps, services, agents, and processes.") + static var openAppWhenRun: Bool { true } + + @available(macOS 26.0, *) + static var supportedModes: IntentModes { + .foreground(.immediate) + } + + @MainActor + func perform() async throws -> some IntentResult { + NSApp.activate(ignoringOtherApps: true) + return .result() + } +} + +struct RefreshDevScopeIntent: AppIntent { + static let title: LocalizedStringResource = "Refresh DevScope" + static let description = IntentDescription("Refresh the DevScope running activity list.") + static var openAppWhenRun: Bool { true } + + @available(macOS 26.0, *) + static var supportedModes: IntentModes { + .foreground(.immediate) + } + + @MainActor + func perform() async throws -> some IntentResult { + NSApp.activate(ignoringOtherApps: true) + NotificationCenter.default.post(name: .devScopeRefreshRequested, object: nil) + return .result() + } +} + +struct DevScopeShortcuts: AppShortcutsProvider { + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: OpenDevScopeIntent(), + phrases: [ + "Open \(.applicationName)", + "Show \(.applicationName)" + ], + shortTitle: "Open DevScope", + systemImageName: "scope" + ) + + AppShortcut( + intent: RefreshDevScopeIntent(), + phrases: [ + "Refresh \(.applicationName)", + "Scan with \(.applicationName)" + ], + shortTitle: "Refresh", + systemImageName: "arrow.clockwise" + ) + } +} diff --git a/Sources/DevScope/App/DevScopeSupport.swift b/Sources/DevScope/App/DevScopeSupport.swift new file mode 100644 index 0000000..071262b --- /dev/null +++ b/Sources/DevScope/App/DevScopeSupport.swift @@ -0,0 +1,6 @@ +import Foundation + +enum DevScopeSupport { + static let buyMeACoffeeURLString = "https://buymeacoffee.com/s1korrrr" + static let buyMeACoffeeURL = URL(string: buyMeACoffeeURLString)! +} diff --git a/Sources/DevScope/Infrastructure/AutomationManagementInfrastructure.swift b/Sources/DevScope/Infrastructure/AutomationManagementInfrastructure.swift new file mode 100644 index 0000000..e185624 --- /dev/null +++ b/Sources/DevScope/Infrastructure/AutomationManagementInfrastructure.swift @@ -0,0 +1,487 @@ +import CryptoKit +import Darwin +import DevScopeCore +import Foundation + +struct AutomationExecutorRouter: AutomationMutationApplying { + let launchd: LaunchdAutomationExecutor + let cron: CronAutomationExecutor + let legacy: LegacyLoginItemAutomationExecutor + + func apply( + _ operation: AutomationOperation, + to record: AutomationRecord, + linkedProcesses: [ClassifiedDevProcess], + proposedSourceURL: URL? + ) async throws -> AutomationExecutorResult { + switch record.sourceKind { + case .launchAgent: + try await launchd.apply( + operation, + to: record, + linkedProcesses: linkedProcesses, + proposedSourceURL: proposedSourceURL + ) + case .crontab: + try await cron.apply( + operation, + to: record, + linkedProcesses: linkedProcesses, + proposedSourceURL: proposedSourceURL + ) + case .legacyLoginItem: + try await legacy.apply( + operation, + to: record, + linkedProcesses: linkedProcesses, + proposedSourceURL: proposedSourceURL + ) + case .launchDaemon, .serviceManagement: + throw AutomationExecutorError.unsupportedSource + } + } + + func restorePreTransactionState( + _ state: AutomationPreTransactionState, + for record: AutomationRecord, + recovery: AutomationVerifiedRecoveryInput, + linkedProcesses: [ClassifiedDevProcess], + expectedAppliedState: AutomationExecutorAppliedState? + ) async throws -> AutomationExecutorResult { + switch record.sourceKind { + case .launchAgent: + try await launchd.restorePreTransactionState( + state, + for: record, + recovery: recovery, + linkedProcesses: linkedProcesses, + expectedAppliedState: expectedAppliedState + ) + case .crontab: + try await cron.restorePreTransactionState( + state, + for: record, + recovery: recovery, + linkedProcesses: linkedProcesses, + expectedAppliedState: expectedAppliedState + ) + case .legacyLoginItem: + try await legacy.restorePreTransactionState( + state, + for: record, + recovery: recovery, + linkedProcesses: linkedProcesses, + expectedAppliedState: expectedAppliedState + ) + case .launchDaemon, .serviceManagement: + throw AutomationExecutorError.unsupportedSource + } + } +} + +actor AutomationRecoverableSourceProvider { + private let runner: any AutomationCommandRunning + private let fileSystem: any AutomationFileSystem + private let legacyListing: any LegacyLoginItemListing + private let root: URL + private let currentUID: uid_t + + init( + runner: any AutomationCommandRunning, + fileSystem: any AutomationFileSystem, + legacyListing: any LegacyLoginItemListing, + root: URL, + currentUID: uid_t + ) { + self.runner = runner + self.fileSystem = fileSystem + self.legacyListing = legacyListing + self.root = root.standardizedFileURL + self.currentUID = currentUID + } + + func capture(_ record: AutomationRecord) async throws -> AutomationRecoverableSource { + guard record.ownership == .user, record.ownerUID == currentUID else { + throw AutomationManagerConfigurationError.recoverableSourceUnavailable + } + switch record.sourceKind { + case .crontab: + return try await captureCron(record) + case .legacyLoginItem: + return try await captureLegacyLoginItem(record) + default: + throw AutomationManagerConfigurationError.recoverableSourceUnavailable + } + } + + private func captureCron( + _ record: AutomationRecord + ) async throws -> AutomationRecoverableSource { + let listed = try await runner.run(AutomationCommand( + executable: "/usr/bin/crontab", + arguments: ["-l"], + environment: ["LC_ALL": "C"] + )) + guard listed.status == 0, + let checksum = CronDocumentChecksum.checksum(listed.standardOutput), + checksum == record.sourceChecksum else { + throw AutomationManagerConfigurationError.recoverableSourceUnavailable + } + return try materialize( + data: listed.standardOutput, + checksum: checksum, + at: Self.transactionURL(for: record, root: root) + ) + } + + private func captureLegacyLoginItem( + _ record: AutomationRecord + ) async throws -> AutomationRecoverableSource { + guard let selectedPath = record.executable else { + throw AutomationManagerConfigurationError.recoverableSourceUnavailable + } + let canonicalSelectedPath = URL(fileURLWithPath: selectedPath).standardizedFileURL.path + let items = try await legacyListing.currentUserLoginItems() + guard let descriptor = items.first(where: { + $0.name == record.label + && URL(fileURLWithPath: $0.path).standardizedFileURL.path == canonicalSelectedPath + }) else { + throw AutomationManagerConfigurationError.recoverableSourceUnavailable + } + let data = try LegacyLoginItemRecoveryDocument.encode( + selectedRecord: record, + descriptor: descriptor, + currentUID: currentUID + ) + let checksum = LegacyLoginItemRecoveryDocument.checksum(ofEncodedDocument: data) + guard checksum == record.sourceChecksum else { + throw AutomationManagerConfigurationError.recoverableSourceUnavailable + } + return try materialize( + data: data, + checksum: checksum, + at: Self.transactionURL(for: record, root: root) + ) + } + + private func materialize( + data: Data, + checksum: String, + at url: URL + ) throws -> AutomationRecoverableSource { + try fileSystem.createDirectory(root, permissions: 0o700) + let rootMetadata = try fileSystem.metadata(for: root) + guard rootMetadata.canonicalURL.standardizedFileURL.path == root.path, + rootMetadata.ownerUID == currentUID, + !rootMetadata.isSymbolicLink, + rootMetadata.permissions == 0o700 else { + throw AutomationManagerConfigurationError.recoverableSourceUnavailable + } + guard let rootAuthorization = AutomationDirectoryAuthorization( + directoryURL: root, + resourceIdentifier: rootMetadata.resourceIdentifier + ) else { throw AutomationManagerConfigurationError.recoverableSourceUnavailable } + if fileSystem.itemExists(at: url) { + let metadata = try fileSystem.metadata(for: url) + guard metadata.canonicalURL.standardizedFileURL.path == url.path, + metadata.ownerUID == currentUID, + !metadata.isSymbolicLink, + metadata.permissions == 0o600 else { + throw AutomationManagerConfigurationError.recoverableSourceUnavailable + } + if try fileSystem.read(url) != data { + let staged = try fileSystem.writeStagedFile( + nextTo: url, + data: data, + permissions: 0o600, + authorization: rootAuthorization + ) + guard let destination = AutomationFileAuthorization( + fileURL: url, + directory: rootAuthorization, + expectation: .existing(resourceIdentifier: metadata.resourceIdentifier ?? "") + ) else { throw AutomationManagerConfigurationError.recoverableSourceUnavailable } + try install(destination: destination, staged: staged) + } + } else { + let staged = try fileSystem.writeStagedFile( + nextTo: url, + data: data, + permissions: 0o600, + authorization: rootAuthorization + ) + guard let destination = AutomationFileAuthorization( + fileURL: url, + directory: rootAuthorization, + expectation: .absent + ) else { throw AutomationManagerConfigurationError.recoverableSourceUnavailable } + try install(destination: destination, staged: staged) + } + let storedData = try fileSystem.read(url) + let metadata = try fileSystem.metadata(for: url) + guard storedData == data, + metadata.canonicalURL.standardizedFileURL.path == url.path, + metadata.ownerUID == currentUID, + !metadata.isSymbolicLink, + metadata.permissions == 0o600 else { + throw AutomationManagerConfigurationError.recoverableSourceUnavailable + } + return AutomationRecoverableSource( + transactionURL: url, + data: data, + checksum: checksum, + metadata: metadata + ) + } + + private func install( + destination: AutomationFileAuthorization, + staged: AutomationStagedFile + ) throws { + let outcome: AutomationFileMutationOutcome + do { + outcome = try fileSystem.replaceItem(at: destination, with: staged) + } catch let partial as AutomationFilePartialMutation { + throw partial + } catch { + try failAfterCleaningStaged(staged, originalError: error) + } + guard case .unchanged = outcome else { + return + } + try failAfterCleaningStaged( + staged, + originalError: AutomationManagerConfigurationError.recoverableSourceUnavailable + ) + } + + private func failAfterCleaningStaged( + _ staged: AutomationStagedFile, + originalError: Error + ) throws -> Never { + do { + guard case .committed = try fileSystem.removeItem(staged.authorization) else { + throw AutomationManagerConfigurationError.recoverableSourceUnavailable + } + } catch { + let directoryMatches = (try? fileSystem.metadata( + for: staged.authorization.directory.directoryURL + ).resourceIdentifier) == staged.authorization.directory.resourceIdentifier + let leafMatches: Bool + if case .existing(let identity) = staged.authorization.expectation { + leafMatches = (try? fileSystem.metadata(for: staged.url).resourceIdentifier) == identity + } else { + leafMatches = false + } + guard directoryMatches && leafMatches else { throw originalError } + throw AutomationFilePartialMutation( + kind: .remove, + commitState: .unknown, + observedFiles: [staged.authorization], + recoveryHandle: staged.authorization, + resultURL: staged.url + ) + } + throw originalError + } + + nonisolated static func transactionURL( + for record: AutomationRecord, + root: URL + ) -> URL { + switch record.sourceKind { + case .crontab: + root.appending(path: "current-crontab", directoryHint: .notDirectory) + case .legacyLoginItem: + root.appending( + path: "legacy-\(rawChecksum(Data(record.id.rawValue.utf8))).json", + directoryHint: .notDirectory + ) + default: + root.appending(path: "unsupported", directoryHint: .notDirectory) + } + } + + nonisolated private static func rawChecksum(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } +} + +struct AutomationAuthorityContextBuilder: Sendable { + private let fileSystem: any AutomationFileSystem + private let currentUID: uid_t + private let launchAgentsRoot: URL + private let transactionRoot: URL + + init( + fileSystem: any AutomationFileSystem, + currentUID: uid_t, + launchAgentsRoot: URL, + transactionRoot: URL + ) { + self.fileSystem = fileSystem + self.currentUID = currentUID + self.launchAgentsRoot = launchAgentsRoot.standardizedFileURL + self.transactionRoot = transactionRoot.standardizedFileURL + } + + func context(for record: AutomationRecord) throws -> AutomationCapabilityContext { + if record.ownership != .user + || record.kind == .launchDaemon + || record.kind == .backgroundItem + || record.sourceKind == .launchDaemon + || record.sourceKind == .serviceManagement + { + return AutomationCapabilityContext( + currentUID: currentUID, + canonicalPathIsApproved: false, + sourceOwnerUID: record.ownerUID, + isSymlink: false, + isManaged: record.ownership == .managed, + implementedCapabilities: [.exportRecord] + ) + } + let sourceURL: URL + switch record.sourceKind { + case .launchAgent: + guard let directURL = record.sourceURL else { + throw AutomationManagerConfigurationError.destinationAuthorizationUnavailable + } + sourceURL = directURL.standardizedFileURL + case .crontab, .legacyLoginItem: + sourceURL = AutomationRecoverableSourceProvider.transactionURL( + for: record, + root: transactionRoot + ) + case .launchDaemon, .serviceManagement: + throw AutomationManagerConfigurationError.destinationAuthorizationUnavailable + } + return try context(for: record, destination: sourceURL) + } + + func context( + for record: AutomationRecord, + destination: URL + ) throws -> AutomationCapabilityContext { + let destination = destination.standardizedFileURL + let approvedRoot: URL + switch record.sourceKind { + case .launchAgent: + approvedRoot = launchAgentsRoot + case .crontab, .legacyLoginItem: + approvedRoot = transactionRoot + case .launchDaemon, .serviceManagement: + throw AutomationManagerConfigurationError.destinationAuthorizationUnavailable + } + let destinationExists = fileSystem.itemExists(at: destination) + let metadataURL = destinationExists + ? destination + : destination.deletingLastPathComponent().standardizedFileURL + let metadata = try fileSystem.metadata(for: metadataURL) + let canonicalPathIsApproved = AutomationPathAuthorization.isApprovedDestination( + destination, + approvedRoot: approvedRoot, + destinationExists: destinationExists, + verifiedMetadataURL: metadata.canonicalURL, + metadataIsSymbolicLink: metadata.isSymbolicLink + ) + return AutomationCapabilityContext( + currentUID: currentUID, + canonicalPathIsApproved: canonicalPathIsApproved, + sourceOwnerUID: metadata.ownerUID, + isSymlink: destinationExists ? metadata.isSymbolicLink : false, + isManaged: record.ownership == .managed, + implementedCapabilities: Self.implementedCapabilities(for: record.sourceKind) + ) + } + + private static func implementedCapabilities( + for sourceKind: AutomationSourceKind + ) -> Set { + switch sourceKind { + case .launchAgent: + [.startNow, .stopCurrentRun, .enable, .disable, .disableAndStop, + .edit, .duplicate, .importRecord, .exportRecord, .remove, .restore] + case .crontab: + [.startNow, .stopCurrentRun, .enable, .disable, .disableAndStop, + .edit, .duplicate, .importRecord, .exportRecord, .remove, .restore] + case .legacyLoginItem: + [.startNow, .enable, .disable, .exportRecord, .remove] + case .launchDaemon, .serviceManagement: + [.exportRecord] + } + } +} + +struct AutomationAuthorityCapabilityDecisionProvider: + AutomationCapabilityDecisionProviding, Sendable +{ + let authority: AutomationAuthorityContextBuilder + + func decisions( + for records: [AutomationRecord] + ) async -> [AutomationRecord.ID: AutomationCapabilityDecision] { + await Task.detached(priority: .utility) { + Dictionary(uniqueKeysWithValues: records.map { record in + do { + let context = try authority.context(for: record) + return (record.id, AutomationCapabilityPolicy.decision(for: record, context: context)) + } catch { + return ( + record.id, + AutomationCapabilityDecision( + capabilities: record.capabilities.intersection([.exportRecord]), + reason: "DevScope could not verify source ownership and path safety for management." + ) + ) + } + }) + }.value + } +} + +struct AutomationManagementDestinationProvider: + AutomationManagementDestinationProviding, Sendable +{ + let transactionRoot: URL + + func duplicateDestination(for record: AutomationRecord, label: String) -> URL? { + switch record.sourceKind { + case .launchAgent: + guard let sourceURL = record.sourceURL, + let filename = safeFilename(label, requiredExtension: "plist") else { return nil } + return sourceURL.deletingLastPathComponent().appendingPathComponent(filename) + case .crontab: + return AutomationRecoverableSourceProvider.transactionURL(for: record, root: transactionRoot) + case .legacyLoginItem, .launchDaemon, .serviceManagement: + return nil + } + } + + func importDestination(for record: AutomationRecord, suggestedFilename: String) -> URL? { + switch record.sourceKind { + case .launchAgent: + // Import replaces the selected, checksum-bound source. Creating a sibling + // requires separately inspected destination authority that the inventory + // does not publish. + return record.sourceURL?.standardizedFileURL + case .crontab: + return AutomationRecoverableSourceProvider.transactionURL(for: record, root: transactionRoot) + case .legacyLoginItem, .launchDaemon, .serviceManagement: + return nil + } + } + + private func safeFilename(_ value: String, requiredExtension: String) -> String? { + let component = URL(fileURLWithPath: value).lastPathComponent + guard component == value, + !component.isEmpty, + component != ".", + component != "..", + component.unicodeScalars.allSatisfy({ + CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "._-")).contains($0) + }) else { return nil } + return component.hasSuffix(".\(requiredExtension)") + ? component : "\(component).\(requiredExtension)" + } +} diff --git a/Sources/DevScope/Infrastructure/LaunchctlAutomationRuntimeStateProvider.swift b/Sources/DevScope/Infrastructure/LaunchctlAutomationRuntimeStateProvider.swift new file mode 100644 index 0000000..5c638c8 --- /dev/null +++ b/Sources/DevScope/Infrastructure/LaunchctlAutomationRuntimeStateProvider.swift @@ -0,0 +1,127 @@ +import DevScopeCore +import Foundation + +struct LaunchctlAutomationRuntimeStateProvider: LaunchdRuntimeStateProviding { + static let maximumConcurrentPrintQueries = 4 + + private let runner: any AutomationCommandRunning + + init(runner: any AutomationCommandRunning) { + self.runner = runner + } + + func states( + for labels: [String], + guiUID: uid_t + ) async -> [String: LaunchdRuntimeState] { + let domain = "gui/\(guiUID)" + let enabledStates: [String: AutomationEnabledState]? + do { + let result = try await runner.run( + AutomationCommand( + executable: "/bin/launchctl", + arguments: ["print-disabled", domain], + environment: ["LC_ALL": "C"] + )) + enabledStates = result.status == 0 ? Self.parseEnabledStates(result.standardOutput) : nil + } catch { + enabledStates = nil + } + + let uniqueLabels = Array(Set(labels)).sorted { + $0.utf8.lexicographicallyPrecedes($1.utf8) + } + let stateForLabel: @Sendable (String) async -> (String, LaunchdRuntimeState) = { label in + let loadState: AutomationLoadState + do { + let result = try await runner.run( + AutomationCommand( + executable: "/bin/launchctl", + arguments: ["print", "\(domain)/\(label)"], + environment: ["LC_ALL": "C"] + )) + switch LaunchctlServiceTargetClassifier.classify( + result, + label: label, + guiUID: guiUID + ) { + case .loaded: + loadState = .loaded + case .absent: + loadState = .unloaded + case .unknown: + loadState = .unknown + } + } catch { + loadState = .unknown + } + let enabledState = + enabledStates?[label] + ?? (enabledStates == nil ? .unknown : .enabled) + return ( + label, + LaunchdRuntimeState( + enabledState: enabledState, + loadState: loadState + ) + ) + } + + return await withTaskGroup( + of: (String, LaunchdRuntimeState).self, + returning: [String: LaunchdRuntimeState].self + ) { group in + let initialCount = min(Self.maximumConcurrentPrintQueries, uniqueLabels.count) + for label in uniqueLabels.prefix(initialCount) { + group.addTask { + await stateForLabel(label) + } + } + + var states: [String: LaunchdRuntimeState] = [:] + var nextIndex = initialCount + while let (label, state) = await group.next() { + states[label] = state + if nextIndex < uniqueLabels.count { + let nextLabel = uniqueLabels[nextIndex] + nextIndex += 1 + group.addTask { + await stateForLabel(nextLabel) + } + } + } + return states + } + } + + static func parseEnabledStates( + _ data: Data + ) -> [String: AutomationEnabledState]? { + guard let output = String(data: data, encoding: .utf8) else { return nil } + var states: [String: AutomationEnabledState] = [:] + for rawLine in output.split(whereSeparator: \.isNewline) { + let line = rawLine.trimmingCharacters(in: .whitespaces) + guard line.first == "\"", + let closingQuote = line.dropFirst().firstIndex(of: "\"") + else { continue } + let label = String(line[line.index(after: line.startIndex)..") else { continue } + let value = suffix[arrow.upperBound...] + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + let state: AutomationEnabledState + switch value { + case "disabled", "true": + state = .disabled + case "enabled", "false": + state = .enabled + default: + continue + } + if let existing = states[label], existing != state { return nil } + states[label] = state + } + return states + } +} diff --git a/Sources/DevScope/Infrastructure/LocalAutomationFileSystem.swift b/Sources/DevScope/Infrastructure/LocalAutomationFileSystem.swift new file mode 100644 index 0000000..1272e85 --- /dev/null +++ b/Sources/DevScope/Infrastructure/LocalAutomationFileSystem.swift @@ -0,0 +1,847 @@ +import Darwin +import CryptoKit +import DevScopeCore +import Foundation + +enum LocalAutomationFileSystemFault: Hashable, Sendable { + case replaceDisplacedUnlink + case replacePostRenameVerification + case replaceRestoreCollision + case replaceSwapBackFailure + case replaceRestoredStagedUnlink + case trashFinalMove + case trashStagedIdentityMismatch + case trashRestoreCollision + case removeUnlink + case removeStagedIdentityMismatch + case removeRestoreCollision +} + +struct LocalAutomationFileSystemHooks: Sendable { + var beforeReplaceCommit: (@Sendable (URL) -> Void)? + var faults: Set + var trashDirectory: URL? + + init( + beforeReplaceCommit: (@Sendable (URL) -> Void)? = nil, + faults: Set = [], + trashDirectory: URL? = nil + ) { + self.beforeReplaceCommit = beforeReplaceCommit + self.faults = faults + self.trashDirectory = trashDirectory + } +} + +final class LocalAutomationFileSystem: AutomationFileSystem, @unchecked Sendable { + private let fileManager = FileManager.default + private let hooks: LocalAutomationFileSystemHooks + private let maxReadableBytes: Int + + init( + hooks: LocalAutomationFileSystemHooks = .init(), + maxReadableBytes: Int = 8 * 1_024 * 1_024 + ) { + self.hooks = hooks + self.maxReadableBytes = max(0, maxReadableBytes) + } + + func itemExists(at url: URL) -> Bool { + do { + let (parent, name) = try Self.openParent(of: url) + defer { _ = close(parent) } + var status = stat() + return fstatat(parent, name, &status, AT_SYMLINK_NOFOLLOW) == 0 || errno != ENOENT + } catch { + return true + } + } + + func plistURLs(in directory: URL) throws -> [URL] { + let descriptor = try Self.openDirectoryNoFollow(directory) + _ = close(descriptor) + return try fileManager.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ).filter { $0.pathExtension.caseInsensitiveCompare("plist") == .orderedSame } + .sorted { $0.path.utf8.lexicographicallyPrecedes($1.path.utf8) } + } + + func read(_ url: URL) throws -> Data { + let (parent, name) = try Self.openParent(of: url) + defer { _ = close(parent) } + let descriptor = openat(parent, name, O_RDONLY | O_NOFOLLOW | O_CLOEXEC) + guard descriptor >= 0 else { throw Self.posixError() } + defer { _ = close(descriptor) } + var status = stat() + guard fstat(descriptor, &status) == 0, (status.st_mode & S_IFMT) == S_IFREG else { + throw POSIXError(.EPERM) + } + guard status.st_size >= 0, UInt64(status.st_size) <= UInt64(maxReadableBytes) else { + throw POSIXError(.EFBIG) + } + return try Self.readAll(descriptor, maxBytes: maxReadableBytes) + } + + func metadata(for url: URL) throws -> AutomationFileMetadata { + let path = url.standardizedFileURL.path + if path == "/" { + let descriptor = try Self.openDirectoryNoFollow(url) + defer { _ = close(descriptor) } + var status = stat() + guard fstat(descriptor, &status) == 0 else { throw Self.posixError() } + return Self.metadata(path: path, status: status, isSymbolicLink: false) + } + + let (parent, name) = try Self.openParent(of: url) + defer { _ = close(parent) } + var parentStatus = stat() + guard fstat(parent, &parentStatus) == 0 else { throw Self.posixError() } + var status = stat() + guard fstatat(parent, name, &status, AT_SYMLINK_NOFOLLOW) == 0 else { + throw Self.posixError() + } + let isSymbolicLink = (status.st_mode & S_IFMT) == S_IFLNK + return Self.metadata(path: path, status: status, isSymbolicLink: isSymbolicLink) + } + + func createDirectory(_ url: URL, permissions: Int) throws { + let path = url.standardizedFileURL.path + guard path.hasPrefix("/"), 0...0o777 ~= permissions else { throw POSIXError(.EINVAL) } + let components = path.split(separator: "/").map(String.init) + var parent = open("/", O_RDONLY | O_DIRECTORY | O_CLOEXEC) + guard parent >= 0 else { throw Self.posixError() } + defer { if parent >= 0 { _ = close(parent) } } + + var traversed = "" + for (index, component) in components.enumerated() { + traversed += "/\(component)" + let isFinal = index == components.count - 1 + var child = openat(parent, component, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC) + var created = false + if child < 0, errno == ENOENT { + guard mkdirat(parent, component, mode_t(isFinal ? permissions : 0o700)) == 0 else { + throw Self.posixError() + } + created = true + child = openat(parent, component, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC) + } + guard child >= 0 else { throw Self.posixError() } + var status = stat() + guard fstat(child, &status) == 0 else { + _ = close(child) + throw Self.posixError() + } + if isFinal { + let mode = Int(status.st_mode & 0o777) + if created { + guard fchmod(child, mode_t(permissions)) == 0 else { + _ = close(child) + throw Self.posixError() + } + guard fstat(child, &status) == 0 else { + _ = close(child) + throw Self.posixError() + } + } else if mode != permissions { + _ = close(child) + throw POSIXError(.EPERM) + } + } + _ = close(parent) + parent = child + } + } + + + func writeStagedFile( + nextTo url: URL, + data: Data, + permissions: Int, + authorization: AutomationDirectoryAuthorization + ) throws -> AutomationStagedFile { + guard 0...0o777 ~= permissions else { throw POSIXError(.EINVAL) } + let parentURL = url.deletingLastPathComponent().standardizedFileURL + return try withVerifiedParent(of: url, authorization: authorization) { parent, _ in + for _ in 0..<16 { + let name = ".devscope-\(UUID().uuidString.lowercased()).tmp" + let descriptor = openat( + parent, name, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, + mode_t(permissions) + ) + if descriptor < 0 { + if errno == EEXIST { continue } + throw Self.posixError() + } + do { + try Self.writeAll(data, to: descriptor) + var status = stat() + guard fsync(descriptor) == 0, fstat(descriptor, &status) == 0 else { + throw Self.posixError() + } + let temporaryURL = parentURL.appending(path: name, directoryHint: .notDirectory) + let resourceIdentifier = Self.resourceIdentifier(status) + guard (status.st_mode & S_IFMT) == S_IFREG, + status.st_nlink == 1, + let fileAuthorization = AutomationFileAuthorization( + fileURL: temporaryURL, + directory: authorization, + expectation: .existing(resourceIdentifier: resourceIdentifier) + ), close(descriptor) == 0 else { throw Self.posixError() } + return AutomationStagedFile( + url: temporaryURL, + authorization: fileAuthorization, + binding: AutomationStagedFileBinding( + data: data, + resourceIdentifier: resourceIdentifier, + ownerUID: status.st_uid, + permissions: Int(status.st_mode & 0o777), + linkCount: UInt64(status.st_nlink) + ) + ) + } catch { + _ = close(descriptor) + _ = unlinkat(parent, name, 0) + throw error + } + } + throw POSIXError(.EEXIST) + } + } + + + func replaceItem( + at destination: AutomationFileAuthorization, + with staged: AutomationStagedFile + ) throws -> AutomationFileMutationOutcome { + guard destination.directory == staged.authorization.directory, + destination.fileURL.deletingLastPathComponent().standardizedFileURL + == destination.directory.directoryURL, + staged.url == staged.authorization.fileURL, + case .existing(let stagedIdentity) = staged.authorization.expectation + else { throw POSIXError(.EPERM) } + + return try withVerifiedParent( + of: destination.fileURL, + authorization: destination.directory + ) { parent, destinationName in + let stagedName = staged.url.lastPathComponent + guard staged.binding.resourceIdentifier == stagedIdentity, + try Self.entryMatchesBinding( + parent: parent, + name: stagedName, + binding: staged.binding + ) + else { + throw POSIXError(.EPERM) + } + hooks.beforeReplaceCommit?(staged.url) + switch destination.expectation { + case .absent: + guard renameatx_np(parent, stagedName, parent, destinationName, UInt32(RENAME_EXCL)) == 0 + else { throw Self.posixError() } + let installedMatchesBinding = hooks.faults.contains(.replacePostRenameVerification) + ? false + : (try? Self.entryMatchesBinding( + parent: parent, + name: destinationName, + binding: staged.binding + )) == true + guard installedMatchesBinding else { + if hooks.faults.contains(.replaceRestoreCollision) { + let collision = openat( + parent, + stagedName, + O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, + mode_t(0o600) + ) + if collision >= 0 { _ = close(collision) } + } + let restored = renameatx_np( + parent, destinationName, parent, stagedName, UInt32(RENAME_EXCL) + ) == 0 + if restored, + (try? Self.entryIdentity(parent: parent, name: destinationName)) == nil { + try cleanupRestoredStaged( + parent: parent, + name: stagedName, + staged: staged + ) + return .unchanged + } + var observed: [AutomationFileAuthorization] = [] + let installedIdentity = try? Self.entryIdentity(parent: parent, name: destinationName) + let collisionIdentity = try? Self.entryIdentity(parent: parent, name: stagedName) + let installed = installedIdentity.flatMap { + AutomationFileAuthorization( + fileURL: destination.fileURL, + directory: destination.directory, + expectation: .existing(resourceIdentifier: $0) + ) + } + let collision = collisionIdentity.flatMap { + AutomationFileAuthorization( + fileURL: staged.url, + directory: destination.directory, + expectation: .existing(resourceIdentifier: $0) + ) + } + if let installed { observed.append(installed) } + if let collision { observed.append(collision) } + let installedIsRecovery = (try? Self.entryMatchesBinding( + parent: parent, + name: destinationName, + binding: staged.binding + )) == true + throw AutomationFilePartialMutation( + kind: .replace, + commitState: installedIsRecovery ? .committed : .unknown, + observedFiles: observed, + recoveryHandle: installedIsRecovery ? installed : nil, + resultURL: destination.fileURL + ) + } + case .existing(let destinationIdentity): + guard try Self.entryIdentity(parent: parent, name: destinationName) == destinationIdentity, + renameatx_np(parent, stagedName, parent, destinationName, UInt32(RENAME_SWAP)) == 0 + else { throw POSIXError(.EPERM) } + let installedMatches = (try? Self.entryMatchesBinding( + parent: parent, + name: destinationName, + binding: staged.binding + )) == true + let displacedMatches = (try? Self.entryIdentity( + parent: parent, name: stagedName + )) == destinationIdentity + guard installedMatches, displacedMatches else { + let swappedBack = !hooks.faults.contains(.replaceSwapBackFailure) + && renameatx_np( + parent, stagedName, parent, destinationName, UInt32(RENAME_SWAP) + ) == 0 + if swappedBack, + (try? Self.entryIdentity(parent: parent, name: destinationName)) + == destinationIdentity, + (try? Self.entryIdentity(parent: parent, name: stagedName)) == stagedIdentity { + try cleanupRestoredStaged( + parent: parent, + name: stagedName, + staged: staged + ) + return .unchanged + } + let destinationObserved = (try? Self.entryIdentity( + parent: parent, name: destinationName + )).flatMap { + AutomationFileAuthorization( + fileURL: destination.fileURL, + directory: destination.directory, + expectation: .existing(resourceIdentifier: $0) + ) + } + let stagedObserved = (try? Self.entryIdentity( + parent: parent, name: stagedName + )).flatMap { + AutomationFileAuthorization( + fileURL: staged.url, + directory: staged.authorization.directory, + expectation: .existing(resourceIdentifier: $0) + ) + } + let observed = [destinationObserved, stagedObserved].compactMap { $0 } + let recovery = destinationObserved?.expectation + == .existing(resourceIdentifier: destinationIdentity) + ? destinationObserved + : (stagedObserved?.expectation == .existing(resourceIdentifier: destinationIdentity) + ? stagedObserved : nil) + throw AutomationFilePartialMutation( + kind: .replace, + commitState: .unknown, + observedFiles: observed, + recoveryHandle: recovery, + resultURL: destination.fileURL + ) + } + if hooks.faults.contains(.replaceDisplacedUnlink) + || unlinkat(parent, stagedName, 0) != 0 + { + let installed = AutomationFileAuthorization( + fileURL: destination.fileURL, + directory: destination.directory, + expectation: .existing(resourceIdentifier: staged.binding.resourceIdentifier) + ) + let recovery = AutomationFileAuthorization( + fileURL: staged.url, + directory: destination.directory, + expectation: .existing(resourceIdentifier: destinationIdentity) + ) + throw AutomationFilePartialMutation( + kind: .replace, + commitState: .committed, + observedFiles: [installed, recovery].compactMap { $0 }, + recoveryHandle: recovery, + resultURL: destination.fileURL + ) + } + } + _ = fsync(parent) + let installedAuthorization = AutomationFileAuthorization( + fileURL: destination.fileURL, + directory: destination.directory, + expectation: .existing(resourceIdentifier: staged.binding.resourceIdentifier) + ) + return .committed(AutomationFileMutationReceipt( + primaryFile: installedAuthorization, + resultURL: destination.fileURL, + bindingVerified: true + )) + } + } + + private func cleanupRestoredStaged( + parent: Int32, + name: String, + staged: AutomationStagedFile + ) throws { + let exactStagedAuthorization = (try? Self.entryIdentity(parent: parent, name: name)) + == staged.binding.resourceIdentifier ? staged.authorization : nil + guard let exactStagedAuthorization else { + let observed = (try? Self.entryIdentity(parent: parent, name: name)).flatMap { + AutomationFileAuthorization( + fileURL: staged.url, + directory: staged.authorization.directory, + expectation: .existing(resourceIdentifier: $0) + ) + } + throw AutomationFilePartialMutation( + kind: .remove, + commitState: .unknown, + observedFiles: observed.map { [$0] } ?? [], + recoveryHandle: nil, + resultURL: staged.url + ) + } + guard !hooks.faults.contains(.replaceRestoredStagedUnlink), + unlinkat(parent, name, 0) == 0, + (try? Self.entryIdentity(parent: parent, name: name)) == nil + else { + throw AutomationFilePartialMutation( + kind: .remove, + commitState: .unknown, + observedFiles: [exactStagedAuthorization], + recoveryHandle: exactStagedAuthorization, + resultURL: staged.url + ) + } + _ = fsync(parent) + } + + func fileMatchesBinding( + _ file: AutomationFileAuthorization, + binding: AutomationStagedFileBinding + ) throws -> Bool { + guard case .existing(let expectedIdentity) = file.expectation, + expectedIdentity == binding.resourceIdentifier + else { return false } + return try withVerifiedParent(of: file.fileURL, authorization: file.directory) { + parent, name in + try Self.entryMatchesBinding(parent: parent, name: name, binding: binding) + } + } + + + func moveToTrash( + _ source: AutomationFileAuthorization + ) throws -> AutomationFileMutationOutcome { + guard case .existing(let expectedIdentity) = source.expectation else { + throw POSIXError(.EPERM) + } + let trash = hooks.trashDirectory ?? FileManager.default.homeDirectoryForCurrentUser + .appending(path: ".Trash", directoryHint: .isDirectory) + try createDirectory(trash, permissions: 0o700) + let trashDescriptor = try Self.openDirectoryNoFollow(trash) + defer { _ = close(trashDescriptor) } + return try withVerifiedParent(of: source.fileURL, authorization: source.directory) { + parent, name in + let stagedName = ".devscope-trash-\(UUID().uuidString.lowercased())" + guard renameatx_np(parent, name, parent, stagedName, UInt32(RENAME_EXCL)) == 0 else { + throw Self.posixError() + } + let stagedIdentityMatches = !hooks.faults.contains(.trashStagedIdentityMismatch) + && (try? Self.entryIdentity(parent: parent, name: stagedName)) == expectedIdentity + guard stagedIdentityMatches else { + if hooks.faults.contains(.trashRestoreCollision) { + let collision = openat( + parent, name, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, + mode_t(0o600) + ) + if collision >= 0 { _ = close(collision) } + } + let restored = renameatx_np( + parent, stagedName, parent, name, UInt32(RENAME_EXCL) + ) == 0 + if restored, + (try? Self.entryIdentity(parent: parent, name: name)) == expectedIdentity, + (try? Self.entryIdentity(parent: parent, name: stagedName)) == nil { + return .unchanged + } + let evidence = Self.partialMutationEvidence( + parent: parent, + source: source, + sourceName: name, + stagedName: stagedName, + expectedIdentity: expectedIdentity + ) + throw AutomationFilePartialMutation( + kind: .trash, + commitState: .unknown, + observedFiles: evidence.observed, + recoveryHandle: evidence.recovery, + resultURL: nil + ) + } + let destinationName = "\(name)-\(UUID().uuidString.lowercased())" + let movedToTrash = !hooks.faults.contains(.trashFinalMove) + && renameat(parent, stagedName, trashDescriptor, destinationName) == 0 + guard movedToTrash else { + let restored = renameatx_np( + parent, stagedName, parent, name, UInt32(RENAME_EXCL) + ) == 0 + if restored, + (try? Self.entryIdentity(parent: parent, name: name)) == expectedIdentity, + (try? Self.entryIdentity(parent: parent, name: stagedName)) == nil { + return .unchanged + } + var observed: [AutomationFileAuthorization] = [] + let sourceIdentity = try? Self.entryIdentity(parent: parent, name: name) + let stagedIdentity = try? Self.entryIdentity(parent: parent, name: stagedName) + let sourceObserved = sourceIdentity.flatMap { + AutomationFileAuthorization( + fileURL: source.fileURL, + directory: source.directory, + expectation: .existing(resourceIdentifier: $0) + ) + } + let stagedURL = source.directory.directoryURL.appending( + path: stagedName, directoryHint: .notDirectory + ) + let stagedObserved = stagedIdentity.flatMap { + AutomationFileAuthorization( + fileURL: stagedURL, + directory: source.directory, + expectation: .existing(resourceIdentifier: $0) + ) + } + if let sourceObserved { observed.append(sourceObserved) } + if let stagedObserved { observed.append(stagedObserved) } + let recovery = sourceIdentity == expectedIdentity ? sourceObserved + : (stagedIdentity == expectedIdentity ? stagedObserved : nil) + throw AutomationFilePartialMutation( + kind: .trash, + commitState: .unknown, + observedFiles: observed, + recoveryHandle: recovery, + resultURL: nil + ) + } + _ = fsync(parent) + _ = fsync(trashDescriptor) + let resultURL = trash.appending(path: destinationName, directoryHint: .notDirectory) + var trashStatus = stat() + let trashIdentity = try? Self.entryIdentity( + parent: trashDescriptor, + name: destinationName + ) + let trashAuthorization = fstat(trashDescriptor, &trashStatus) == 0 + ? AutomationDirectoryAuthorization( + directoryURL: trash, + resourceIdentifier: Self.resourceIdentifier(trashStatus) + ) : nil + let observedFile = trashAuthorization.flatMap { directory in + trashIdentity.flatMap { identity in + AutomationFileAuthorization( + fileURL: resultURL, + directory: directory, + expectation: .existing(resourceIdentifier: identity) + ) + } + } + guard trashIdentity == expectedIdentity, + let trashedFile = observedFile + else { + throw AutomationFilePartialMutation( + kind: .trash, + commitState: trashIdentity == expectedIdentity ? .committed : .unknown, + observedFiles: observedFile.map { [$0] } ?? [], + recoveryHandle: trashIdentity == expectedIdentity ? observedFile : nil, + resultURL: resultURL + ) + } + return .committed(AutomationFileMutationReceipt( + primaryFile: trashedFile, + resultURL: resultURL, + bindingVerified: true + )) + } + } + + + func removeItem( + _ source: AutomationFileAuthorization + ) throws -> AutomationFileMutationOutcome { + guard case .existing(let expectedIdentity) = source.expectation else { + throw POSIXError(.EPERM) + } + return try withVerifiedParent(of: source.fileURL, authorization: source.directory) { + parent, name in + let stagedName = ".devscope-remove-\(UUID().uuidString.lowercased())" + guard renameatx_np(parent, name, parent, stagedName, UInt32(RENAME_EXCL)) == 0 else { + throw Self.posixError() + } + let stagedIdentityMatches = !hooks.faults.contains(.removeStagedIdentityMismatch) + && (try? Self.entryIdentity(parent: parent, name: stagedName)) == expectedIdentity + guard stagedIdentityMatches else { + if hooks.faults.contains(.removeRestoreCollision) { + let collision = openat( + parent, name, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, + mode_t(0o600) + ) + if collision >= 0 { _ = close(collision) } + } + let restored = renameatx_np( + parent, stagedName, parent, name, UInt32(RENAME_EXCL) + ) == 0 + if restored, + (try? Self.entryIdentity(parent: parent, name: name)) == expectedIdentity, + (try? Self.entryIdentity(parent: parent, name: stagedName)) == nil { + return .unchanged + } + let evidence = Self.partialMutationEvidence( + parent: parent, + source: source, + sourceName: name, + stagedName: stagedName, + expectedIdentity: expectedIdentity + ) + throw AutomationFilePartialMutation( + kind: .remove, + commitState: .unknown, + observedFiles: evidence.observed, + recoveryHandle: evidence.recovery, + resultURL: nil + ) + } + if hooks.faults.contains(.removeUnlink) || unlinkat(parent, stagedName, 0) != 0 { + let restored = renameatx_np( + parent, stagedName, parent, name, UInt32(RENAME_EXCL) + ) == 0 + if restored, + (try? Self.entryIdentity(parent: parent, name: name)) == expectedIdentity, + (try? Self.entryIdentity(parent: parent, name: stagedName)) == nil { + return .unchanged + } + var observed: [AutomationFileAuthorization] = [] + let sourceIdentity = try? Self.entryIdentity(parent: parent, name: name) + let stagedIdentity = try? Self.entryIdentity(parent: parent, name: stagedName) + let sourceObserved = sourceIdentity.flatMap { + AutomationFileAuthorization( + fileURL: source.fileURL, + directory: source.directory, + expectation: .existing(resourceIdentifier: $0) + ) + } + let stagedURL = source.directory.directoryURL.appending( + path: stagedName, directoryHint: .notDirectory + ) + let stagedObserved = stagedIdentity.flatMap { + AutomationFileAuthorization( + fileURL: stagedURL, + directory: source.directory, + expectation: .existing(resourceIdentifier: $0) + ) + } + if let sourceObserved { observed.append(sourceObserved) } + if let stagedObserved { observed.append(stagedObserved) } + let recovery = sourceIdentity == expectedIdentity ? sourceObserved + : (stagedIdentity == expectedIdentity ? stagedObserved : nil) + throw AutomationFilePartialMutation( + kind: .remove, + commitState: .unknown, + observedFiles: observed, + recoveryHandle: recovery, + resultURL: nil + ) + } + _ = fsync(parent) + return .committed(AutomationFileMutationReceipt( + primaryFile: nil, + resultURL: nil, + bindingVerified: true + )) + } + } + + private func withVerifiedParent( + of url: URL, + authorization: AutomationDirectoryAuthorization, + _ body: (Int32, String) throws -> T + ) throws -> T { + let parentURL = url.deletingLastPathComponent().standardizedFileURL + guard parentURL == authorization.directoryURL, + !authorization.resourceIdentifier.isEmpty else { throw POSIXError(.EPERM) } + let parent = try Self.openDirectoryNoFollow(parentURL) + defer { _ = close(parent) } + var status = stat() + guard fstat(parent, &status) == 0, + Self.resourceIdentifier(status) == authorization.resourceIdentifier else { + throw POSIXError(.EPERM) + } + let name = url.lastPathComponent + guard !name.isEmpty, !name.contains("/") else { throw POSIXError(.EINVAL) } + return try body(parent, name) + } + + private static func partialMutationEvidence( + parent: Int32, + source: AutomationFileAuthorization, + sourceName: String, + stagedName: String, + expectedIdentity: String + ) -> (observed: [AutomationFileAuthorization], recovery: AutomationFileAuthorization?) { + let sourceIdentity = try? entryIdentity(parent: parent, name: sourceName) + let stagedIdentity = try? entryIdentity(parent: parent, name: stagedName) + let sourceObserved = sourceIdentity.flatMap { + AutomationFileAuthorization( + fileURL: source.fileURL, + directory: source.directory, + expectation: .existing(resourceIdentifier: $0) + ) + } + let stagedURL = source.directory.directoryURL.appending( + path: stagedName, directoryHint: .notDirectory + ) + let stagedObserved = stagedIdentity.flatMap { + AutomationFileAuthorization( + fileURL: stagedURL, + directory: source.directory, + expectation: .existing(resourceIdentifier: $0) + ) + } + let observed = [sourceObserved, stagedObserved].compactMap { $0 } + let recovery = sourceIdentity == expectedIdentity ? sourceObserved + : (stagedIdentity == expectedIdentity ? stagedObserved : nil) + return (observed, recovery) + } + + private static func openParent(of url: URL) throws -> (Int32, String) { + let standardized = url.standardizedFileURL + let name = standardized.lastPathComponent + guard !name.isEmpty, !name.contains("/") else { throw POSIXError(.EINVAL) } + return (try openDirectoryNoFollow(standardized.deletingLastPathComponent()), name) + } + + private static func openDirectoryNoFollow(_ url: URL) throws -> Int32 { + let path = url.standardizedFileURL.path + guard path.hasPrefix("/") else { throw POSIXError(.EINVAL) } + var descriptor = open("/", O_RDONLY | O_DIRECTORY | O_CLOEXEC) + guard descriptor >= 0 else { throw posixError() } + if path == "/" { return descriptor } + for component in path.split(separator: "/").map(String.init) { + let next = openat( + descriptor, component, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC + ) + guard next >= 0 else { + let error = posixError() + _ = close(descriptor) + throw error + } + _ = close(descriptor) + descriptor = next + } + return descriptor + } + + private static func metadata( + path: String, + status: stat, + isSymbolicLink: Bool + ) -> AutomationFileMetadata { + let canonicalURL = isSymbolicLink + ? URL(fileURLWithPath: path).resolvingSymlinksInPath().standardizedFileURL + : URL(fileURLWithPath: path).standardizedFileURL + return AutomationFileMetadata( + canonicalURL: canonicalURL, + ownerUID: status.st_uid, + isSymbolicLink: isSymbolicLink, + modificationDate: Date( + timeIntervalSince1970: TimeInterval(status.st_mtimespec.tv_sec) + + TimeInterval(status.st_mtimespec.tv_nsec) / 1_000_000_000 + ), + resourceIdentifier: resourceIdentifier(status), + permissions: Int(status.st_mode & 0o777) + ) + } + + private static func resourceIdentifier(_ status: stat) -> String { + "\(status.st_dev):\(status.st_ino)" + } + + private static func entryIdentity(parent: Int32, name: String) throws -> String { + var status = stat() + guard fstatat(parent, name, &status, AT_SYMLINK_NOFOLLOW) == 0, + (status.st_mode & S_IFMT) == S_IFREG else { throw posixError() } + return resourceIdentifier(status) + } + + private static func entryMatchesBinding( + parent: Int32, + name: String, + binding: AutomationStagedFileBinding + ) throws -> Bool { + let descriptor = openat(parent, name, O_RDONLY | O_NOFOLLOW | O_CLOEXEC) + guard descriptor >= 0 else { throw posixError() } + defer { _ = close(descriptor) } + var status = stat() + guard fstat(descriptor, &status) == 0 else { throw posixError() } + guard (status.st_mode & S_IFMT) == S_IFREG, + resourceIdentifier(status) == binding.resourceIdentifier, + status.st_uid == binding.ownerUID, + Int(status.st_mode & 0o777) == binding.permissions, + UInt64(status.st_nlink) == binding.linkCount + else { return false } + let data = try readAll(descriptor, maxBytes: binding.data.count) + let checksum = SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + return data == binding.data && checksum == binding.checksum + } + + private static func readAll(_ descriptor: Int32, maxBytes: Int) throws -> Data { + var data = Data() + var buffer = [UInt8](repeating: 0, count: 64 * 1_024) + while true { + let count = Darwin.read(descriptor, &buffer, buffer.count) + if count < 0, errno == EINTR { continue } + guard count >= 0 else { throw posixError() } + if count == 0 { return data } + guard count <= maxBytes - data.count else { throw POSIXError(.EFBIG) } + data.append(buffer, count: count) + } + } + + private static func writeAll(_ data: Data, to descriptor: Int32) throws { + try data.withUnsafeBytes { rawBuffer in + guard var base = rawBuffer.baseAddress else { return } + var remaining = rawBuffer.count + while remaining > 0 { + let count = Darwin.write(descriptor, base, remaining) + if count < 0, errno == EINTR { continue } + guard count > 0 else { throw posixError() } + remaining -= count + base = base.advanced(by: count) + } + } + } + + private static func posixError() -> POSIXError { + POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } +} diff --git a/Sources/DevScope/Infrastructure/SystemAutomationCommandRunner.swift b/Sources/DevScope/Infrastructure/SystemAutomationCommandRunner.swift new file mode 100644 index 0000000..3da4895 --- /dev/null +++ b/Sources/DevScope/Infrastructure/SystemAutomationCommandRunner.swift @@ -0,0 +1,351 @@ +import Darwin +import DevScopeCore +import Foundation + +enum SystemAutomationCommandError: Error { + case invalidExecutable + case launchFailed + case outputLimitExceeded + case executionTimedOut +} + +struct SystemAutomationCommandRunnerHooks: Sendable { + var afterSpawnBeforeInstall: (@Sendable (pid_t) -> Void)? + var afterInstall: (@Sendable (pid_t) -> Void)? + var afterLeaderExitObserved: (@Sendable (pid_t) -> Void)? + + init( + afterSpawnBeforeInstall: (@Sendable (pid_t) -> Void)? = nil, + afterInstall: (@Sendable (pid_t) -> Void)? = nil, + afterLeaderExitObserved: (@Sendable (pid_t) -> Void)? = nil + ) { + self.afterSpawnBeforeInstall = afterSpawnBeforeInstall + self.afterInstall = afterInstall + self.afterLeaderExitObserved = afterLeaderExitObserved + } +} + +private final class AutomationChildProcessController: @unchecked Sendable { + private let lock = NSLock() + private var processGroupID: pid_t? + private var cancellationRequested = false + private var escalationTask: Task? + + func install(processGroupID: pid_t) { + let shouldCancel = lock.withLock { + self.processGroupID = processGroupID + return cancellationRequested + } + if shouldCancel { scheduleEscalationIfNeeded(for: processGroupID) } + } + + func clear() { + lock.withLock { processGroupID = nil } + } + + func cancel() { + let group = lock.withLock { () -> pid_t? in + cancellationRequested = true + return processGroupID + } + if let group { scheduleEscalationIfNeeded(for: group) } + } + + func awaitCancellationEscalationIfNeeded() async { + let task = lock.withLock { cancellationRequested ? escalationTask : nil } + await task?.value + } + + private func scheduleEscalationIfNeeded(for group: pid_t) { + lock.withLock { + guard escalationTask == nil else { return } + terminate(group, signal: SIGTERM) + escalationTask = Task.detached { + try? await Task.sleep(for: .seconds(2)) + guard !Task.isCancelled else { return } + _ = Darwin.kill(-group, SIGKILL) + } + } + } + + private func terminate(_ group: pid_t, signal: Int32) { + guard group > 0 else { return } + _ = Darwin.kill(-group, signal) + } +} + +actor SystemAutomationCommandRunner: AutomationCommandRunning { + private static let maximumCapturedBytes = 8 * 1_024 * 1_024 + private let hooks: SystemAutomationCommandRunnerHooks + private let maximumCapturedBytes: Int + private let executionTimeout: Duration + + init( + hooks: SystemAutomationCommandRunnerHooks = .init(), + maximumCapturedBytes: Int = SystemAutomationCommandRunner.maximumCapturedBytes, + executionTimeout: Duration = .seconds(30) + ) { + self.hooks = hooks + self.maximumCapturedBytes = max(0, maximumCapturedBytes) + self.executionTimeout = executionTimeout + } + + func run(_ command: AutomationCommand) async throws -> AutomationCommandResult { + guard command.executable.hasPrefix("/"), + !command.executable.contains("\0"), + command.arguments.allSatisfy({ !$0.contains("\0") }), + command.environment.allSatisfy({ key, value in + !key.isEmpty && !key.contains("=") && !key.contains("\0") && !value.contains("\0") + }) + else { throw SystemAutomationCommandError.invalidExecutable } + + try Task.checkCancellation() + let controller = AutomationChildProcessController() + return try await withTaskCancellationHandler { + let spawned = try Self.spawn(command) + hooks.afterSpawnBeforeInstall?(spawned.pid) + controller.install(processGroupID: spawned.pid) + hooks.afterInstall?(spawned.pid) + + let outputTask = Task.detached { + try await Self.performBlocking { + try Self.drain( + spawned.standardOutput, + maximumCapturedBytes: self.maximumCapturedBytes + ) + } + } + let errorTask = Task.detached { + try await Self.performBlocking { + try Self.drain( + spawned.standardError, + maximumCapturedBytes: self.maximumCapturedBytes + ) + } + } + let leaderTask = Task.detached { + try await Self.performBlocking { + try Self.observeLeaderExitWithoutReaping(spawned.pid) + } + } + let timeoutTask = Task.detached { [executionTimeout] in + do { + try await Task.sleep(for: executionTimeout) + } catch { + return false + } + controller.cancel() + return true + } + + do { + try await leaderTask.value + hooks.afterLeaderExitObserved?(spawned.pid) + let output = try await outputTask.value + let error = try await errorTask.value + timeoutTask.cancel() + if await timeoutTask.value { + throw SystemAutomationCommandError.executionTimedOut + } + let status = try Self.reap(spawned.pid) + controller.clear() + try Task.checkCancellation() + return AutomationCommandResult( + status: Self.commandStatus(fromWaitStatus: status), + standardOutput: output, + standardError: error + ) + } catch { + timeoutTask.cancel() + controller.cancel() + _ = try? await outputTask.value + _ = try? await errorTask.value + await controller.awaitCancellationEscalationIfNeeded() + _ = try? Self.reap(spawned.pid) + controller.clear() + throw error + } + } onCancel: { + controller.cancel() + } + } + + private struct SpawnedCommand: Sendable { + let pid: pid_t + let standardOutput: FileHandle + let standardError: FileHandle + } + + nonisolated private static func spawn(_ command: AutomationCommand) throws -> SpawnedCommand { + var output = [Int32](repeating: -1, count: 2) + var error = [Int32](repeating: -1, count: 2) + guard output.withUnsafeMutableBufferPointer({ Darwin.pipe($0.baseAddress!) }) == 0 else { + throw SystemAutomationCommandError.launchFailed + } + guard error.withUnsafeMutableBufferPointer({ Darwin.pipe($0.baseAddress!) }) == 0 else { + close(output[0]); close(output[1]) + throw SystemAutomationCommandError.launchFailed + } + for descriptor in output + error { + let existingFlags = fcntl(descriptor, F_GETFD) + guard existingFlags >= 0, + fcntl(descriptor, F_SETFD, existingFlags | FD_CLOEXEC) == 0 + else { + for descriptor in output { close(descriptor) } + for descriptor in error { close(descriptor) } + throw SystemAutomationCommandError.launchFailed + } + } + + var actions: posix_spawn_file_actions_t? = nil + var attributes: posix_spawnattr_t? = nil + guard posix_spawn_file_actions_init(&actions) == 0 else { + for descriptor in output { close(descriptor) } + for descriptor in error { close(descriptor) } + throw SystemAutomationCommandError.launchFailed + } + defer { posix_spawn_file_actions_destroy(&actions) } + guard posix_spawnattr_init(&attributes) == 0 else { + for descriptor in output { close(descriptor) } + for descriptor in error { close(descriptor) } + throw SystemAutomationCommandError.launchFailed + } + defer { + posix_spawnattr_destroy(&attributes) + } + + var result = posix_spawn_file_actions_addopen( + &actions, STDIN_FILENO, "/dev/null", O_RDONLY, 0 + ) + if result == 0 { result = posix_spawn_file_actions_adddup2(&actions, output[1], STDOUT_FILENO) } + if result == 0 { result = posix_spawn_file_actions_adddup2(&actions, error[1], STDERR_FILENO) } + if result == 0 { result = posix_spawn_file_actions_addclose(&actions, output[0]) } + if result == 0 { result = posix_spawn_file_actions_addclose(&actions, error[0]) } + if result == 0 { result = posix_spawn_file_actions_addclose(&actions, output[1]) } + if result == 0 { result = posix_spawn_file_actions_addclose(&actions, error[1]) } + + var emptyMask = sigset_t() + var defaultSignals = sigset_t() + sigemptyset(&emptyMask) + sigemptyset(&defaultSignals) + sigaddset(&defaultSignals, SIGINT) + sigaddset(&defaultSignals, SIGHUP) + sigaddset(&defaultSignals, SIGQUIT) + sigaddset(&defaultSignals, SIGTERM) + sigaddset(&defaultSignals, SIGPIPE) + if result == 0 { result = posix_spawnattr_setsigmask(&attributes, &emptyMask) } + if result == 0 { result = posix_spawnattr_setsigdefault(&attributes, &defaultSignals) } + if result == 0 { result = posix_spawnattr_setpgroup(&attributes, 0) } + let flags = Int16( + POSIX_SPAWN_SETPGROUP | POSIX_SPAWN_SETSIGMASK | POSIX_SPAWN_SETSIGDEF + | POSIX_SPAWN_CLOEXEC_DEFAULT + ) + if result == 0 { result = posix_spawnattr_setflags(&attributes, flags) } + guard result == 0 else { + for descriptor in output { close(descriptor) } + for descriptor in error { close(descriptor) } + throw SystemAutomationCommandError.launchFailed + } + + var environment = [ + "HOME": FileManager.default.homeDirectoryForCurrentUser.path, + "LOGNAME": NSUserName(), + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + "USER": NSUserName(), + ] + environment.merge(command.environment) { _, explicit in explicit } + let argv = [command.executable] + command.arguments + let envp = environment.keys.sorted().map { "\($0)=\(environment[$0]!)" } + var pid: pid_t = 0 + let spawnResult = withMutableCStringArray(argv) { argvPointer in + withMutableCStringArray(envp) { environmentPointer in + posix_spawn( + &pid, + command.executable, + &actions, + &attributes, + argvPointer, + environmentPointer + ) + } + } + close(output[1]) + close(error[1]) + guard spawnResult == 0, pid > 1 else { + close(output[0]) + close(error[0]) + throw SystemAutomationCommandError.launchFailed + } + return SpawnedCommand( + pid: pid, + standardOutput: FileHandle(fileDescriptor: output[0], closeOnDealloc: true), + standardError: FileHandle(fileDescriptor: error[0], closeOnDealloc: true) + ) + } + + nonisolated private static func withMutableCStringArray( + _ strings: [String], + _ body: (UnsafeMutablePointer?>) -> Result + ) -> Result { + let pointers = strings.map { strdup($0)! } + defer { + for pointer in pointers { free(pointer) } + } + var terminated = pointers.map(Optional.some) + terminated.append(nil) + return terminated.withUnsafeMutableBufferPointer { buffer in + body(buffer.baseAddress!) + } + } + + nonisolated private static func performBlocking( + _ operation: @escaping @Sendable () throws -> Value + ) async throws -> Value { + try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global(qos: .utility).async { + continuation.resume(with: Result { try operation() }) + } + } + } + + nonisolated private static func observeLeaderExitWithoutReaping(_ pid: pid_t) throws { + var information = siginfo_t() + while true { + if waitid(P_PID, id_t(pid), &information, WEXITED | WNOWAIT) == 0 { return } + if errno != EINTR { throw SystemAutomationCommandError.launchFailed } + } + } + + nonisolated private static func reap(_ pid: pid_t) throws -> Int32 { + var status: Int32 = 0 + while true { + let result = waitpid(pid, &status, 0) + if result == pid { return status } + if result == -1, errno == EINTR { continue } + throw SystemAutomationCommandError.launchFailed + } + } + + nonisolated private static func commandStatus(fromWaitStatus status: Int32) -> Int32 { + let signal = status & 0x7f + return signal == 0 ? (status >> 8) & 0xff : 128 + signal + } + + nonisolated private static func drain( + _ handle: FileHandle, + maximumCapturedBytes: Int + ) throws -> Data { + defer { try? handle.close() } + var captured = Data() + var exceededLimit = false + while let chunk = try handle.read(upToCount: 64 * 1_024), !chunk.isEmpty { + let available = maximumCapturedBytes - captured.count + if available > 0 { + captured.append(chunk.prefix(available)) + } + if chunk.count > available { exceededLimit = true } + } + if exceededLimit { throw SystemAutomationCommandError.outputLimitExceeded } + return captured + } +} diff --git a/Sources/DevScope/Stores/AutomationNotifier.swift b/Sources/DevScope/Stores/AutomationNotifier.swift new file mode 100644 index 0000000..8685da3 --- /dev/null +++ b/Sources/DevScope/Stores/AutomationNotifier.swift @@ -0,0 +1,156 @@ +import DevScopeCore +import Foundation +import UserNotifications + +protocol AutomationNotificationCenterClient: Sendable { + func requestAuthorization() async throws -> Bool + func deliver(_ content: AutomationNotificationContent) async throws +} + +enum AutomationNotificationDeliveryState: Equatable, Sendable { + case notRequested + case requesting + case enabled + case denied + case authorizationFailed + case deliveryFailed +} + +struct SystemAutomationNotificationCenter: AutomationNotificationCenterClient { + func requestAuthorization() async throws -> Bool { + try await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) + } + + func deliver(_ content: AutomationNotificationContent) async throws { + let notificationContent = UNMutableNotificationContent() + notificationContent.title = content.title + notificationContent.body = content.body + notificationContent.sound = .default + try await UNUserNotificationCenter.current().add(UNNotificationRequest( + identifier: UUID().uuidString, + content: notificationContent, + trigger: nil + )) + } +} + +@MainActor +final class AutomationNotifier: ObservableObject { + typealias Sleep = @Sendable (Duration) async throws -> Void + + @Published private(set) var preferences: AutomationNotificationPreferences + @Published private(set) var deliveryState: AutomationNotificationDeliveryState = .notRequested + + private let notificationCenter: any AutomationNotificationCenterClient + private let retryDelay: Duration + private let sleep: Sleep + private var policy: AutomationNotificationPolicy + private var deliveryTask: Task? + private var deliveryGeneration: UInt64 = 0 + + init( + notificationCenter: any AutomationNotificationCenterClient = SystemAutomationNotificationCenter(), + maximumRetainedEventIdentities: Int = 256, + retryDelay: Duration = .seconds(2), + sleep: @escaping Sleep = { try await Task.sleep(for: $0) } + ) { + self.notificationCenter = notificationCenter + self.retryDelay = max(.zero, retryDelay) + self.sleep = sleep + policy = AutomationNotificationPolicy( + maximumRetainedEventIdentities: maximumRetainedEventIdentities + ) + preferences = policy.preferences + } + + func setPreference( + _ preference: AutomationNotificationPreference, + isEnabled: Bool + ) async { + let action = policy.setPreference(preference, isEnabled: isEnabled) + preferences = policy.preferences + guard action == .requestAuthorization else { return } + await requestAuthorization() + } + + func synchronize(_ persisted: AutomationNotificationPreferences) async { + var shouldRequest = false + for preference in AutomationNotificationPreference.allCases { + if policy.setPreference(preference, isEnabled: persisted[preference]) == .requestAuthorization { + shouldRequest = true + } + } + preferences = policy.preferences + if shouldRequest { + await requestAuthorization() + } + } + + private func requestAuthorization() async { + deliveryState = .requesting + do { + let granted = try await notificationCenter.requestAuthorization() + policy.recordAuthorizationResult(granted: granted) + deliveryState = granted ? .enabled : .denied + } catch { + policy.recordAuthorizationResult(granted: false) + deliveryState = .authorizationFailed + } + } + + func consume(_ events: [AutomationEvent]) async { + deliveryGeneration &+= 1 + let generation = deliveryGeneration + let priorDelivery = deliveryTask + let task = Task { @MainActor [weak self] in + await priorDelivery?.value + await self?.consumeBatch(events) + } + deliveryTask = task + await task.value + if deliveryGeneration == generation { + deliveryTask = nil + } + } + + private func consumeBatch(_ events: [AutomationEvent]) async { + var attemptedDelivery = false + var deliveryFailed = false + for event in events { + guard let content = policy.notification(for: event) else { continue } + attemptedDelivery = true + if !(await deliverWithOneRetry(event, content: content)) { + deliveryFailed = true + } + } + if attemptedDelivery { + if deliveryFailed { + deliveryState = .deliveryFailed + } else if deliveryState != .deliveryFailed { + deliveryState = .enabled + } + } + } + + private func deliverWithOneRetry( + _ event: AutomationEvent, + content: AutomationNotificationContent + ) async -> Bool { + do { + try await notificationCenter.deliver(content) + return true + } catch { + policy.recordDeliveryFailure(for: event) + } + + do { + try await sleep(retryDelay) + guard let retryContent = policy.notification(for: event) else { return false } + try await notificationCenter.deliver(retryContent) + return true + } catch { + policy.recordDeliveryFailure(for: event) + return false + } + } +} diff --git a/Sources/DevScope/Stores/AutomationStore.swift b/Sources/DevScope/Stores/AutomationStore.swift new file mode 100644 index 0000000..703c47b --- /dev/null +++ b/Sources/DevScope/Stores/AutomationStore.swift @@ -0,0 +1,345 @@ +import DevScopeCore +import Foundation + +struct AutomationPendingOperation: Equatable, Sendable { + let recordID: AutomationRecord.ID + let operation: AutomationOperation + let startedAt: Date +} + +struct AutomationCapabilitySnapshot: Equatable, Sendable { + static let empty = AutomationCapabilitySnapshot( + inventoryGeneration: 0, + decisionsByRecordID: [:] + ) + + let inventoryGeneration: UInt64 + let decisionsByRecordID: [AutomationRecord.ID: AutomationCapabilityDecision] +} + +protocol AutomationCapabilityDecisionProviding: Sendable { + func decisions( + for records: [AutomationRecord] + ) async -> [AutomationRecord.ID: AutomationCapabilityDecision] +} + +protocol AutomationManagementDestinationProviding: Sendable { + func duplicateDestination(for record: AutomationRecord, label: String) -> URL? + func importDestination(for record: AutomationRecord, suggestedFilename: String) -> URL? +} + +private struct UnavailableAutomationManagementDestinationProvider: + AutomationManagementDestinationProviding +{ + func duplicateDestination(for record: AutomationRecord, label: String) -> URL? { nil } + func importDestination(for record: AutomationRecord, suggestedFilename: String) -> URL? { nil } +} + +private struct UnavailableAutomationCapabilityDecisionProvider: + AutomationCapabilityDecisionProviding +{ + func decisions( + for records: [AutomationRecord] + ) async -> [AutomationRecord.ID: AutomationCapabilityDecision] { + [:] + } +} + +protocol AutomationInventoryRefreshing: Sendable { + func refresh(force: Bool) async -> AutomationInventorySnapshot + func refreshAfterCurrent() async -> AutomationInventorySnapshot +} + +extension AutomationInventoryService: AutomationInventoryRefreshing {} + +protocol AutomationManaging: Sendable { + func perform( + _ operation: AutomationOperation, + record: AutomationRecord, + expectedChecksum: String?, + linkedProcesses: [ClassifiedDevProcess] + ) async -> AutomationOperationResult + + func restorationManifests() async -> [AutomationBackup] +} + +extension AutomationManager: AutomationManaging {} + +extension AutomationManaging { + func restorationManifests() async -> [AutomationBackup] { [] } +} + +@MainActor +final class AutomationStore: ObservableObject { + typealias Sleep = @Sendable (Duration) async throws -> Void + + private enum RefreshMode: Int { + case automatic + case force + case afterCurrent + + func merged(with other: RefreshMode) -> RefreshMode { + rawValue >= other.rawValue ? self : other + } + } + + @Published private(set) var snapshot = ProcessStoreLiveSnapshot.emptyAutomationInventory + @Published private(set) var isRefreshing = false + @Published private(set) var pendingOperation: AutomationPendingOperation? + @Published private(set) var operationResult: AutomationOperationResult? + @Published private(set) var operationResultRecordID: AutomationRecord.ID? + @Published private(set) var capabilitySnapshot = AutomationCapabilitySnapshot.empty + @Published private(set) var backups: [AutomationBackup] = [] + @Published var selectedRecordID: AutomationRecord.ID? + + private let inventoryService: any AutomationInventoryRefreshing + private let manager: any AutomationManaging + private let capabilityDecisionProvider: any AutomationCapabilityDecisionProviding + private let destinationProvider: any AutomationManagementDestinationProviding + private let sleep: Sleep + private let initialRefreshDelay: Duration + private let automaticRefreshInterval: Duration + private let now: @Sendable () -> Date + private var lifecycleTask: Task? + private var operationTask: Task? + private var activeRefreshTask: Task? + private var activeRefreshWaiters: [CheckedContinuation] = [] + private var pendingRefreshMode: RefreshMode? + private var pendingRefreshWaiters: [CheckedContinuation] = [] + private var refreshGeneration: UInt64 = 0 + private var operationGeneration: UInt64 = 0 + + init( + inventoryService: any AutomationInventoryRefreshing, + manager: any AutomationManaging, + capabilityDecisionProvider: any AutomationCapabilityDecisionProviding = + UnavailableAutomationCapabilityDecisionProvider(), + destinationProvider: any AutomationManagementDestinationProviding = + UnavailableAutomationManagementDestinationProvider(), + initialRefreshDelay: Duration = .milliseconds(350), + automaticRefreshInterval: Duration = .seconds(60), + sleep: @escaping Sleep = { try await Task.sleep(for: $0) }, + now: @escaping @Sendable () -> Date = Date.init + ) { + self.inventoryService = inventoryService + self.manager = manager + self.capabilityDecisionProvider = capabilityDecisionProvider + self.destinationProvider = destinationProvider + self.initialRefreshDelay = max(.zero, initialRefreshDelay) + self.automaticRefreshInterval = max(.seconds(60), automaticRefreshInterval) + self.sleep = sleep + self.now = now + } + + deinit { + lifecycleTask?.cancel() + operationTask?.cancel() + activeRefreshTask?.cancel() + for waiter in activeRefreshWaiters + pendingRefreshWaiters { waiter.resume() } + } + + func start() { + guard lifecycleTask == nil else { return } + let delay = initialRefreshDelay + let interval = automaticRefreshInterval + let sleep = sleep + lifecycleTask = Task { [weak self] in + do { + try await sleep(delay) + try Task.checkCancellation() + self?.refreshAutomatically() + while !Task.isCancelled { + try await sleep(interval) + try Task.checkCancellation() + self?.refreshAutomatically() + } + } catch is CancellationError { + return + } catch { + return + } + } + } + + func stop() { + lifecycleTask?.cancel() + lifecycleTask = nil + refreshGeneration &+= 1 + activeRefreshTask?.cancel() + activeRefreshTask = nil + pendingRefreshMode = nil + for waiter in activeRefreshWaiters + pendingRefreshWaiters { waiter.resume() } + activeRefreshWaiters.removeAll() + pendingRefreshWaiters.removeAll() + isRefreshing = false + } + + func refresh() { + requestRefresh(mode: .force) + } + + func dismissOperationResult() { + operationResult = nil + operationResultRecordID = nil + } + + func duplicateDestination(for record: AutomationRecord, label: String) -> URL? { + destinationProvider.duplicateDestination(for: record, label: label) + } + + func importDestination(for record: AutomationRecord, suggestedFilename: String) -> URL? { + destinationProvider.importDestination(for: record, suggestedFilename: suggestedFilename) + } + + func perform( + _ operation: AutomationOperation, + record: AutomationRecord, + expectedChecksum: String?, + linkedProcesses: [ClassifiedDevProcess] + ) { + guard pendingOperation == nil else { return } + let pending = AutomationPendingOperation( + recordID: record.id, + operation: operation, + startedAt: now() + ) + pendingOperation = pending + operationGeneration &+= 1 + let generation = operationGeneration + let manager = manager + operationTask = Task { [weak self] in + let result = await manager.perform( + operation, + record: record, + expectedChecksum: expectedChecksum, + linkedProcesses: linkedProcesses + ) + guard let self, + !Task.isCancelled, + operationGeneration == generation, + pendingOperation == pending else { return } + operationResult = result + operationResultRecordID = record.id + // AutomationManager returns only after its authoritative refresh. Read + // that cached generation instead of forcing every source to scan again. + await waitForRefresh(mode: .automatic) + guard !Task.isCancelled, + operationGeneration == generation, + pendingOperation == pending else { return } + pendingOperation = nil + operationTask = nil + } + } + + private func refreshAutomatically() { + requestRefresh(mode: .automatic) + } + + private func requestRefresh(mode: RefreshMode) { + enqueueRefresh(mode: mode, waiter: nil) + } + + private func waitForRefresh(mode: RefreshMode) async { + await withCheckedContinuation { continuation in + enqueueRefresh(mode: mode, waiter: continuation) + } + } + + private func enqueueRefresh( + mode: RefreshMode, + waiter: CheckedContinuation? + ) { + guard activeRefreshTask == nil else { + pendingRefreshMode = pendingRefreshMode?.merged(with: mode) ?? mode + if let waiter { pendingRefreshWaiters.append(waiter) } + return + } + startRefresh(mode: mode, waiters: waiter.map { [$0] } ?? []) + } + + private func startRefresh( + mode: RefreshMode, + waiters: [CheckedContinuation] + ) { + precondition(activeRefreshTask == nil) + refreshGeneration &+= 1 + let generation = refreshGeneration + let service = inventoryService + let capabilityDecisionProvider = capabilityDecisionProvider + let manager = manager + activeRefreshWaiters = waiters + isRefreshing = true + activeRefreshTask = Task { [weak self] in + let refreshed: AutomationInventorySnapshot + switch mode { + case .automatic: + refreshed = await service.refresh(force: false) + case .force: + refreshed = await service.refresh(force: true) + case .afterCurrent: + refreshed = await service.refreshAfterCurrent() + } + let decisions = await capabilityDecisionProvider.decisions(for: refreshed.records) + let backups = await manager.restorationManifests() + self?.completeRefresh( + generation: generation, + snapshot: refreshed, + capabilityDecisions: decisions, + backups: backups + ) + } + } + + private func completeRefresh( + generation: UInt64, + snapshot refreshed: AutomationInventorySnapshot, + capabilityDecisions: [AutomationRecord.ID: AutomationCapabilityDecision], + backups refreshedBackups: [AutomationBackup] + ) { + guard generation == refreshGeneration else { return } + activeRefreshTask = nil + let completedWaiters = activeRefreshWaiters + activeRefreshWaiters.removeAll() + + // Every ID-validated completion is a coherent publication point. A queued + // refresh may replace it later, but must not hide post-operation evidence + // from the waiter that requested this generation. + snapshot = refreshed + capabilitySnapshot = AutomationCapabilitySnapshot( + inventoryGeneration: refreshed.generation, + decisionsByRecordID: completedCapabilityDecisions( + for: refreshed.records, + provided: capabilityDecisions + ) + ) + backups = refreshedBackups + for waiter in completedWaiters { waiter.resume() } + + if let pendingMode = pendingRefreshMode { + let waiters = pendingRefreshWaiters + self.pendingRefreshMode = nil + pendingRefreshWaiters.removeAll() + startRefresh(mode: pendingMode, waiters: waiters) + } else { + isRefreshing = false + } + } + + private func completedCapabilityDecisions( + for records: [AutomationRecord], + provided: [AutomationRecord.ID: AutomationCapabilityDecision] + ) -> [AutomationRecord.ID: AutomationCapabilityDecision] { + Dictionary(uniqueKeysWithValues: records.map { record in + if let decision = provided[record.id] { + return (record.id, decision) + } + return ( + record.id, + AutomationCapabilityDecision( + capabilities: record.capabilities.intersection([.exportRecord]), + reason: "DevScope could not verify source ownership and path safety for management." + ) + ) + }) + } +} diff --git a/Sources/DevScope/Stores/FoundationModelGenerationOptions.swift b/Sources/DevScope/Stores/FoundationModelGenerationOptions.swift new file mode 100644 index 0000000..4ac866e --- /dev/null +++ b/Sources/DevScope/Stores/FoundationModelGenerationOptions.swift @@ -0,0 +1,22 @@ +#if canImport(FoundationModels) + import FoundationModels + + @available(macOS 26.0, *) + extension GenerationOptions { + static func devScopeGreedy(maximumResponseTokens: Int) -> Self { + #if compiler(>=6.4) + Self( + samplingMode: .greedy, + temperature: 0.0, + maximumResponseTokens: maximumResponseTokens + ) + #else + Self( + sampling: .greedy, + temperature: 0.0, + maximumResponseTokens: maximumResponseTokens + ) + #endif + } + } +#endif diff --git a/Sources/DevScope/Stores/ProcessNameEnhancer.swift b/Sources/DevScope/Stores/ProcessNameEnhancer.swift new file mode 100644 index 0000000..395b899 --- /dev/null +++ b/Sources/DevScope/Stores/ProcessNameEnhancer.swift @@ -0,0 +1,151 @@ +import DevScopeCore +import Foundation + +#if canImport(FoundationModels) +import FoundationModels +#endif + +actor ProcessNameEnhancer { + private var cache: [ProcessCacheIdentity: String] = [:] + + func enhancedName(for item: ClassifiedDevProcess) async -> String? { + guard shouldEnhance(item) else { + return nil + } + let identity = ProcessCacheIdentity(process: item.process) + + if let cached = cache[identity] { + if Self.isSafeDisplayName(cached, fallback: fallbackCandidate(for: item)) { + return cached + } + cache[identity] = nil + } + + guard let name = await generateName(for: item) else { + return nil + } + + cache[identity] = name + return name + } + + func retainOnly(identities: Set) { + cache = cache.filter { identities.contains($0.key) } + } + + func isAvailable() -> Bool { + #if canImport(FoundationModels) + if #available(macOS 26.0, *) { + return SystemLanguageModel.default.isAvailable + } + #endif + return false + } + + private func fallbackCandidate(for item: ClassifiedDevProcess) -> String { + item.classification.displayName + } + + private func prompt(for item: ClassifiedDevProcess) -> String { + """ + Rename this local development process for a macOS process monitor. + Return only a short human-readable label, 2 to 5 words, no quotes, no punctuation unless needed. + Prefer product/workload words like "Uvicorn API", "Next dev server", "Node MCP server", "Python research loop". + Preserve known product names such as Ollama, Redis, Postgres, Docker, and LM Studio. + Do not expose full paths. + + Runtime: \(item.classification.kind.rawValue) + Current label: \(fallbackCandidate(for: item)) + Project: \(ProcessPresentation.projectName(for: item) ?? "Unknown") + Executable: \(item.process.executableName) + Command: \(ProcessPresentation.redactedCommand(item.process.command)) + """ + } + + nonisolated static func isSafeDisplayName(_ candidate: String, fallback: String) -> Bool { + let cleaned = candidate + .replacingOccurrences(of: "\n", with: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + let lowered = cleaned.lowercased() + let blockedFragments = [ + "name convention", + "product name", + "return only", + "human-readable", + "macos process monitor" + ] + let blockedExactNames = [ + "bash", + "go", + "java", + "javascript", + "node", + "python", + "rust", + "shell", + "swift", + "typescript", + "zsh" + ] + + guard cleaned.count >= 4, + cleaned.count <= 42, + cleaned.split(separator: " ").count <= 6, + !cleaned.contains("/"), + !cleaned.contains(","), + !blockedExactNames.contains(lowered), + !blockedFragments.contains(where: { lowered.contains($0) }) else { + return false + } + + return cleaned.localizedCaseInsensitiveCompare(fallback) != .orderedSame + } + + private func sanitize(_ candidate: String, fallback: String) -> String? { + let cleaned = candidate + .replacingOccurrences(of: "\n", with: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + + guard Self.isSafeDisplayName(cleaned, fallback: fallback) else { + return nil + } + + return cleaned + } + + private func generateName(for item: ClassifiedDevProcess) async -> String? { + #if canImport(FoundationModels) + if #available(macOS 26.0, *) { + let model = SystemLanguageModel.default + guard model.isAvailable else { + return nil + } + + do { + let session = LanguageModelSession( + model: model, + instructions: "You create concise labels for local development processes. Be literal, conservative, and never invent a product name that is not implied by the command." + ) + let response = try await session.respond( + to: prompt(for: item), + options: .devScopeGreedy(maximumResponseTokens: 12) + ) + return sanitize(response.content, fallback: fallbackCandidate(for: item)) + } catch { + return nil + } + } + #endif + + return nil + } + + private func shouldEnhance(_ item: ClassifiedDevProcess) -> Bool { + switch item.classification.kind { + case .ai, .browser, .macApp, .backgroundAgent, .systemService, .shell, .other: + return false + case .javascript, .python, .swift, .rust, .go, .flutter, .java, .database, .container, .webServer, .mcp: + return true + } + } +} diff --git a/Sources/DevScope/Stores/ProcessSnapshotWorker.swift b/Sources/DevScope/Stores/ProcessSnapshotWorker.swift new file mode 100644 index 0000000..3ea4198 --- /dev/null +++ b/Sources/DevScope/Stores/ProcessSnapshotWorker.swift @@ -0,0 +1,18 @@ +import DevScopeCore +import Foundation + +actor ProcessSnapshotWorker { + private var builder = ProcessSnapshotBuilder() + + func invalidateWorkspaceFacts() { + builder.invalidateWorkspaceFacts() + } + + func build( + processes: [DevProcess], + now: Date, + graceInterval: TimeInterval + ) -> DevProcessSnapshot { + builder.build(processes: processes, now: now, graceInterval: graceInterval) + } +} diff --git a/Sources/DevScope/Stores/ProcessStore.swift b/Sources/DevScope/Stores/ProcessStore.swift new file mode 100644 index 0000000..646a44c --- /dev/null +++ b/Sources/DevScope/Stores/ProcessStore.swift @@ -0,0 +1,701 @@ +import DevScopeCore +import Foundation + +struct ProcessActionFeedback: Identifiable, Equatable { + enum Kind: Equatable { + case success + case warning + case error + case info + } + + let id = UUID() + let title: String + let detail: String + let symbolName: String + let kind: Kind +} + +private enum SignalVerificationContext { + case complete + case partial(failedTarget: ProcessIdentity, reason: String) +} + +private struct PendingSignalVerification { + let targets: [ProcessIdentity] + let signalName: String + let context: SignalVerificationContext +} + +@MainActor +final class ProcessStore: ObservableObject { + @Published private(set) var liveSnapshot = ProcessStoreLiveSnapshot() + @Published private(set) var enhancedNames: [ProcessCacheIdentity: String] = [:] + @Published private(set) var workflowNotes: [String: WorkflowAINote] = [:] + @Published private(set) var isRefreshing = false + @Published private(set) var isEnhancingNames = false + @Published private(set) var isEnhancingWorkflows = false + @Published private(set) var actionFeedback: ProcessActionFeedback? + @Published private(set) var automationEvents: [AutomationEvent] = [] + + var processes: [DevProcess] { liveSnapshot.processes } + var classifiedProcesses: [ClassifiedDevProcess] { liveSnapshot.classifiedProcesses } + var workflows: [DevWorkflow] { liveSnapshot.workflows } + var dashboardMetricHistory: [DevProcessMetricSample] { liveSnapshot.dashboardMetricHistory } + var liveProcessIDs: Set { liveSnapshot.liveProcessIDs } + var statusMessage: String { + get { liveSnapshot.statusMessage } + set { liveSnapshot.statusMessage = newValue } + } + var lastRefresh: Date? { liveSnapshot.lastRefresh } + + private let scanner: ProcessProviding + private let gpuMetricProvider: GPUMetricProviding + private let killer: ProcessKiller + private let nameEnhancer = ProcessNameEnhancer() + private let workflowEnhancer = WorkflowIntelligenceEnhancer() + private let snapshotWorker = ProcessSnapshotWorker() + private var processMetricHistory = ProcessMetricHistoryStore(limit: 120) + private var automaticRefreshTask: Task? + private var feedbackDismissalTask: Task? + private var pendingSignalVerifications: [PendingSignalVerification] = [] + private var automationContext = AutomationPresentationContext( + inventory: ProcessStoreLiveSnapshot.emptyAutomationInventory, + longRunningThreshold: AutomationPresentationContext.defaultLongRunningThreshold + ) + private var automationContextVersion: UInt64 = 0 + private var automationEventDetector = AutomationEventDetector() + private var previousAutomationPresentation: AutomationPresentationSnapshot? + private var resetAutomationEventBaselineOnNextScan = false + private var isScanInFlight = false + private var manualRefreshPending = false + private var lastCurrentDirectoryRefresh = Date.distantPast + private var lastNameEnhancement = Date.distantPast + private var lastWorkflowEnhancement = Date.distantPast + private var lastGPUMetricRefresh = Date.distantPast + private var cachedGPUMetric: DevGPUMetric? + private let currentDirectoryRefreshInterval: TimeInterval = 120 + private let gpuMetricRefreshInterval: TimeInterval = 10 + private let automaticRefreshInterval: Duration = .seconds(2) + private let nameEnhancementInterval: TimeInterval = 300 + private let workflowEnhancementInterval: TimeInterval = 300 + private let missingProcessGraceInterval: TimeInterval = 2.5 + private let maxDashboardMetricSamples = 180 + var usesAppleNaming = true + + init( + scanner: ProcessProviding = SystemProcessScanner(), + gpuMetricProvider: GPUMetricProviding = SystemGPUMetricProvider(), + killer: ProcessKiller = ProcessKiller() + ) { + self.scanner = scanner + self.gpuMetricProvider = gpuMetricProvider + self.killer = killer + } + + deinit { + automaticRefreshTask?.cancel() + feedbackDismissalTask?.cancel() + } + + func startRealtimeUpdates() { + guard automaticRefreshTask == nil else { + return + } + + automaticRefreshTask = Task { [weak self] in + try? await Task.sleep(for: .milliseconds(350)) + guard !Task.isCancelled else { + return + } + self?.refresh() + + while !Task.isCancelled { + try? await Task.sleep(for: self?.automaticRefreshInterval ?? .seconds(2)) + guard !Task.isCancelled else { + return + } + self?.refresh(isAutomatic: true) + } + } + } + + func stopRealtimeUpdates() { + automaticRefreshTask?.cancel() + automaticRefreshTask = nil + } + + func updateAutomationContext( + inventory: AutomationInventorySnapshot, + longRunningThreshold: TimeInterval + ) { + let nextContext = AutomationPresentationContext( + inventory: inventory, + longRunningThreshold: longRunningThreshold + ) + guard nextContext != automationContext else { return } + if nextContext.longRunningThreshold != automationContext.longRunningThreshold { + resetAutomationEventBaselineOnNextScan = true + } + automationContext = nextContext + automationContextVersion &+= 1 + + let projection = nextContext.build( + processes: liveSnapshot.processes, + now: liveSnapshot.lastRefresh ?? Date() + ) + liveSnapshot = liveSnapshot.replacingAutomationProjection(projection) + } + + func refresh(isAutomatic: Bool = false) { + guard !isScanInFlight else { + if !isAutomatic { + manualRefreshPending = true + isRefreshing = true + } + return + } + + let scanner = scanner + let gpuMetricProvider = gpuMetricProvider + let existingProcesses = processes + let capturedAutomationContext = automationContext + let capturedAutomationContextVersion = automationContextVersion + let includeCurrentDirectories = shouldRefreshCurrentDirectories(isAutomatic: isAutomatic) + let includeGPUMetric = shouldRefreshGPUMetric(isAutomatic: isAutomatic) + isScanInFlight = true + if !isAutomatic { + isRefreshing = true + } + + Task { @MainActor in + do { + if includeCurrentDirectories { + await snapshotWorker.invalidateWorkspaceFacts() + } + let scanResult = try await Task.detached(priority: .userInitiated) { + let processes = try scanner.snapshot(includeCurrentDirectories: includeCurrentDirectories) + let gpuMetric = includeGPUMetric ? try? gpuMetricProvider.snapshot() : nil + return (processes: processes, gpuMetric: gpuMetric) + }.value + if includeGPUMetric { + cachedGPUMetric = scanResult.gpuMetric + lastGPUMetricRefresh = Date() + } + let effectiveGPUMetric = includeGPUMetric ? scanResult.gpuMetric : cachedGPUMetric + let mergedSnapshot = mergeCurrentDirectories( + into: scanResult.processes, + from: existingProcesses, + includeCurrentDirectories: includeCurrentDirectories + ) + let timestamp = Date() + let presentationSnapshot = await snapshotWorker.build( + processes: mergedSnapshot, + now: timestamp, + graceInterval: missingProcessGraceInterval + ) + processMetricHistory.record( + processes: mergedSnapshot, + gpuMetric: effectiveGPUMetric, + timestamp: timestamp + ) + let nextDashboardHistory = updatedDashboardMetricHistory( + current: liveSnapshot.dashboardMetricHistory, + processes: mergedSnapshot, + gpuMetric: effectiveGPUMetric, + timestamp: timestamp + ) + let newestAutomationContext = capturedAutomationContextVersion == automationContextVersion + ? capturedAutomationContext + : automationContext + let automationPresentation = newestAutomationContext.build( + processes: presentationSnapshot.processes, + now: timestamp + ) + automationEvents = automationEventDetector.events( + previous: resetAutomationEventBaselineOnNextScan + ? nil : previousAutomationPresentation, + current: automationPresentation, + now: timestamp + ) + previousAutomationPresentation = automationPresentation + resetAutomationEventBaselineOnNextScan = false + liveSnapshot = ProcessStoreLiveSnapshot( + processes: presentationSnapshot.processes, + classifiedProcesses: presentationSnapshot.classified, + workflows: presentationSnapshot.workflows, + dashboardMetricHistory: nextDashboardHistory, + liveProcessIDs: presentationSnapshot.liveProcessIDs, + automationInventory: automationPresentation.inventory, + automationLinksByProcessID: automationPresentation.linksByProcessID, + allAutomationLinksByProcessID: automationPresentation.allLinksByProcessID, + longRunningProcessIDs: automationPresentation.longRunningProcessIDs, + statusMessage: "\(presentationSnapshot.liveProcessIDs.count) running items found", + lastRefresh: timestamp + ) + let enhanceableIdentities = Set( + presentationSnapshot.classified + .filter(isEnhanceableForNaming) + .map { ProcessCacheIdentity(process: $0.process) } + ) + enhancedNames = enhancedNames.filter { enhanceableIdentities.contains($0.key) } + workflowNotes = workflowNotes.filter { entry in + presentationSnapshot.workflows.contains { $0.id == entry.key } + } + if includeCurrentDirectories { + lastCurrentDirectoryRefresh = timestamp + } + if usesAppleNaming && shouldEnhanceNames(isAutomatic: isAutomatic) { + let liveItems = presentationSnapshot.classified.filter { + presentationSnapshot.liveProcessIDs.contains($0.process.pid) + } + enhanceVisibleNamesIfAvailable(items: liveItems) + } + if usesAppleNaming && shouldEnhanceWorkflows(isAutomatic: isAutomatic) { + enhanceWorkflowNotesIfAvailable( + workflows: presentationSnapshot.workflows, + items: presentationSnapshot.classified + ) + } + resolvePendingSignalVerifications() + } catch { + statusMessage = error.localizedDescription + } + isScanInFlight = false + if manualRefreshPending { + manualRefreshPending = false + refresh() + } else if !isAutomatic { + isRefreshing = false + } + } + } + + func displayName(for item: ClassifiedDevProcess) -> String { + guard usesAppleNaming, + let enhancedName = enhancedNames[ProcessCacheIdentity(process: item.process)], + ProcessNameEnhancer.isSafeDisplayName(enhancedName, fallback: item.classification.displayName) else { + return item.classification.displayName + } + + return enhancedName + } + + func metricHistory(for processID: Int32) -> [DevProcessMetricSample] { + processMetricHistory.history(for: processID) + } + + func familySummary(for process: DevProcess) -> ProcessFamilySummary { + ProcessPresentation.familySummary(for: process, in: processes) + } + + func isProcessLive(pid: Int32) -> Bool { + liveProcessIDs.contains(pid) + } + + private func enhanceVisibleNamesIfAvailable(items classifiedItems: [ClassifiedDevProcess]) { + guard !isEnhancingNames else { + return + } + + let items = Array(classifiedItems.filter(isEnhanceableForNaming).prefix(8)) + guard !items.isEmpty else { + return + } + + let enhancer = nameEnhancer + isEnhancingNames = true + + Task { @MainActor in + lastNameEnhancement = Date() + guard await enhancer.isAvailable() else { + isEnhancingNames = false + return + } + + await enhancer.retainOnly( + identities: Set(items.map { ProcessCacheIdentity(process: $0.process) }) + ) + var names: [ProcessCacheIdentity: String] = [:] + + for item in items { + if let enhancedName = await enhancer.enhancedName(for: item) { + names[ProcessCacheIdentity(process: item.process)] = enhancedName + } + } + + enhancedNames.merge(names) { _, new in new } + isEnhancingNames = false + } + } + + private func enhanceWorkflowNotesIfAvailable(workflows: [DevWorkflow], items: [ClassifiedDevProcess]) { + guard !isEnhancingWorkflows, !workflows.isEmpty else { + return + } + + let enhancer = workflowEnhancer + isEnhancingWorkflows = true + + Task { @MainActor in + lastWorkflowEnhancement = Date() + let notes = await enhancer.notes(for: workflows, items: items) + if !notes.isEmpty { + workflowNotes.merge(notes) { _, new in new } + } + isEnhancingWorkflows = false + } + } + + func actionDecision(for item: ClassifiedDevProcess) -> ProcessActionDecision { + ProcessActionPolicy.decision( + for: item, + currentProcessID: Int32(ProcessInfo.processInfo.processIdentifier) + ) + } + + private func allowSignal(_ item: ClassifiedDevProcess) -> Bool { + let decision = actionDecision(for: item) + guard let reason = decision.reason else { return true } + statusMessage = reason + presentFeedback( + title: "Process protected", + detail: reason, + symbolName: "lock.shield.fill", + kind: .warning + ) + return false + } + + func terminate(_ item: ClassifiedDevProcess) { + guard allowSignal(item) else { return } + let process = item.process + do { + let targets = try killer.terminate( + item, + currentProcessID: Int32(ProcessInfo.processInfo.processIdentifier) + ) + statusMessage = "Sent TERM to PID \(process.pid)" + presentFeedback( + title: "TERM sent", + detail: "\(process.executable) · PID \(process.pid)", + symbolName: "xmark.circle", + kind: .info + ) + scheduleSignalVerification(targets: targets, signalName: "TERM") + } catch { + handleSignalExecutionError(error, signalName: "TERM") + } + } + + func forceTerminate(_ item: ClassifiedDevProcess) { + guard allowSignal(item) else { return } + let process = item.process + do { + let targets = try killer.forceTerminate( + item, + currentProcessID: Int32(ProcessInfo.processInfo.processIdentifier) + ) + statusMessage = "Sent KILL to PID \(process.pid)" + presentFeedback( + title: "KILL sent", + detail: "\(process.executable) · PID \(process.pid)", + symbolName: "bolt.fill", + kind: .warning + ) + scheduleSignalVerification(targets: targets, signalName: "KILL") + } catch { + handleSignalExecutionError(error, signalName: "KILL") + } + } + + func terminateTree(root item: ClassifiedDevProcess) { + guard allowSignal(item) else { return } + do { + let targets = try killer.terminateTree( + root: item, + processes: processes, + classifiedProcesses: classifiedProcesses, + currentProcessID: Int32(ProcessInfo.processInfo.processIdentifier) + ) + statusMessage = "Sent TERM to \(targets.count) processes" + presentFeedback( + title: "TERM tree sent", + detail: "\(targets.count) process\(targets.count == 1 ? "" : "es") signaled", + symbolName: "point.3.connected.trianglepath.dotted", + kind: .info + ) + scheduleSignalVerification(targets: targets, signalName: "TERM") + } catch { + handleSignalExecutionError(error, signalName: "TERM") + } + } + + func forceTerminateTree(root item: ClassifiedDevProcess) { + guard allowSignal(item) else { return } + do { + let targets = try killer.forceTerminateTree( + root: item, + processes: processes, + classifiedProcesses: classifiedProcesses, + currentProcessID: Int32(ProcessInfo.processInfo.processIdentifier) + ) + statusMessage = "Sent KILL to \(targets.count) processes" + presentFeedback( + title: "KILL tree sent", + detail: "\(targets.count) process\(targets.count == 1 ? "" : "es") signaled", + symbolName: "bolt.horizontal.fill", + kind: .warning + ) + scheduleSignalVerification(targets: targets, signalName: "KILL") + } catch { + handleSignalExecutionError(error, signalName: "KILL") + } + } + + func presentFeedback( + title: String, + detail: String, + symbolName: String, + kind: ProcessActionFeedback.Kind = .info + ) { + actionFeedback = ProcessActionFeedback( + title: title, + detail: detail, + symbolName: symbolName, + kind: kind + ) + + feedbackDismissalTask?.cancel() + feedbackDismissalTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(5)) + guard !Task.isCancelled else { + return + } + await MainActor.run { + self?.actionFeedback = nil + } + } + } + + private func scheduleSignalVerification( + targets: [ProcessIdentity], + signalName: String, + context: SignalVerificationContext = .complete + ) { + var seenPIDs = Set() + let targets = targets.filter { seenPIDs.insert($0.pid).inserted } + guard !targets.isEmpty else { return } + + Task { @MainActor [weak self] in + try? await Task.sleep(for: .milliseconds(700)) + guard !Task.isCancelled, let self else { return } + pendingSignalVerifications.append( + PendingSignalVerification( + targets: targets, + signalName: signalName, + context: context + ) + ) + refresh() + } + } + + private func resolvePendingSignalVerifications() { + guard !pendingSignalVerifications.isEmpty else { return } + let verifications = pendingSignalVerifications + pendingSignalVerifications.removeAll() + let currentProcesses = processes.filter { liveProcessIDs.contains($0.pid) } + + for verification in verifications { + let observation = ProcessSignalObservation.observe( + targets: verification.targets, + in: currentProcesses + ) + switch verification.context { + case .complete: + presentCompleteSignalVerification( + observation, + signalName: verification.signalName + ) + case let .partial(failedTarget, reason): + let presentation = ProcessPartialSignalVerificationPresentation.make( + signalName: verification.signalName, + observation: observation, + failedTarget: failedTarget, + reason: reason + ) + statusMessage = presentation.statusMessage + presentFeedback( + title: presentation.title, + detail: presentation.detail, + symbolName: presentation.symbolName, + kind: .warning + ) + } + } + } + + private func presentCompleteSignalVerification( + _ observation: ProcessSignalObservationSummary, + signalName: String + ) { + let targetPIDs = observation.observations.map(\.target.pid) + if !observation.unverifiable.isEmpty { + let unverifiablePIDs = observation.unverifiable.map(\.pid).sorted() + let stillRunningPIDs = observation.stillRunning.map(\.pid).sorted() + var details = [ + "Could not verify birth identity for: \(unverifiablePIDs.map(String.init).joined(separator: ", "))" + ] + if !stillRunningPIDs.isEmpty { + details.append("Still running: \(stillRunningPIDs.map(String.init).joined(separator: ", "))") + } + statusMessage = "\(signalName) result could not be verified" + presentFeedback( + title: "Signal result not verified", + detail: details.joined(separator: ". "), + symbolName: "questionmark.circle.fill", + kind: .warning + ) + } else if observation.verifiesAllTargetsStopped { + let detail = targetPIDs.count == 1 + ? "PID \(targetPIDs[0]) exited" + : "\(targetPIDs.count) processes exited" + statusMessage = "\(signalName) completed" + presentFeedback( + title: "Process stopped", + detail: detail, + symbolName: "checkmark.circle.fill", + kind: .success + ) + } else { + let stillRunning = observation.stillRunning + statusMessage = "\(stillRunning.count) process\(stillRunning.count == 1 ? "" : "es") still running" + presentFeedback( + title: "\(signalName) still pending", + detail: "Still running: \(stillRunning.map(\.pid).sorted().map(String.init).joined(separator: ", "))", + symbolName: "exclamationmark.triangle.fill", + kind: .warning + ) + } + } + + private func presentSignalError(_ error: Error) { + presentFeedback( + title: "Signal failed", + detail: error.localizedDescription, + symbolName: "xmark.octagon.fill", + kind: .error + ) + } + + private func handleSignalExecutionError(_ error: Error, signalName: String) { + guard let failure = error as? ProcessSignalExecutionFailure, + !failure.signaledIdentities.isEmpty else { + let underlyingError = (error as? ProcessSignalExecutionFailure)?.underlyingError ?? error + statusMessage = underlyingError.localizedDescription + presentSignalError(underlyingError) + return + } + + let signaledCount = failure.signaledIdentities.count + scheduleSignalVerification( + targets: failure.signaledIdentities, + signalName: signalName, + context: .partial( + failedTarget: failure.failedTarget, + reason: failure.underlyingError.localizedDescription + ) + ) + statusMessage = "\(signalName) partially completed: \(signaledCount) signaled; PID \(failure.failedTarget.pid) failed" + presentFeedback( + title: "\(signalName) partially completed", + detail: "\(failure.localizedDescription). Earlier signals remain in effect.", + symbolName: "exclamationmark.triangle.fill", + kind: .warning + ) + } + + private func shouldRefreshCurrentDirectories(isAutomatic: Bool) -> Bool { + !isAutomatic || lastRefresh == nil || Date().timeIntervalSince(lastCurrentDirectoryRefresh) >= currentDirectoryRefreshInterval + } + + private func shouldRefreshGPUMetric(isAutomatic: Bool) -> Bool { + !isAutomatic || lastRefresh == nil || Date().timeIntervalSince(lastGPUMetricRefresh) >= gpuMetricRefreshInterval + } + + private func shouldEnhanceNames(isAutomatic: Bool) -> Bool { + !isAutomatic || Date().timeIntervalSince(lastNameEnhancement) >= nameEnhancementInterval + } + + private func shouldEnhanceWorkflows(isAutomatic: Bool) -> Bool { + !isAutomatic || Date().timeIntervalSince(lastWorkflowEnhancement) >= workflowEnhancementInterval + } + + private func isEnhanceableForNaming(_ item: ClassifiedDevProcess) -> Bool { + item.classification.kind != .ai && item.classification.kind != .other && item.classification.kind != .shell + } + + private func updatedDashboardMetricHistory( + current: [DevProcessMetricSample], + processes: [DevProcess], + gpuMetric: DevGPUMetric?, + timestamp: Date + ) -> [DevProcessMetricSample] { + var updated = current + let usage = processes.compactMap(\.resourceUsage) + let totalCPU = usage.reduce(0) { $0 + $1.cpuPercent } + let totalMemory = usage.reduce(Int64(0)) { $0 + $1.residentMemoryBytes } + + updated.append( + DevProcessMetricSample( + timestamp: timestamp, + cpuPercent: totalCPU, + residentMemoryBytes: totalMemory, + gpuPercent: gpuMetric?.utilizationPercent + ) + ) + + if updated.count > maxDashboardMetricSamples { + updated.removeFirst(updated.count - maxDashboardMetricSamples) + } + + return updated + } + + private func mergeCurrentDirectories( + into snapshot: [DevProcess], + from existingProcesses: [DevProcess], + includeCurrentDirectories: Bool + ) -> [DevProcess] { + guard !includeCurrentDirectories else { + return snapshot + } + + let existingByPID = Dictionary(uniqueKeysWithValues: existingProcesses.map { ($0.pid, $0) }) + return snapshot.map { process in + guard process.currentDirectory == nil, + let previous = existingByPID[process.pid], + let currentDirectory = ProcessScanner.carriedCurrentDirectory( + from: previous, + to: process + ) else { + return process + } + + return DevProcess( + pid: process.pid, + parentPID: process.parentPID, + executable: process.executable, + command: process.command, + argumentVector: process.argumentVector, + currentDirectory: currentDirectory, + resourceUsage: process.resourceUsage, + birthToken: process.birthToken, + bundleIdentifier: process.bundleIdentifier, + launchLabel: process.launchLabel + ) + } + } + +} diff --git a/Sources/DevScope/Stores/ProcessStoreLiveSnapshot.swift b/Sources/DevScope/Stores/ProcessStoreLiveSnapshot.swift new file mode 100644 index 0000000..da2ab4a --- /dev/null +++ b/Sources/DevScope/Stores/ProcessStoreLiveSnapshot.swift @@ -0,0 +1,34 @@ +import DevScopeCore +import Foundation + +struct ProcessStoreLiveSnapshot { + static let emptyAutomationInventory = AutomationInventorySnapshot( + generation: 0, + records: [], + health: [:], + refreshedAt: Date(timeIntervalSince1970: 0) + ) + + var processes: [DevProcess] = [] + var classifiedProcesses: [ClassifiedDevProcess] = [] + var workflows: [DevWorkflow] = [] + var dashboardMetricHistory: [DevProcessMetricSample] = [] + var liveProcessIDs: Set = [] + var automationInventory = Self.emptyAutomationInventory + var automationLinksByProcessID: [Int32: AutomationProcessLink] = [:] + var allAutomationLinksByProcessID: [Int32: [AutomationProcessLink]] = [:] + var longRunningProcessIDs: Set = [] + var statusMessage = "Ready" + var lastRefresh: Date? + + func replacingAutomationProjection( + _ projection: AutomationPresentationSnapshot + ) -> ProcessStoreLiveSnapshot { + var copy = self + copy.automationInventory = projection.inventory + copy.automationLinksByProcessID = projection.linksByProcessID + copy.allAutomationLinksByProcessID = projection.allLinksByProcessID + copy.longRunningProcessIDs = projection.longRunningProcessIDs + return copy + } +} diff --git a/Sources/DevScope/Stores/WorkflowIntelligenceEnhancer.swift b/Sources/DevScope/Stores/WorkflowIntelligenceEnhancer.swift new file mode 100644 index 0000000..2a1d4ca --- /dev/null +++ b/Sources/DevScope/Stores/WorkflowIntelligenceEnhancer.swift @@ -0,0 +1,180 @@ +import DevScopeCore +import Foundation + +#if canImport(FoundationModels) +import FoundationModels +#endif + +struct WorkflowAINote: Equatable { + let workflowID: String + let text: String +} + +struct WorkflowNoteCacheKey: Equatable, Sendable { + private struct ProcessFacts: Equatable, Sendable { + let identity: ProcessCacheIdentity + let kind: DevRuntimeKind + let displayName: String + let projectName: String? + let tags: [String] + let cpuPercent: Double? + let residentMemoryBytes: Int64? + let redactedCommand: String + } + + private let workflowID: String + private let title: String + private let kind: DevWorkflowKind + private let processIDs: [Int32] + private let primaryProject: String? + private let tags: [String] + private let totalCPU: Double + private let totalMemoryBytes: Int64 + private let risk: DevWorkflowRisk + private let summary: String + private let processFacts: [ProcessFacts] + + init(workflow: DevWorkflow, items: [ClassifiedDevProcess]) { + workflowID = workflow.id + title = workflow.title + kind = workflow.kind + processIDs = workflow.processIDs + primaryProject = workflow.primaryProject + tags = workflow.tags.map(\.title) + totalCPU = workflow.totalCPU + totalMemoryBytes = workflow.totalMemoryBytes + risk = workflow.risk + summary = workflow.summary + processFacts = items.map { item in + ProcessFacts( + identity: ProcessCacheIdentity(process: item.process), + kind: item.classification.kind, + displayName: item.classification.displayName, + projectName: ProcessPresentation.projectName(for: item), + tags: item.classification.tags.map(\.title), + cpuPercent: item.process.resourceUsage?.cpuPercent, + residentMemoryBytes: item.process.resourceUsage?.residentMemoryBytes, + redactedCommand: ProcessPresentation.redactedCommand(item.process.command) + ) + } + } +} + +actor WorkflowIntelligenceEnhancer { + private struct CacheEntry { + let key: WorkflowNoteCacheKey + let note: WorkflowAINote + } + + private var cache: [String: CacheEntry] = [:] + + func notes(for workflows: [DevWorkflow], items: [ClassifiedDevProcess]) async -> [String: WorkflowAINote] { + guard await isAvailable() else { + return [:] + } + + let itemsByPID = Dictionary(uniqueKeysWithValues: items.map { ($0.process.pid, $0) }) + var notes: [String: WorkflowAINote] = [:] + + for workflow in workflows.prefix(4) { + let relatedItems = workflow.processIDs.compactMap { itemsByPID[$0] } + let cacheKey = WorkflowNoteCacheKey(workflow: workflow, items: relatedItems) + if let cached = cache[workflow.id], cached.key == cacheKey { + notes[workflow.id] = cached.note + continue + } + + guard let note = await generateNote(for: workflow, items: relatedItems) else { + continue + } + + cache[workflow.id] = CacheEntry(key: cacheKey, note: note) + notes[workflow.id] = note + } + + cache = cache.filter { entry in + workflows.contains { $0.id == entry.key } + } + + return notes + } + + func isAvailable() async -> Bool { + #if canImport(FoundationModels) + if #available(macOS 26.0, *) { + return SystemLanguageModel.default.isAvailable + } + #endif + return false + } + + private func prompt(for workflow: DevWorkflow, items: [ClassifiedDevProcess]) -> String { + let processFacts = items.prefix(10).map { item in + let tags = item.classification.tags.map(\.title).joined(separator: ", ") + let project = workflow.primaryProject ?? ProcessPresentation.projectName(for: item) ?? "Unknown" + return """ + - PID \(item.process.pid), \(item.classification.kind.rawValue), label: \(item.classification.displayName), project: \(project), CPU: \(item.process.resourceUsage?.cpuPercent ?? 0), memory bytes: \(item.process.resourceUsage?.residentMemoryBytes ?? 0), tags: \(tags.isEmpty ? "none" : tags), command: \(ProcessPresentation.redactedCommand(item.process.command)) + """ + } + .joined(separator: "\n") + + return """ + Create one concise operator note for this local developer workflow. + Return only one sentence, max 24 words. + Do not invent facts. Do not mention private paths. Do not suggest force kill. + + Workflow: \(workflow.title) + Kind: \(workflow.kind.rawValue) + Summary: \(workflow.summary) + Total CPU: \(workflow.totalCPU) + Total memory bytes: \(workflow.totalMemoryBytes) + Processes: + \(processFacts) + """ + } + + private func generateNote(for workflow: DevWorkflow, items: [ClassifiedDevProcess]) async -> WorkflowAINote? { + #if canImport(FoundationModels) + if #available(macOS 26.0, *) { + let model = SystemLanguageModel.default + guard model.isAvailable else { + return nil + } + + do { + let session = LanguageModelSession( + model: model, + instructions: "You summarize local development process workflows. Be factual, conservative, and concise." + ) + let response = try await session.respond( + to: prompt(for: workflow, items: items), + options: .devScopeGreedy(maximumResponseTokens: 40) + ) + return sanitize(response.content, workflowID: workflow.id) + } catch { + return nil + } + } + #endif + + return nil + } + + private func sanitize(_ text: String, workflowID: String) -> WorkflowAINote? { + let cleaned = text + .replacingOccurrences(of: "\n", with: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + let lowered = cleaned.lowercased() + + guard cleaned.count >= 8, + cleaned.count <= 180, + !cleaned.contains("/Users/"), + !cleaned.contains("force kill"), + !lowered.contains("i think"), + !lowered.contains("probably") else { + return nil + } + + return WorkflowAINote(workflowID: workflowID, text: cleaned) + } +} diff --git a/Sources/DevScope/Views/ApplicationFamilyListView.swift b/Sources/DevScope/Views/ApplicationFamilyListView.swift new file mode 100644 index 0000000..649f217 --- /dev/null +++ b/Sources/DevScope/Views/ApplicationFamilyListView.swift @@ -0,0 +1,213 @@ +import DevScopeCore +import SwiftUI + +struct ApplicationFamilyListView: View { + @Environment(\.accessibilityReduceMotion) private var reduceMotion + let families: [ProcessApplicationFamily] + @Binding var selection: Int32? + @Binding var expandedFamilyIDs: Set + let displayName: (ClassifiedDevProcess) -> String + + var body: some View { + ZStack { + VStack(spacing: 0) { + header + + ScrollView { + LazyVStack(spacing: 0) { + ForEach(families) { family in + familySection(family) + } + } + } + } + + if families.isEmpty { + ContentUnavailableView( + "No matching applications", + systemImage: "macwindow.on.rectangle", + description: Text( + "Try a different search or open an application with visible OS processes." + ) + ) + } + } + } + + private var header: some View { + HStack(spacing: 0) { + Text("Application family") + .frame(maxWidth: .infinity, alignment: .leading) + Text("OS processes") + .frame(width: 96, alignment: .trailing) + Text("CPU") + .frame(width: 76, alignment: .trailing) + Text("Memory") + .frame(width: 96, alignment: .trailing) + } + .font(.caption.weight(.semibold)) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(.ultraThinMaterial) + .overlay(alignment: .bottom) { Divider() } + } + + @ViewBuilder + private func familySection(_ family: ProcessApplicationFamily) -> some View { + let isExpanded = expandedFamilyIDs.contains(family.id) + + Button { + withAnimation(reduceMotion ? nil : .snappy(duration: 0.18)) { + if isExpanded { + expandedFamilyIDs.remove(family.id) + } else { + expandedFamilyIDs.insert(family.id) + } + } + } label: { + HStack(spacing: 0) { + HStack(spacing: 10) { + Image(systemName: isExpanded ? "chevron.down" : "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .frame(width: 12) + + Image(systemName: "macwindow.on.rectangle") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(DevScopePalette.accent) + .frame(width: 28, height: 28) + .background( + DevScopePalette.accent.opacity(0.14), + in: RoundedRectangle(cornerRadius: 8, style: .continuous) + ) + + VStack(alignment: .leading, spacing: 2) { + Text(family.title) + .font(.callout.weight(.semibold)) + .lineLimit(1) + Text(roleSummary(family)) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + + Text("\(family.members.count)") + .font(.callout.monospacedDigit().weight(.semibold)) + .frame(width: 96, alignment: .trailing) + Text(ProcessMetricFormat.cpu(family.totalCPU)) + .font(.callout.monospacedDigit()) + .frame(width: 76, alignment: .trailing) + Text(ProcessMetricFormat.memory(family.totalMemoryBytes)) + .font(.callout.monospacedDigit()) + .frame(width: 96, alignment: .trailing) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(family.title) + .accessibilityValue( + "\(family.members.count) OS processes, \(roleSummary(family)), \(isExpanded ? "expanded" : "collapsed")" + ) + .accessibilityHint(isExpanded ? "Collapses this application family." : "Shows its OS processes.") + .help(isExpanded ? "Hide application processes" : "Show application processes") + + if isExpanded { + ForEach(family.members) { member in + memberRow(member) + } + .transition(.opacity) + } + + Divider() + } + + private func memberRow(_ member: ApplicationProcessMember) -> some View { + let item = member.item + let isSelected = selection == item.process.pid + + return Button { + selection = item.process.pid + } label: { + HStack(spacing: 0) { + HStack(spacing: 8) { + Color.clear + .frame(width: CGFloat(min(member.depth, 8)) * 14) + + Image(systemName: member.role.symbolName) + .font(.caption.weight(.semibold)) + .foregroundStyle(roleColor(member.role)) + .frame(width: 18) + + VStack(alignment: .leading, spacing: 2) { + Text(displayName(item)) + .font(.callout.weight(isSelected ? .semibold : .regular)) + .lineLimit(1) + HStack(spacing: 6) { + Label(member.role.title, systemImage: member.role.symbolName) + .font(.caption2.weight(.semibold)) + .foregroundStyle(roleColor(member.role)) + Text("PID \(item.process.pid)") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + + Text("") + .frame(width: 96) + Text(ProcessMetricFormat.cpu(item.process.resourceUsage?.cpuPercent)) + .font(.callout.monospacedDigit()) + .frame(width: 76, alignment: .trailing) + Text(ProcessMetricFormat.memory(item.process.resourceUsage?.residentMemoryBytes)) + .font(.callout.monospacedDigit()) + .frame(width: 96, alignment: .trailing) + } + .padding(.leading, 24) + .padding(.trailing, 12) + .padding(.vertical, 6) + .contentShape(Rectangle()) + .background(isSelected ? DevScopePalette.accent.opacity(0.18) : Color.clear) + .overlay(alignment: .leading) { + if isSelected { + Rectangle() + .fill(DevScopePalette.accent) + .frame(width: 3) + } + } + } + .buttonStyle(.plain) + .accessibilityLabel("\(displayName(item)), \(member.role.title), PID \(item.process.pid)") + .accessibilityValue( + "\(ProcessMetricFormat.cpu(item.process.resourceUsage?.cpuPercent)), \(ProcessMetricFormat.memory(item.process.resourceUsage?.residentMemoryBytes))" + ) + .accessibilityAddTraits(isSelected ? .isSelected : []) + } + + private func roleSummary(_ family: ProcessApplicationFamily) -> String { + [ + countLabel(family.applicationCount, singular: "app", plural: "apps"), + countLabel(family.helperCount, singular: "helper", plural: "helpers"), + countLabel(family.workerCount, singular: "worker", plural: "workers"), + ].joined(separator: " · ") + } + + private func countLabel(_ count: Int, singular: String, plural: String) -> String { + "\(count) \(count == 1 ? singular : plural)" + } + + private func roleColor(_ role: ApplicationProcessRole) -> Color { + switch role { + case .application: + DevScopePalette.accent + case .helper: + .secondary + case .worker: + .orange + } + } +} diff --git a/Sources/DevScope/Views/AutomationApplicationIconStore.swift b/Sources/DevScope/Views/AutomationApplicationIconStore.swift new file mode 100644 index 0000000..4dcb3a4 --- /dev/null +++ b/Sources/DevScope/Views/AutomationApplicationIconStore.swift @@ -0,0 +1,158 @@ +import AppKit +import Combine +import DevScopeCore +import Foundation + +@MainActor +protocol AutomationApplicationLocating: AnyObject { + func applicationURL(forBundleIdentifier identifier: String) -> URL? + func icon(forApplicationAt url: URL) -> NSImage? +} + +@MainActor +final class WorkspaceAutomationApplicationLocator: AutomationApplicationLocating { + func applicationURL(forBundleIdentifier identifier: String) -> URL? { + NSWorkspace.shared.urlForApplication(withBundleIdentifier: identifier) + } + + func icon(forApplicationAt url: URL) -> NSImage? { + guard url.pathExtension.localizedCaseInsensitiveCompare("app") == .orderedSame else { + return nil + } + return NSWorkspace.shared.icon(forFile: url.path) + } +} + +struct AutomationApplicationIconKey: Hashable, Sendable { + let bundleIdentifier: String? + let sourcePath: String? + let executablePath: String? +} + +@MainActor +final class AutomationApplicationIconStore: ObservableObject { + private enum Resolution { + case image(NSImage) + case missing + + var icon: NSImage? { + guard case .image(let image) = self else { + return nil + } + return image + } + } + + private let locator: AutomationApplicationLocating + private let capacity: Int + private var cache: [AutomationApplicationIconKey: Resolution] = [:] + private var insertionOrder: [AutomationApplicationIconKey] = [] + private var pending: [AutomationApplicationIconKey: Task] = [:] + + private(set) var revision = 0 + + init( + locator: AutomationApplicationLocating = WorkspaceAutomationApplicationLocator(), + capacity: Int = 256 + ) { + self.locator = locator + self.capacity = max(1, capacity) + } + + func key(for record: AutomationRecord) -> AutomationApplicationIconKey { + AutomationApplicationIconKey( + bundleIdentifier: record.providerBundleIdentifier, + sourcePath: record.sourceURL?.standardizedFileURL.path, + executablePath: record.executable.map { URL(fileURLWithPath: $0).standardizedFileURL.path } + ) + } + + func icon(for record: AutomationRecord) -> NSImage? { + guard case .image(let image) = cache[key(for: record)] else { + return nil + } + return image + } + + @discardableResult + func resolve(_ record: AutomationRecord) async -> NSImage? { + let cacheKey = key(for: record) + if let cached = cache[cacheKey] { + return cached.icon + } + + let resolutionTask: Task + if let inFlight = pending[cacheKey] { + resolutionTask = inFlight + } else { + let created = Task { @MainActor in + await Task.yield() + return resolveNow(record) + } + pending[cacheKey] = created + resolutionTask = created + } + + let resolution = await resolutionTask.value + complete(resolution, for: cacheKey) + return resolution.icon + } + + private func resolveNow(_ record: AutomationRecord) -> Resolution { + if let identifier = record.providerBundleIdentifier, + let applicationURL = locator.applicationURL(forBundleIdentifier: identifier), + let image = locator.icon(forApplicationAt: applicationURL) { + return .image(image) + } + + if let sourceURL = record.sourceURL, + sourceURL.pathExtension.localizedCaseInsensitiveCompare("app") == .orderedSame, + let image = locator.icon(forApplicationAt: sourceURL) { + return .image(image) + } + + if let applicationURL = applicationBundleURL(containing: record.executable), + let image = locator.icon(forApplicationAt: applicationURL) { + return .image(image) + } + + return .missing + } + + private func applicationBundleURL(containing path: String?) -> URL? { + guard let path else { + return nil + } + + var candidate = URL(fileURLWithPath: path).standardizedFileURL + while candidate.path != "/" { + if candidate.pathExtension.localizedCaseInsensitiveCompare("app") == .orderedSame { + return candidate + } + candidate.deleteLastPathComponent() + } + return nil + } + + private func evictIfNeeded() { + while cache.count > capacity, + let index = insertionOrder.firstIndex(where: { pending[$0] == nil }) { + cache.removeValue(forKey: insertionOrder.remove(at: index)) + } + } + + private func complete( + _ resolution: Resolution, + for cacheKey: AutomationApplicationIconKey + ) { + pending.removeValue(forKey: cacheKey) + guard cache[cacheKey] == nil else { + return + } + cache[cacheKey] = resolution + insertionOrder.removeAll { $0 == cacheKey } + insertionOrder.append(cacheKey) + evictIfNeeded() + revision &+= 1 + } +} diff --git a/Sources/DevScope/Views/AutomationConfirmationView.swift b/Sources/DevScope/Views/AutomationConfirmationView.swift new file mode 100644 index 0000000..f4e8264 --- /dev/null +++ b/Sources/DevScope/Views/AutomationConfirmationView.swift @@ -0,0 +1,62 @@ +import DevScopeCore +import SwiftUI + +struct AutomationConfirmationView: View { + @Environment(\.dismiss) private var dismiss + let action: AutomationManagementAction + let record: AutomationRecord + let policy: AutomationConfirmationPolicy + let confirm: () -> Void + @State private var enteredLabel = "" + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Label(policy.title, systemImage: action.isDestructive ? "exclamationmark.triangle" : "checkmark.shield") + .font(.title3.weight(.semibold)) + .foregroundStyle(action.isDestructive ? Color.orange : DevScopePalette.accent) + + Text(policy.consequence) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + if let command = policy.displayedCommand { + VStack(alignment: .leading, spacing: 6) { + Text("Command") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Text(command) + .font(.system(.callout, design: .monospaced)) + .textSelection(.enabled) + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.quaternary.opacity(0.35), in: RoundedRectangle(cornerRadius: 8)) + } + } + + if let requiredLabel = policy.requiredLabel { + VStack(alignment: .leading, spacing: 6) { + Text("Enter \(requiredLabel) to confirm") + .font(.callout.weight(.medium)) + TextField("Automation label", text: $enteredLabel) + .textFieldStyle(.roundedBorder) + .accessibilityHint("The Remove button is enabled only when this exactly matches the automation label.") + } + } + + HStack { + Spacer() + Button("Cancel") { dismiss() } + .keyboardShortcut(.cancelAction) + Button(action.title, role: action.isDestructive ? .destructive : nil) { + confirm() + dismiss() + } + .disabled(!policy.isSatisfiedByLabel(enteredLabel)) + .keyboardShortcut(action.isDestructive ? nil : .defaultAction) + } + } + .padding(20) + .frame(width: 470) + .accessibilityElement(children: .contain) + } +} diff --git a/Sources/DevScope/Views/AutomationDetailView.swift b/Sources/DevScope/Views/AutomationDetailView.swift new file mode 100644 index 0000000..b66c06f --- /dev/null +++ b/Sources/DevScope/Views/AutomationDetailView.swift @@ -0,0 +1,592 @@ +import DevScopeCore +import SwiftUI + +struct AutomationDetailView: View { + let record: AutomationRecord? + let iconStore: AutomationApplicationIconStore + let decision: AutomationCapabilityDecision? + let linkedProcesses: [ClassifiedDevProcess] + let sourceHealth: AutomationSourceHealth? + let backups: [AutomationBackup] + let pendingOperation: AutomationPendingOperation? + let operationResult: AutomationOperationResult? + let perform: (AutomationManagementAction) -> Void + let dismissResult: () -> Void + let saveExport: (AutomationExportArtifact) -> Void + let openLoginItemsSettings: () -> Void + + var body: some View { + Group { + if let record { + VStack(spacing: 0) { + AutomationManagementBar( + record: record, + decision: decision, + backups: backups, + pendingOperation: pendingOperation, + operationResult: operationResult, + perform: perform, + dismissResult: dismissResult, + saveExport: saveExport, + openLoginItemsSettings: openLoginItemsSettings + ) + + Divider() + + ScrollView { + VStack(alignment: .leading, spacing: 18) { + header(record) + healthSection(record) + overview(record) + trigger(record) + command(record) + currentProcesses + source(record) + validation(record) + operationResultSection + history(record) + } + .padding(16) + } + } + } else { + ContentUnavailableView( + "Select an automation", + systemImage: "gearshape.2", + description: Text("Choose a definition to inspect its trigger, source, live processes, validation, and supported management controls.") + ) + } + } + } + + private func header(_ record: AutomationRecord) -> some View { + HStack(alignment: .top, spacing: 12) { + AutomationIdentityView(record: record, store: iconStore, size: 48) + VStack(alignment: .leading, spacing: 6) { + Text(record.displayName) + .font(.title3.weight(.semibold)) + .fixedSize(horizontal: false, vertical: true) + Text(record.label) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + Label(record.state.displayTitle, systemImage: record.state.symbolName) + .font(.caption.weight(.semibold)) + .foregroundStyle(record.state.tint) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + @ViewBuilder + private func healthSection(_ record: AutomationRecord) -> some View { + if let sourceHealth, sourceHealth.state != .healthy { + AutomationInspectorSection(title: "Source Status", systemImage: "exclamationmark.triangle") { + Text(sourceHealth.message ?? "\(record.sourceKind.displayTitle) could not be fully inspected.") + .font(.callout) + .foregroundStyle(.orange) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + private func overview(_ record: AutomationRecord) -> some View { + AutomationInspectorSection(title: "Overview", systemImage: "info.circle") { + inspectorGrid([ + ("Kind", record.kind.displayTitle), + ("State", record.state.displayTitle), + ("Ownership", record.ownership.displayTitle), + ("Enabled", record.enabledState.rawValue.capitalized), + ("Loaded", record.loadState.rawValue.capitalized), + ("Approval", record.approvalState.rawValue.capitalized), + ]) + if let provider = record.providerBundleIdentifier { + field("Owning Application", provider) + } + ForEach(record.evidence, id: \.self) { evidence in + Label("\(evidence.source): \(evidence.detail)", systemImage: evidence.strength == .strong ? "checkmark.seal" : "questionmark.circle") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + private func trigger(_ record: AutomationRecord) -> some View { + AutomationInspectorSection(title: "Trigger", systemImage: "calendar.badge.clock") { + field("Schedule", record.schedule.summary) + ForEach(Array(record.schedule.triggers.enumerated()), id: \.offset) { _, trigger in + Text(String(describing: trigger)) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + private func command(_ record: AutomationRecord) -> some View { + AutomationInspectorSection(title: "Command", systemImage: "terminal") { + field("Executable", record.executable ?? "Unavailable") + if !record.arguments.isEmpty { + field("Arguments", ProcessPresentation.redactedCommand(record.arguments.joined(separator: " "))) + } + field("Working Directory", record.workingDirectory ?? "Not configured") + if !record.environment.isEmpty { + field("Environment Keys", record.environment.keys.sorted().joined(separator: ", ")) + } + } + } + + private var currentProcesses: some View { + AutomationInspectorSection(title: "Current Processes", systemImage: "waveform.path.ecg") { + if linkedProcesses.isEmpty { + Text("No strongly linked process is currently running.") + .font(.callout) + .foregroundStyle(.secondary) + } else { + ForEach(linkedProcesses, id: \.process.pid) { item in + HStack { + Text(item.classification.displayName).lineLimit(1) + Spacer() + Text("PID \(item.process.pid)").font(.caption.monospacedDigit()).foregroundStyle(.secondary) + } + } + } + } + } + + private func source(_ record: AutomationRecord) -> some View { + AutomationInspectorSection(title: "Source", systemImage: "doc.text") { + field("Provider", record.sourceKind.displayTitle) + field("Path", record.sourceURL?.path ?? "Not exposed by macOS") + field("Checksum", record.sourceChecksum ?? "Unavailable") + } + } + + private func validation(_ record: AutomationRecord) -> some View { + AutomationInspectorSection(title: "Validation", systemImage: "checkmark.shield") { + if record.validationFindings.isEmpty { + Label("No validation findings", systemImage: "checkmark.circle") + .font(.callout) + .foregroundStyle(.green) + } else { + ForEach(record.validationFindings, id: \.self) { finding in + Label(finding, systemImage: "exclamationmark.circle") + .font(.callout) + .foregroundStyle(.orange) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } + + @ViewBuilder + private var operationResultSection: some View { + if let operationResult { + let result = AutomationManagementPresentation.result(operationResult) + let recoveryHandles = recoveryHandles(from: operationResult) + AutomationInspectorSection( + title: "Operation Result", + systemImage: result.isFailure ? "exclamationmark.circle" : "checkmark.circle" + ) { + VStack(alignment: .leading, spacing: 6) { + Text(result.detail) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + resultEvidence("Applied", values: result.appliedEvidence) + resultEvidence("Verified", values: result.verificationEvidence) + resultEvidence("Rollback", values: [result.rollbackEvidence]) + if let backup = result.backupEvidence { + resultEvidence("Recovery Backup", values: [backup]) + } + resultEvidence("File Mutation", values: result.mutationEvidence) + if let recovery = result.recoveryGuidance { + Text(recovery).font(.caption).foregroundStyle(.orange).textSelection(.enabled) + } + if !recoveryHandles.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("Recovery Locations") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + ForEach(recoveryHandles, id: \.fileURL.path) { handle in + VStack(alignment: .leading, spacing: 5) { + Text(handle.fileURL.path) + .font(.caption.monospaced()) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + HStack(spacing: 8) { + Button("Reveal in Finder") { + NSWorkspace.shared.activateFileViewerSelecting([handle.fileURL]) + } + Button("Copy Path") { + copyRecoveryPath(handle.fileURL) + } + } + .controlSize(.small) + } + } + } + .padding(.top, 4) + } + } + .padding(10) + .background(.quaternary.opacity(0.25), in: RoundedRectangle(cornerRadius: 10)) + } + } + } + + private func history(_ record: AutomationRecord) -> some View { + AutomationInspectorSection(title: "History & Recovery", systemImage: "clock.arrow.circlepath") { + let matching = backups.filter { $0.recordID == record.id }.sorted { $0.createdAt > $1.createdAt } + if matching.isEmpty { + Text("No verified restoration backup is available for this automation.") + .font(.callout) + .foregroundStyle(.secondary) + } else { + ForEach(matching) { backup in + let restoreAvailable = pendingOperation == nil + && decision?.capabilities.contains(.restore) == true + let restoreHelp = restoreAvailable + ? "Restore the verified backup from \(backup.createdAt.formatted(date: .abbreviated, time: .shortened)) after conflict and integrity checks" + : pendingOperation != nil + ? "Wait for the current automation operation to finish" + : decision?.reason ?? "DevScope cannot verify that this backup may be restored safely" + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(backup.createdAt.formatted(date: .abbreviated, time: .shortened)) + Text("Verified backup · \(backup.checksum.prefix(12))") + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + } + Spacer() + Button("Restore") { perform(.restore(backup.id)) } + .disabled(!restoreAvailable) + .help(restoreHelp) + .accessibilityLabel("Restore backup from \(backup.createdAt.formatted(date: .abbreviated, time: .shortened))") + .accessibilityHint(restoreHelp) + } + } + } + } + } + + @ViewBuilder + private func resultEvidence(_ title: String, values: [String]) -> some View { + if !values.isEmpty { + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + ForEach(Array(values.enumerated()), id: \.offset) { _, value in + Text(value) + .font(.caption.monospaced()) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } + } + .padding(.top, 2) + } + } + + private func recoveryHandles(from result: AutomationOperationResult) -> [AutomationFileAuthorization] { + var handles: [AutomationFileAuthorization] = [] + for evidence in [result.fileMutationEvidence, result.rollbackFileMutationEvidence].compactMap({ $0 }) { + for handle in evidence.recoveryHandles where !handles.contains(where: { + $0.fileURL.standardizedFileURL == handle.fileURL.standardizedFileURL + }) { + handles.append(handle) + } + } + return handles + } + + private func copyRecoveryPath(_ url: URL) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(url.standardizedFileURL.path, forType: .string) + } + + private func inspectorGrid(_ entries: [(String, String)]) -> some View { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 72), spacing: 8)], spacing: 8) { + ForEach(Array(entries.enumerated()), id: \.offset) { _, entry in + VStack(alignment: .leading, spacing: 2) { + Text(entry.0).font(.caption2).foregroundStyle(.secondary) + Text(entry.1) + .font(.caption.weight(.semibold)) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + .background(.quaternary.opacity(0.25), in: RoundedRectangle(cornerRadius: 8)) + } + } + } + + private func field(_ label: String, _ value: String) -> some View { + VStack(alignment: .leading, spacing: 3) { + Text(label).font(.caption2.weight(.semibold)).foregroundStyle(.secondary) + Text(value).font(.callout).textSelection(.enabled).fixedSize(horizontal: false, vertical: true) + } + } +} + +private struct AutomationManagementBar: View { + let record: AutomationRecord + let decision: AutomationCapabilityDecision? + let backups: [AutomationBackup] + let pendingOperation: AutomationPendingOperation? + let operationResult: AutomationOperationResult? + let perform: (AutomationManagementAction) -> Void + let dismissResult: () -> Void + let saveExport: (AutomationExportArtifact) -> Void + let openLoginItemsSettings: () -> Void + + private var resolvedDecision: AutomationCapabilityDecision { + decision ?? AutomationCapabilityDecision( + capabilities: [], + reason: "DevScope has not verified source ownership and path safety yet." + ) + } + + private var actions: [AutomationManagementAction] { + AutomationManagementPresentation.actions( + decision: resolvedDecision, + backups: backups, + record: record + ) + } + + private var pinnedActions: [AutomationManagementAction] { + actions.filter { $0.placement == .pinned } + } + + private var overflowActions: [AutomationManagementAction] { + actions.filter { $0.placement == .overflow } + } + + private var operationIsPending: Bool { + pendingOperation != nil + } + + private var pendingHelp: String { + operationIsPending + ? "Wait for the current automation operation to finish" + : "" + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Label("Management", systemImage: "slider.horizontal.3") + .font(.subheadline.weight(.semibold)) + .accessibilityAddTraits(.isHeader) + + Spacer(minLength: 8) + + if let pendingOperation, pendingOperation.recordID == record.id { + HStack(spacing: 6) { + ProgressView().controlSize(.small) + Text("\(pendingOperation.operation.displayTitle) in progress") + .font(.caption.weight(.medium)) + .lineLimit(1) + } + .foregroundStyle(.secondary) + } else { + Label(record.state.displayTitle, systemImage: record.state.symbolName) + .font(.caption.weight(.semibold)) + .foregroundStyle(record.state.tint) + .lineLimit(1) + } + } + + if hasVisibleControl { + LazyVGrid( + columns: [GridItem(.adaptive(minimum: 112, maximum: 180), spacing: 8)], + spacing: 8 + ) { + ForEach(pinnedActions, id: \.self) { action in + managementButton(action) + } + + if !overflowActions.isEmpty { + Menu { + ForEach(overflowActions, id: \.self) { action in + let helpText = AutomationManagementPresentation.helpText( + for: action, + record: record + ) + Button(role: action.isDestructive ? .destructive : nil) { + perform(action) + } label: { + Label(action.title, systemImage: action.systemImage) + } + .accessibilityHint(helpText) + .help(helpText) + } + } label: { + Label("More Actions", systemImage: "ellipsis.circle") + .lineLimit(1) + .frame(maxWidth: .infinity) + } + .menuStyle(.button) + .tint(.accentColor) + .controlSize(.small) + .disabled(operationIsPending) + .accessibilityLabel("More automation actions") + .accessibilityHint( + operationIsPending + ? pendingHelp + : "Edit, duplicate, import, export, or remove this automation when available." + ) + .help(operationIsPending ? pendingHelp : "More automation actions") + } + + if record.sourceKind == .serviceManagement { + Button(action: openLoginItemsSettings) { + Label("System Settings", systemImage: "gearshape") + .lineLimit(1) + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .tint(.accentColor) + .controlSize(.small) + .disabled(operationIsPending) + .accessibilityHint( + "Opens Login Items & Extensions, where macOS manages this background item." + ) + .help("Open Login Items & Extensions in System Settings") + } + } + } + + if let reason = resolvedDecision.reason { + Text(reason) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + } else if !hasVisibleControl { + Text("No management operation is available for this source.") + .font(.caption) + .foregroundStyle(.secondary) + } + + if let operationResult { + operationStatus(operationResult) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(.bar) + .accessibilityIdentifier("automation-management-bar") + } + + private var hasVisibleControl: Bool { + !pinnedActions.isEmpty + || !overflowActions.isEmpty + || record.sourceKind == .serviceManagement + } + + private func managementButton(_ action: AutomationManagementAction) -> some View { + let helpText = AutomationManagementPresentation.helpText(for: action, record: record) + return Button(role: action.isDestructive ? .destructive : nil) { + perform(action) + } label: { + Label(action.title, systemImage: action.systemImage) + .lineLimit(1) + .minimumScaleFactor(0.8) + .frame(maxWidth: .infinity) + } + .buttonStyle(AutomationManagementActionButtonStyle(tint: actionTint(action))) + .controlSize(.small) + .disabled(operationIsPending) + .accessibilityHint(operationIsPending ? pendingHelp : helpText) + .help(operationIsPending ? pendingHelp : helpText) + } + + private func actionTint(_ action: AutomationManagementAction) -> Color { + switch action.emphasis { + case .neutral: .accentColor + case .positive: .green + case .caution: .orange + case .destructive: .red + } + } + + private func operationStatus(_ operationResult: AutomationOperationResult) -> some View { + let result = AutomationManagementPresentation.result(operationResult) + return HStack(spacing: 8) { + Label( + result.title, + systemImage: result.isFailure ? "exclamationmark.circle.fill" : "checkmark.circle.fill" + ) + .font(.caption.weight(.semibold)) + .foregroundStyle(result.isFailure ? Color.red : Color.green) + .lineLimit(1) + + Spacer(minLength: 4) + + if let artifact = operationResult.exportArtifact { + Button(artifact.isRedacted ? "Save Redacted" : "Save Unredacted") { + saveExport(artifact) + } + .buttonStyle(.bordered) + .controlSize(.small) + .accessibilityHint( + "Opens a save panel. DevScope does not place export contents in operation status or logs." + ) + .help("Save the completed automation export") + } + + Button(action: dismissResult) { + Image(systemName: "xmark.circle.fill") + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .accessibilityLabel("Dismiss operation result") + .help("Dismiss operation result") + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(.quaternary.opacity(0.3), in: RoundedRectangle(cornerRadius: 8)) + } +} + +private struct AutomationManagementActionButtonStyle: ButtonStyle { + @Environment(\.isEnabled) private var isEnabled + + let tint: Color + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.caption.weight(.medium)) + .foregroundStyle(tint) + .padding(.horizontal, 9) + .padding(.vertical, 5) + .background( + tint.opacity(configuration.isPressed ? 0.22 : 0.12), + in: RoundedRectangle(cornerRadius: 6, style: .continuous) + ) + .overlay { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(tint.opacity(isEnabled ? 0.34 : 0.16), lineWidth: 1) + } + .contentShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + .opacity(isEnabled ? 1 : 0.55) + } +} + +private struct AutomationInspectorSection: View { + let title: String + let systemImage: String + @ViewBuilder let content: Content + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Label(title, systemImage: systemImage).font(.subheadline.weight(.semibold)) + content + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} diff --git a/Sources/DevScope/Views/AutomationEditorView.swift b/Sources/DevScope/Views/AutomationEditorView.swift new file mode 100644 index 0000000..8521db6 --- /dev/null +++ b/Sources/DevScope/Views/AutomationEditorView.swift @@ -0,0 +1,202 @@ +import DevScopeCore +import Foundation +import SwiftUI + +struct AutomationEditorView: View { + enum Purpose { + case edit + case duplicate + } + + @Environment(\.dismiss) private var dismiss + let record: AutomationRecord + let purpose: Purpose + let duplicateDestination: (String) -> URL? + let apply: (AutomationEditPayload) -> Void + + @State private var label: String + @State private var executable: String + @State private var argumentsText: String + @State private var environmentText: String + @State private var scheduleText: String + @State private var workingDirectory: String + @State private var rawText = "" + @State private var usesRawRepresentation = false + + init( + record: AutomationRecord, + purpose: Purpose, + duplicateDestination: @escaping (String) -> URL?, + apply: @escaping (AutomationEditPayload) -> Void + ) { + self.record = record + self.purpose = purpose + self.duplicateDestination = duplicateDestination + self.apply = apply + _label = State(initialValue: purpose == .duplicate ? "\(record.label).copy" : record.label) + _executable = State(initialValue: record.executable ?? "") + _argumentsText = State(initialValue: record.arguments.joined(separator: "\n")) + _environmentText = State(initialValue: AutomationEditorPresentation.environmentText(for: record.environment)) + _scheduleText = State(initialValue: AutomationEditorPresentation.scheduleText(for: record.schedule)) + _workingDirectory = State(initialValue: record.workingDirectory ?? "") + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + VStack(alignment: .leading, spacing: 3) { + Text(purpose == .edit ? "Edit Automation" : "Duplicate Automation") + .font(.title2.weight(.semibold)) + Text("Changes are validated again by the transaction manager before any source is written.") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Toggle("Advanced Raw", isOn: $usesRawRepresentation) + .toggleStyle(.switch) + } + + if usesRawRepresentation { + HSplitView { + reviewedFields + .frame(minWidth: 360, idealWidth: 430) + VStack(alignment: .leading, spacing: 8) { + Text("Complete Raw Source") + .font(.headline) + Text("Paste the complete property list or current-user crontab. The reviewed fields must describe the same command, environment, schedule, and working directory.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + TextEditor(text: $rawText) + .font(.system(.callout, design: .monospaced)) + .frame(minWidth: 360, minHeight: 360) + .overlay { + RoundedRectangle(cornerRadius: 8).stroke(.quaternary) + } + .accessibilityLabel("Complete raw automation source") + } + .padding(12) + } + } else { + reviewedFields + } + + if let validationMessage { + Label(validationMessage, systemImage: "exclamationmark.circle") + .font(.caption) + .foregroundStyle(.orange) + } + + HStack { + Spacer() + Button("Cancel") { dismiss() } + .keyboardShortcut(.cancelAction) + Button(purpose == .edit ? "Apply" : "Create Copy") { + guard let payload else { return } + apply(payload) + dismiss() + } + .buttonStyle(.borderedProminent) + .disabled(payload == nil) + .keyboardShortcut(.defaultAction) + } + } + .padding(20) + .frame(minWidth: usesRawRepresentation ? 900 : 680, minHeight: 620) + } + + private var reviewedFields: some View { + Form { + TextField("Label", text: $label) + TextField("Executable", text: $executable) + TextField("Working Directory", text: $workingDirectory) + editorField("Arguments (one per line)", text: $argumentsText, minHeight: 80) + editorField("Environment (one KEY=VALUE per line)", text: $environmentText, minHeight: 80) + editorField( + "Schedule (run-at-load, keep-alive, interval SECONDS, calendar DESCRIPTION, cron EXPRESSION, or on-demand)", + text: $scheduleText, + minHeight: 80 + ) + } + .formStyle(.grouped) + } + + private func editorField( + _ title: String, + text: Binding, + minHeight: CGFloat + ) -> some View { + VStack(alignment: .leading, spacing: 5) { + Text(title) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + TextEditor(text: text) + .font(.system(.callout, design: .monospaced)) + .frame(minHeight: minHeight) + } + } + + private var payload: AutomationEditPayload? { + let trimmedLabel = label.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedExecutable = executable.trimmingCharacters(in: .whitespacesAndNewlines) + guard validationMessage == nil, + let environment, + let schedule else { return nil } + return AutomationEditPayload( + label: trimmedLabel, + executable: trimmedExecutable, + arguments: argumentsText.split(whereSeparator: \.isNewline).map(String.init), + environment: environment, + workingDirectory: workingDirectory.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty, + schedule: schedule, + rawRepresentation: rawData, + destination: resolvedDestination, + expectedDestinationChecksum: purpose == .duplicate ? nil : record.sourceChecksum + ) + } + + private var validationMessage: String? { + guard let environment else { + return "Environment entries must use unique non-empty KEY=VALUE lines." + } + guard let schedule else { + return "Enter at least one valid schedule line using the documented schedule syntax." + } + return AutomationEditorPresentation.validationMessage( + record: record, + purposeIsDuplicate: purpose == .duplicate, + label: label.trimmingCharacters(in: .whitespacesAndNewlines), + executable: executable.trimmingCharacters(in: .whitespacesAndNewlines), + arguments: argumentsText.split(whereSeparator: \.isNewline).map(String.init), + environment: environment, + schedule: schedule, + usesRawRepresentation: usesRawRepresentation, + rawData: rawData, + duplicateDestination: resolvedDestination, + workingDirectory: workingDirectory.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + ) + } + + private var environment: [String: String]? { + AutomationEditorPresentation.environment(from: environmentText) + } + + private var schedule: AutomationSchedule? { + AutomationEditorPresentation.schedule(from: scheduleText) + } + + private var rawData: Data? { + usesRawRepresentation ? Data(rawText.utf8) : nil + } + + private var resolvedDestination: URL? { + purpose == .duplicate + ? duplicateDestination(label.trimmingCharacters(in: .whitespacesAndNewlines)) + : record.sourceURL + } +} + +private extension String { + var nilIfEmpty: String? { isEmpty ? nil : self } +} diff --git a/Sources/DevScope/Views/AutomationIdentityView.swift b/Sources/DevScope/Views/AutomationIdentityView.swift new file mode 100644 index 0000000..873018c --- /dev/null +++ b/Sources/DevScope/Views/AutomationIdentityView.swift @@ -0,0 +1,62 @@ +import DevScopeCore +import SwiftUI + +struct AutomationIdentityView: View { + let record: AutomationRecord + let store: AutomationApplicationIconStore + let size: CGFloat + @State private var resolvedIcon: ResolvedIcon? + + var body: some View { + Group { + if let icon = displayedIcon { + Image(nsImage: icon) + .resizable() + .scaledToFit() + } else { + let fallback = AutomationVisualIdentity.fallback(for: record.kind) + ZStack { + RoundedRectangle(cornerRadius: size * 0.28, style: .continuous) + .fill(fallback.color.opacity(0.16)) + Image(systemName: fallback.symbolName) + .font(.system(size: size * 0.46, weight: .semibold)) + .foregroundStyle(fallback.color) + } + } + } + .frame(width: size, height: size) + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityLabel) + .task(id: cacheKey) { + let requestedKey = cacheKey + guard let icon = await store.resolve(record), !Task.isCancelled else { + return + } + resolvedIcon = ResolvedIcon(key: requestedKey, image: icon) + } + } + + private var accessibilityLabel: String { + if displayedIcon != nil { + return record.providerBundleIdentifier.map { "Owning application \($0)" } + ?? "Application icon for \(record.displayName)" + } + return AutomationVisualIdentity.fallback(for: record.kind).accessibilityTitle + } + + private var cacheKey: AutomationApplicationIconKey { + store.key(for: record) + } + + private var displayedIcon: NSImage? { + if resolvedIcon?.key == cacheKey { + return resolvedIcon?.image + } + return store.icon(for: record) + } +} + +private struct ResolvedIcon { + let key: AutomationApplicationIconKey + let image: NSImage +} diff --git a/Sources/DevScope/Views/AutomationRailView.swift b/Sources/DevScope/Views/AutomationRailView.swift new file mode 100644 index 0000000..bcb4451 --- /dev/null +++ b/Sources/DevScope/Views/AutomationRailView.swift @@ -0,0 +1,155 @@ +import DevScopeCore +import SwiftUI + +struct AutomationRailView: View { + @Binding var source: AutomationSourceFilter + @Binding var state: AutomationState? + @Binding var ownership: AutomationOwnershipFilter + let counts: [String: Int] + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + filterSection("Sources") { + filterButton( + "All", + symbol: "tray.full", + tint: DevScopePalette.accent, + key: "source.all", + selected: source == .all + ) { + source = .all + } + filterButton( + "LaunchAgents", + symbol: "bolt.horizontal", + tint: AutomationVisualIdentity.fallback(for: .launchAgent).color, + key: "source.launchd", + selected: source == .launchd + ) { + source = .launchd + } + filterButton( + "Login Items", + symbol: "person.crop.circle.badge.clock", + tint: AutomationVisualIdentity.fallback(for: .loginItem).color, + key: "source.login", + selected: source == .loginItems + ) { + source = .loginItems + } + filterButton( + "Scheduled", + symbol: "calendar.badge.clock", + tint: AutomationVisualIdentity.fallback(for: .cron).color, + key: "source.scheduled", + selected: source == .scheduled + ) { + source = .scheduled + } + } + + filterSection("State") { + filterButton( + "All", + symbol: "circle.grid.2x2", + tint: DevScopePalette.accent, + key: "state.all", + selected: state == nil + ) { state = nil } + ForEach(AutomationState.allCases, id: \.self) { value in + filterButton( + value.displayTitle, + symbol: value.symbolName, + tint: AutomationVisualIdentity.state(for: value).color, + key: "state.\(value.rawValue)", + selected: state == value + ) { + state = value + } + } + } + + filterSection("Ownership") { + filterButton( + "All", + symbol: "person.2", + tint: DevScopePalette.accent, + key: "owner.all", + selected: ownership == .all + ) { ownership = .all } + filterButton( + "User", + symbol: "person.crop.circle", + tint: .blue, + key: "owner.user", + selected: ownership == .user + ) { ownership = .user } + filterButton( + "Third Party", + symbol: "shippingbox", + tint: .purple, + key: "owner.third", + selected: ownership == .thirdParty + ) { ownership = .thirdParty } + filterButton( + "Apple System", + symbol: "apple.logo", + tint: .secondary, + key: "owner.apple", + selected: ownership == .appleSystem + ) { ownership = .appleSystem } + } + } + .padding(12) + } + .background(.ultraThinMaterial) + .accessibilityLabel("Automation filters") + } + + private func filterSection( + _ title: String, + @ViewBuilder content: () -> Content + ) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(title.uppercased()) + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + .padding(.horizontal, 8) + content() + } + } + + private func filterButton( + _ title: String, + symbol: String, + tint: Color, + key: String, + selected: Bool, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + HStack(spacing: 8) { + Image(systemName: symbol) + .frame(width: 18) + .foregroundStyle(tint) + Text(title) + .lineLimit(1) + Spacer(minLength: 4) + if let count = counts[key] { + Text("\(count)") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(selected ? DevScopePalette.accent.opacity(0.16) : .clear, in: RoundedRectangle(cornerRadius: 8)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityAddTraits(selected ? .isSelected : []) + .accessibilityHint("Filters the automation inventory by \(title).") + .help("Filter automations by \(title)") + } +} diff --git a/Sources/DevScope/Views/AutomationTableView.swift b/Sources/DevScope/Views/AutomationTableView.swift new file mode 100644 index 0000000..81ec656 --- /dev/null +++ b/Sources/DevScope/Views/AutomationTableView.swift @@ -0,0 +1,234 @@ +import DevScopeCore +import SwiftUI + +struct AutomationTableRow: Identifiable, Equatable, Sendable { + let record: AutomationRecord + let linkedProcessCount: Int + + var id: AutomationRecord.ID { record.id } +} + +enum AutomationTableMetrics { + static let automationColumnWidth: CGFloat = 220 + static let kindColumnWidth: CGFloat = 100 + static let triggerColumnWidth: CGFloat = 145 + static let stateColumnWidth: CGFloat = 90 + static let ownerColumnWidth: CGFloat = 90 + static let runsColumnWidth: CGFloat = 55 + static let horizontalPadding: CGFloat = 12 + static let contentWidth = automationColumnWidth + + kindColumnWidth + + triggerColumnWidth + + stateColumnWidth + + ownerColumnWidth + + runsColumnWidth + + (horizontalPadding * 2) +} + +struct AutomationTableView: View { + let rows: [AutomationTableRow] + let sourceHealthHasFailure: Bool + @Binding var selection: AutomationRecord.ID? + @Binding var sort: AutomationTableSortState + let iconStore: AutomationApplicationIconStore + @Environment(\.controlActiveState) private var controlActiveState + @FocusState private var isFocused: Bool + @State private var hoveredID: AutomationRecord.ID? + @State private var verticalScrollTargetID: AutomationRecord.ID? + + var body: some View { + ZStack { + ScrollView(.horizontal) { + VStack(spacing: 0) { + header + + ScrollView(.vertical) { + LazyVStack(spacing: 0) { + ForEach(Array(rows.enumerated()), id: \.element.id) { index, row in + rowButton(row, index: index) + .id(row.id) + } + } + .scrollTargetLayout() + } + .scrollPosition(id: $verticalScrollTargetID, anchor: .center) + } + .frame( + minWidth: AutomationTableMetrics.contentWidth, + maxWidth: .infinity, + alignment: .topLeading + ) + } + + if rows.isEmpty { + ContentUnavailableView( + sourceHealthHasFailure ? "Automation sources unavailable" : "No matching automations", + systemImage: sourceHealthHasFailure ? "exclamationmark.triangle" : "gearshape.2", + description: Text(sourceHealthHasFailure + ? "Review the source-specific status above. Healthy sources remain available." + : "Adjust search or filters. Idle definitions remain visible in this workspace.") + ) + } + } + .focusable() + .focused($isFocused) + .focusEffectDisabled() + .onKeyPress(.upArrow) { + move(.previous) + return .handled + } + .onKeyPress(.downArrow) { + move(.next) + return .handled + } + } + + private var header: some View { + HStack(spacing: 0) { + sortableHeader("Automation", column: .automation) + .frame(width: AutomationTableMetrics.automationColumnWidth, alignment: .leading) + sortableHeader("Kind", column: .kind) + .frame(width: AutomationTableMetrics.kindColumnWidth, alignment: .leading) + sortableHeader("Trigger", column: .trigger) + .frame(width: AutomationTableMetrics.triggerColumnWidth, alignment: .leading) + sortableHeader("State", column: .state) + .frame(width: AutomationTableMetrics.stateColumnWidth, alignment: .leading) + sortableHeader("Owner", column: .owner) + .frame(width: AutomationTableMetrics.ownerColumnWidth, alignment: .leading) + sortableHeader("Runs", column: .runs, contentAlignment: .trailing) + .frame(width: AutomationTableMetrics.runsColumnWidth, alignment: .trailing) + } + .padding(.horizontal, AutomationTableMetrics.horizontalPadding) + .padding(.vertical, 8) + .background(.ultraThinMaterial) + .overlay(alignment: .bottom) { Divider() } + } + + private func rowButton(_ row: AutomationTableRow, index: Int) -> some View { + Button { + selection = row.id + isFocused = true + } label: { + HStack(spacing: 0) { + AutomationCell(record: row.record, iconStore: iconStore) + .frame(width: AutomationTableMetrics.automationColumnWidth, alignment: .leading) + Text(row.record.kind.displayTitle) + .foregroundStyle(DevScopePalette.color(for: row.record.kind)) + .frame(width: AutomationTableMetrics.kindColumnWidth, alignment: .leading) + Text(row.record.schedule.summary) + .frame(width: AutomationTableMetrics.triggerColumnWidth, alignment: .leading) + AutomationStateLabelView(state: row.record.state) + .frame(width: AutomationTableMetrics.stateColumnWidth, alignment: .leading) + Text(row.record.ownership.displayTitle) + .frame(width: AutomationTableMetrics.ownerColumnWidth, alignment: .leading) + Text("\(row.linkedProcessCount)") + .monospacedDigit() + .frame(width: AutomationTableMetrics.runsColumnWidth, alignment: .trailing) + } + .font(.callout) + .padding(.horizontal, AutomationTableMetrics.horizontalPadding) + .padding(.vertical, 4) + .contentShape(Rectangle()) + .background(background(row.id, index: index)) + .overlay(alignment: .leading) { + if selection == row.id { + Rectangle().fill(DevScopePalette.accent).frame(width: 3) + } + } + .foregroundStyle(.primary) + } + .buttonStyle(.plain) + .onHover { hovering in + hoveredID = hovering ? row.id : (hoveredID == row.id ? nil : hoveredID) + } + .accessibilityElement(children: .combine) + .accessibilityLabel("\(row.record.displayName), \(row.record.kind.displayTitle), \(row.record.state.displayTitle)") + .accessibilityValue("\(row.linkedProcessCount) linked process\(row.linkedProcessCount == 1 ? "" : "es")") + .accessibilityAddTraits(selection == row.id ? .isSelected : []) + } + + private func background(_ id: AutomationRecord.ID, index: Int) -> Color { + if selection == id { + return DevScopePalette.accent.opacity(controlActiveState == .inactive ? 0.10 : 0.20) + } + if hoveredID == id { return Color.primary.opacity(0.055) } + return index.isMultiple(of: 2) ? Color.white.opacity(0.025) : .clear + } + + private func sortableHeader( + _ title: String, + column: AutomationTableSortColumn, + contentAlignment: Alignment = .leading + ) -> some View { + SortableTableHeader( + title: title, + column: column, + activeColumn: sort.column, + direction: sort.direction, + contentAlignment: contentAlignment, + activate: { sort.activate($0) } + ) + } + + private func move(_ direction: InterfaceSelectionDirection) { + let ids = rows.map(\.id) + guard !ids.isEmpty else { selection = nil; return } + let index = selection.flatMap(ids.firstIndex(of:)) + let nextIndex: Int + switch (direction, index) { + case (.previous, nil): nextIndex = ids.count - 1 + case (.next, nil): nextIndex = 0 + case (.previous, let index?): nextIndex = max(0, index - 1) + case (.next, let index?): nextIndex = min(ids.count - 1, index + 1) + } + let next = ids[nextIndex] + selection = next + verticalScrollTargetID = next + } +} + +private struct AutomationCell: View { + let record: AutomationRecord + let iconStore: AutomationApplicationIconStore + + var body: some View { + HStack(spacing: 10) { + AutomationIdentityView(record: record, store: iconStore, size: 28) + + VStack(alignment: .leading, spacing: 2) { + Text(record.displayName) + .font(.callout.weight(.semibold)) + .lineLimit(1) + .truncationMode(.tail) + Text(contextLabel) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + } + .padding(.vertical, 5) + } + + private var contextLabel: String { + guard let providerBundleIdentifier = record.providerBundleIdentifier else { + return record.label + } + return "\(record.label) · \(providerBundleIdentifier)" + } +} + +private struct AutomationStateLabelView: View { + let state: AutomationState + + var body: some View { + let presentation = AutomationStateLabelPresentation(state: state) + HStack(spacing: 4) { + Image(systemName: presentation.symbolName) + Text(presentation.title) + } + .foregroundStyle(presentation.colorToken.color) + .accessibilityElement(children: .ignore) + .accessibilityLabel(presentation.title) + } +} diff --git a/Sources/DevScope/Views/AutomationVisualIdentity.swift b/Sources/DevScope/Views/AutomationVisualIdentity.swift new file mode 100644 index 0000000..8a539c4 --- /dev/null +++ b/Sources/DevScope/Views/AutomationVisualIdentity.swift @@ -0,0 +1,108 @@ +import DevScopeCore + +enum AutomationColorToken: Equatable, Sendable { + case accent + case blue + case indigo + case cyan + case purple + case green + case orange + case red + case secondary +} + +struct AutomationVisualIdentity: Equatable, Sendable { + let symbolName: String + let colorToken: AutomationColorToken + let accessibilityTitle: String + + static func fallback(for kind: AutomationKind) -> Self { + switch kind { + case .launchAgent: + Self( + symbolName: "bolt.horizontal", + colorToken: .blue, + accessibilityTitle: "LaunchAgent" + ) + case .launchDaemon: + Self( + symbolName: "bolt.horizontal.circle", + colorToken: .indigo, + accessibilityTitle: "LaunchDaemon" + ) + case .loginItem: + Self( + symbolName: "person.crop.circle.badge.clock", + colorToken: .cyan, + accessibilityTitle: "Login item" + ) + case .backgroundItem: + Self( + symbolName: "person.crop.circle.badge.clock", + colorToken: .cyan, + accessibilityTitle: "Background item" + ) + case .cron: + Self( + symbolName: "calendar.badge.clock", + colorToken: .purple, + accessibilityTitle: "Scheduled automation" + ) + } + } + + static func state(for state: AutomationState) -> Self { + switch state { + case .running: + Self( + symbolName: "play.circle.fill", + colorToken: .green, + accessibilityTitle: "Running" + ) + case .idle: + Self( + symbolName: "pause.circle", + colorToken: .secondary, + accessibilityTitle: "Idle" + ) + case .disabled: + Self( + symbolName: "nosign", + colorToken: .secondary, + accessibilityTitle: "Disabled" + ) + case .needsApproval: + Self( + symbolName: "person.badge.clock", + colorToken: .orange, + accessibilityTitle: "Needs Approval" + ) + case .invalid: + Self( + symbolName: "exclamationmark.triangle", + colorToken: .red, + accessibilityTitle: "Invalid" + ) + case .unresolved: + Self( + symbolName: "questionmark.circle", + colorToken: .orange, + accessibilityTitle: "Unresolved" + ) + } + } +} + +struct AutomationStateLabelPresentation: Equatable, Sendable { + let title: String + let symbolName: String + let colorToken: AutomationColorToken + + init(state: AutomationState) { + let identity = AutomationVisualIdentity.state(for: state) + title = identity.accessibilityTitle + symbolName = identity.symbolName + colorToken = identity.colorToken + } +} diff --git a/Sources/DevScope/Views/AutomationWorkspaceLayoutPolicy.swift b/Sources/DevScope/Views/AutomationWorkspaceLayoutPolicy.swift new file mode 100644 index 0000000..d41f3a0 --- /dev/null +++ b/Sources/DevScope/Views/AutomationWorkspaceLayoutPolicy.swift @@ -0,0 +1,50 @@ +import CoreGraphics + +struct AutomationWorkspacePaneConstraints: Equatable, Sendable { + let railMinimum: CGFloat + let railPreferred: CGFloat + let tableMinimum: CGFloat + let tablePreferred: CGFloat + let detailMinimum: CGFloat + let detailPreferred: CGFloat + let tablePriority: Double + let detailPriority: Double + + var minimumTotal: CGFloat { + railMinimum + tableMinimum + detailMinimum + } +} + +enum AutomationWorkspaceLayoutPolicy { + static func constraints(availableWidth: CGFloat) -> AutomationWorkspacePaneConstraints { + let usableWidth = max(0, availableWidth - 18) + let minimumScale = min(1, usableWidth / 780) + let railMinimum = 150 * minimumScale + let tableMinimum = 360 * minimumScale + let detailMinimum = 270 * minimumScale + + let railPreferred: CGFloat + let tablePreferred: CGFloat + let detailPreferred: CGFloat + if usableWidth < 810 { + railPreferred = railMinimum + tablePreferred = tableMinimum + detailPreferred = detailMinimum + } else { + railPreferred = min(280, max(170, usableWidth * 0.18)) + detailPreferred = min(440, max(280, usableWidth * 0.30)) + tablePreferred = usableWidth - railPreferred - detailPreferred + } + + return AutomationWorkspacePaneConstraints( + railMinimum: railMinimum, + railPreferred: railPreferred, + tableMinimum: tableMinimum, + tablePreferred: tablePreferred, + detailMinimum: detailMinimum, + detailPreferred: detailPreferred, + tablePriority: 1, + detailPriority: 1 + ) + } +} diff --git a/Sources/DevScope/Views/AutomationWorkspaceView.swift b/Sources/DevScope/Views/AutomationWorkspaceView.swift new file mode 100644 index 0000000..9fc7df2 --- /dev/null +++ b/Sources/DevScope/Views/AutomationWorkspaceView.swift @@ -0,0 +1,461 @@ +import AppKit +import DevScopeCore +import ServiceManagement +import SwiftUI + +struct AutomationWorkspaceView: View { + @ObservedObject var store: AutomationStore + let inventory: AutomationInventorySnapshot + let processes: [ClassifiedDevProcess] + let linksByProcessID: [Int32: [AutomationProcessLink]] + @Binding var searchText: String + @Binding var sourceFilter: AutomationSourceFilter + @Binding var stateFilter: AutomationState? + @Binding var ownershipFilter: AutomationOwnershipFilter + let includeAppleSystemServices: Bool + let documentPanel: AutomationDocumentPanel + + @State private var pendingConfirmation: PendingAutomationConfirmation? + @State private var pendingEditor: PendingAutomationEditor? + @State private var pendingImport: PendingAutomationImport? + @State private var documentError: String? + @State private var automationSort = AutomationTableSortState() + @StateObject private var applicationIconStore = AutomationApplicationIconStore() + + private var filteredRecords: [AutomationRecord] { + AutomationPresentation.filtered( + inventory.records, + source: sourceFilter, + state: stateFilter, + ownership: ownershipFilter, + searchText: searchText, + includeAppleSystemServices: includeAppleSystemServices + ) + } + + private var eligibleRecords: [AutomationRecord] { + inventory.records.filter { includeAppleSystemServices || $0.ownership != .appleSystem } + } + + private var inventoryCount: AutomationInventoryCountPresentation { + AutomationPresentation.inventoryCount( + visibleCount: filteredRecords.count, + eligibleCount: eligibleRecords.count, + totalCount: inventory.records.count + ) + } + + private var selectedRecord: AutomationRecord? { + guard let id = store.selectedRecordID else { return nil } + return inventory.records.first { $0.id == id } + } + + private var tableRows: [AutomationTableRow] { + let linkCounts = Dictionary( + grouping: linksByProcessID.values.flatMap { $0 }, + by: \.recordID + ).mapValues(\.count) + return automationSort.sorted(filteredRecords.map { + AutomationTableRow(record: $0, linkedProcessCount: linkCounts[$0.id, default: 0]) + }) + } + + var body: some View { + VStack(spacing: 10) { + controlStrip + healthBar + GeometryReader { proxy in + let pane = AutomationWorkspaceLayoutPolicy.constraints(availableWidth: proxy.size.width) + + HSplitView { + AutomationRailView( + source: $sourceFilter, + state: $stateFilter, + ownership: $ownershipFilter, + counts: railCounts + ) + .frame(minWidth: pane.railMinimum, idealWidth: pane.railPreferred) + + AutomationTableView( + rows: tableRows, + sourceHealthHasFailure: hasSourceFailure, + selection: $store.selectedRecordID, + sort: $automationSort, + iconStore: applicationIconStore + ) + .frame(minWidth: pane.tableMinimum, idealWidth: pane.tablePreferred) + .layoutPriority(pane.tablePriority) + .transaction { $0.animation = nil } + + AutomationDetailView( + record: selectedRecord, + iconStore: applicationIconStore, + decision: selectedRecord.flatMap { store.capabilitySnapshot.decisionsByRecordID[$0.id] }, + linkedProcesses: selectedRecord.map(linkedProcesses(for:)) ?? [], + sourceHealth: selectedRecord.flatMap { inventory.health[$0.sourceKind] }, + backups: store.backups, + pendingOperation: store.pendingOperation, + operationResult: store.operationResultRecordID == selectedRecord?.id ? store.operationResult : nil, + perform: begin, + dismissResult: store.dismissOperationResult, + saveExport: saveExport, + openLoginItemsSettings: openLoginItemsSettings + ) + .frame(minWidth: pane.detailMinimum, idealWidth: pane.detailPreferred) + .layoutPriority(pane.detailPriority) + } + .softPanel(cornerRadius: 18) + } + } + .sheet(item: $pendingEditor) { pending in + AutomationEditorView( + record: pending.record, + purpose: pending.purpose, + duplicateDestination: { store.duplicateDestination(for: pending.record, label: $0) } + ) { payload in + let operation: AutomationOperation = pending.purpose == .edit ? .edit(payload) : .duplicate(payload) + store.perform(operation, record: pending.record, expectedChecksum: pending.record.sourceChecksum, linkedProcesses: linkedProcesses(for: pending.record)) + } + } + .sheet(item: $pendingConfirmation) { pending in + if let policy = AutomationManagementPresentation.confirmation(for: pending.action, record: pending.record, backups: pending.backups) { + AutomationConfirmationView(action: pending.action, record: pending.record, policy: policy) { + performConfirmed(pending.action, record: pending.record) + } + } + } + .sheet(item: $pendingImport) { pending in + AutomationImportPreviewView(pending: pending) { + store.perform( + .importRecord(AutomationImportPayload( + destination: pending.preview.destination, + data: pending.document.data, + expectedKind: pending.preview.expectedKind, + expectedDestinationChecksum: pending.record.sourceChecksum + )), + record: pending.record, + expectedChecksum: pending.record.sourceChecksum, + linkedProcesses: linkedProcesses(for: pending.record) + ) + } + } + .alert("Document operation failed", isPresented: Binding( + get: { documentError != nil }, + set: { if !$0 { documentError = nil } } + )) { + Button("OK", role: .cancel) { documentError = nil } + } message: { + Text(documentError ?? "The document could not be opened or saved.") + } + .onChange(of: filteredRecords.map(\.id)) { _, ids in + store.selectedRecordID = AutomationPresentation.resolvedSelection( + current: store.selectedRecordID, + visibleIDs: ids + ) + } + } + + private var controlStrip: some View { + HStack(spacing: 10) { + Image(systemName: "magnifyingglass").foregroundStyle(.tertiary) + TextField("Search label, command, path, schedule, or owning app", text: $searchText) + .textFieldStyle(.plain) + if !searchText.isEmpty { + Button { searchText = "" } label: { Image(systemName: "xmark.circle.fill") } + .buttonStyle(.plain) + .accessibilityLabel("Clear automation search") + .help("Clear automation search") + } + Spacer() + VStack(alignment: .trailing, spacing: 1) { + Text(inventoryCount.primaryText) + .font(.callout.monospacedDigit()) + if let contextText = inventoryCount.contextText { + Text(contextText) + .font(.caption2.monospacedDigit()) + } + } + .foregroundStyle(.secondary) + .accessibilityElement(children: .ignore) + .accessibilityLabel(inventoryCount.accessibilityLabel) + .help(inventoryCount.accessibilityLabel) + if store.isRefreshing { + ProgressView() + .controlSize(.small) + .accessibilityLabel("Refreshing automation inventory") + } + } + .padding(.horizontal, 12) + .padding(.vertical, 9) + .background(.bar, in: RoundedRectangle(cornerRadius: 12)) + } + + @ViewBuilder private var healthBar: some View { + let unhealthy = inventory.health.values.filter { $0.state != .healthy }.sorted { $0.kind.rawValue < $1.kind.rawValue } + if !unhealthy.isEmpty { + VStack(alignment: .leading, spacing: 4) { + ForEach(unhealthy, id: \.kind) { health in + Label("\(health.kind.displayTitle): \(health.message ?? health.state.displayTitle)", systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundStyle(health.state == .permissionRequired ? .orange : .secondary) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.orange.opacity(0.08), in: RoundedRectangle(cornerRadius: 10)) + } + } + + private var hasSourceFailure: Bool { + inventory.health.values.contains { $0.state != .healthy } + } + + private var railCounts: [String: Int] { + let records = eligibleRecords + var counts = ["source.all": records.count, "state.all": records.count, "owner.all": records.count] + counts["source.launchd"] = records.filter { $0.sourceKind == .launchAgent || $0.sourceKind == .launchDaemon }.count + counts["source.login"] = records.filter { $0.sourceKind == .serviceManagement || $0.sourceKind == .legacyLoginItem }.count + counts["source.scheduled"] = records.filter { $0.sourceKind == .crontab }.count + for state in AutomationState.allCases { counts["state.\(state.rawValue)"] = records.filter { $0.state == state }.count } + counts["owner.user"] = records.filter { $0.ownership == .user }.count + counts["owner.third"] = records.filter { $0.ownership == .thirdPartySystem || $0.ownership == .managed }.count + counts["owner.apple"] = records.filter { $0.ownership == .appleSystem }.count + return counts + } + + private func linkedProcesses(for record: AutomationRecord) -> [ClassifiedDevProcess] { + let identities = AutomationPresentation.uniquelyStrongLinkedProcessIdentities( + for: record.id, + linksByProcessID: linksByProcessID + ) + return processes.filter { identities.contains(ProcessIdentity(process: $0.process)) } + } + + private func begin(_ action: AutomationManagementAction) { + guard let record = selectedRecord else { return } + switch action { + case .edit: pendingEditor = PendingAutomationEditor(record: record, purpose: .edit) + case .duplicate: pendingEditor = PendingAutomationEditor(record: record, purpose: .duplicate) + case .importRecord: + beginImport(record) + default: + if AutomationManagementPresentation.confirmation(for: action, record: record, backups: store.backups) != nil { + pendingConfirmation = PendingAutomationConfirmation( + record: record, + action: action, + backups: store.backups.filter { $0.recordID == record.id } + ) + } else { + performConfirmed(action, record: record) + } + } + } + + private func performConfirmed(_ action: AutomationManagementAction, record: AutomationRecord) { + guard let operation = AutomationManagementPresentation.confirmedOperation(for: action, record: record) else { return } + store.perform(operation, record: record, expectedChecksum: record.sourceChecksum, linkedProcesses: linkedProcesses(for: record)) + } + + private func openLoginItemsSettings() { + if #available(macOS 13.0, *) { + SMAppService.openSystemSettingsLoginItems() + } else if let url = URL(string: "x-apple.systempreferences:com.apple.LoginItems-Settings.extension") { + NSWorkspace.shared.open(url) + } + } + + private func beginImport(_ record: AutomationRecord) { + do { + guard let document = try documentPanel.openImport() else { return } + guard let destination = store.importDestination( + for: record, + suggestedFilename: document.sourceURL.lastPathComponent + ) else { + documentError = "DevScope could not resolve an approved import destination for this automation source." + return + } + pendingImport = PendingAutomationImport( + document: document, + record: record, + preview: AutomationImportPresentation( + data: document.data, + expectedRecord: record, + destination: destination + ) + ) + } catch { + documentError = ProcessPresentation.redactedCommand(error.localizedDescription) + } + } + + private func saveExport(_ artifact: AutomationExportArtifact) { + do { + _ = try documentPanel.save(artifact) + } catch { + documentError = "The export could not be saved. \(ProcessPresentation.redactedCommand(error.localizedDescription))" + } + } + +} + +private struct PendingAutomationConfirmation: Identifiable { + let record: AutomationRecord + let action: AutomationManagementAction + let backups: [AutomationBackup] + var id: String { "\(record.id.rawValue)-\(action.id)" } +} + +private struct PendingAutomationEditor: Identifiable { + let record: AutomationRecord + let purpose: AutomationEditorView.Purpose + var id: String { "\(record.id.rawValue)-\(purpose.id)" } +} + +private struct PendingAutomationImport: Identifiable { + let document: AutomationImportedDocument + let record: AutomationRecord + let preview: AutomationImportPresentation + var id: UUID { document.id } +} + +private struct AutomationImportPreviewView: View { + @Environment(\.dismiss) private var dismiss + let pending: PendingAutomationImport + let apply: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("Preview Import").font(.title2.weight(.semibold)) + LabeledContent("File", value: pending.document.sourceURL.lastPathComponent) + LabeledContent("Expected Kind", value: pending.preview.expectedKind.displayTitle) + LabeledContent("Detected Content", value: pending.preview.summary) + LabeledContent("Replaces", value: pending.preview.targetLabel) + LabeledContent("Target Ownership", value: pending.preview.targetOwnership) + VStack(alignment: .leading, spacing: 4) { + Text("Approved Destination").font(.caption.weight(.semibold)).foregroundStyle(.secondary) + Text(pending.preview.destination.path) + .font(.callout.monospaced()) + .textSelection(.enabled) + } + if let message = pending.preview.validationMessage { + Label(message, systemImage: "exclamationmark.triangle") + .foregroundStyle(.orange) + } else { + Label(pending.preview.consequence, systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundStyle(.orange) + Label("The transaction manager will revalidate kind, destination, ownership, and checksum before replacing the selected definition.", systemImage: "checkmark.shield") + .font(.caption) + .foregroundStyle(.secondary) + } + HStack { + Spacer() + Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction) + Button("Replace Definition", role: .destructive) { + apply() + dismiss() + } + .disabled(!pending.preview.canApply) + .help(pending.preview.canApply + ? "Replace the selected checksum-bound automation definition after manager validation" + : pending.preview.validationMessage ?? "The import cannot be applied") + .accessibilityHint("Replaces the selected automation source; it does not create an unrelated new definition.") + } + } + .padding(20) + .frame(width: 560) + } +} + +extension AutomationEditorView.Purpose: Identifiable { + var id: String { self == .edit ? "edit" : "duplicate" } +} + +extension AutomationManagementAction: Identifiable { + public var id: String { + switch self { + case .restore(let id): "restore-\(id.rawValue.uuidString)" + default: title + } + } +} + +extension AutomationKind { + var displayTitle: String { + switch self { + case .launchAgent: "LaunchAgent" + case .launchDaemon: "LaunchDaemon" + case .loginItem: "Login Item" + case .backgroundItem: "Background Item" + case .cron: "Scheduled" + } + } + var symbolName: String { + AutomationVisualIdentity.fallback(for: self).symbolName + } +} + +extension AutomationSourceKind { + var displayTitle: String { + switch self { + case .launchAgent: "User LaunchAgents" + case .launchDaemon: "LaunchDaemons" + case .serviceManagement: "Background Items" + case .legacyLoginItem: "Login Items" + case .crontab: "Current-user crontab" + } + } +} + +extension AutomationState { + var displayTitle: String { + AutomationStateLabelPresentation(state: self).title + } + var symbolName: String { + AutomationVisualIdentity.state(for: self).symbolName + } + var tint: Color { + AutomationVisualIdentity.state(for: self).color + } +} + +extension AutomationOwnership { + var displayTitle: String { + switch self { + case .user: "User" + case .thirdPartySystem: "Third Party" + case .appleSystem: "Apple System" + case .managed: "Managed" + } + } +} + +extension AutomationSourceHealthState { + var displayTitle: String { + switch self { + case .healthy: "Healthy" + case .partial: "Partial" + case .failed: "Failed" + case .permissionRequired: "Permission required" + } + } +} + +extension AutomationOperation { + var displayTitle: String { + switch self { + case .startNow, .confirmedRunToCompletion: "Start Now" + case .stopCurrentRun: "Stop Current Run" + case .enable: "Enable" + case .disable: "Disable" + case .disableAndStop: "Disable and Stop" + case .edit: "Edit" + case .duplicate: "Duplicate" + case .importRecord: "Import" + case .exportRecord: "Export" + case .remove: "Remove" + case .restore: "Restore" + } + } +} diff --git a/Sources/DevScope/Views/ContentView.swift b/Sources/DevScope/Views/ContentView.swift new file mode 100644 index 0000000..c9c6cee --- /dev/null +++ b/Sources/DevScope/Views/ContentView.swift @@ -0,0 +1,1873 @@ +import AppKit +import DevScopeCore +import SwiftUI + +struct ContentView: View { + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @ObservedObject private var store: ProcessStore + @ObservedObject private var automationStore: AutomationStore + private let automationNotifier: AutomationNotifier + @State private var searchText = "" + @State private var automationSearchText = "" + @State private var automationSourceFilter = AutomationSourceFilter.all + @State private var automationStateFilter: AutomationState? + @State private var automationOwnershipFilter = AutomationOwnershipFilter.all + @State private var activityTypeFilter = AutomationActivityTypeFilter.all + @State private var selectedProcessID: Int32? + @State private var retainedSelectedProcess: ClassifiedDevProcess? + @State private var pendingKillAction: KillAction? + @State private var isSponsorPresented = false + @State private var processSort = ProcessTableSortState() + @State private var lastCopiedPayload: PersistedCopyPayload? + @State private var expandedApplicationFamilyIDs: Set = [] + @AppStorage(DevScopeSettingsKey.useAppleNaming) private var useAppleNaming = true + @AppStorage(DevScopeSettingsKey.showProcessGraphs) private var showProcessGraphs = true + @AppStorage(DevScopeSettingsKey.showLiveNotch) private var showLiveNotch = true + @AppStorage(DevScopeSettingsKey.selectedCategoryID) private var selectedCategoryID = + ProcessPresentation.allCategoryID + @AppStorage(DevScopeSettingsKey.activityScope) private var activityScopeRawValue = + ProcessActivityScope.applications.rawValue + @AppStorage(DevScopeSettingsKey.isRailCollapsed) private var isRailCollapsed = false + @AppStorage(DevScopeSettingsKey.isLiveActivityExpanded) private var isLiveActivityExpanded = false + @AppStorage(DevScopeSettingsKey.liveActivityPreferredHeight) + private var liveActivityPreferredHeight = LiveActivityLayoutPolicy.defaultHeight + @AppStorage(DevScopeSettingsKey.selectedWorkspaceMode) private var workspaceModeRaw = + DevScopeWorkspaceMode.processes.rawValue + @AppStorage(DevScopeSettingsKey.includeAppleSystemServices) private var includeAppleSystemServices = false + @AppStorage("favoriteProcessKeys") private var favoriteProcessKeysRaw = "" + @AppStorage("watchedProcessKeys") private var watchedProcessKeysRaw = "" + private let persistentCopyStore = PersistentCopyStore() + private let automationDocumentPanel = AutomationDocumentPanel() + + init( + store: ProcessStore, + automationStore: AutomationStore, + automationNotifier: AutomationNotifier + ) { + self.store = store + self.automationStore = automationStore + self.automationNotifier = automationNotifier + } + + private var allProcesses: [ClassifiedDevProcess] { + store.classifiedProcesses + } + + private var activityScope: ProcessActivityScope { + ProcessActivityScope(rawValue: activityScopeRawValue) ?? .applications + } + + private var activityScopeBinding: Binding { + Binding( + get: { activityScope }, + set: { activityScopeRawValue = $0.rawValue } + ) + } + + private var activityFilteredProcesses: [ClassifiedDevProcess] { + allProcesses.filter(matchesActivityType) + } + + private var filteredProcesses: [ClassifiedDevProcess] { + let filtered: [ClassifiedDevProcess] + if let selectedWorkflow { + let processIDs = Set(selectedWorkflow.processIDs) + filtered = ProcessPresentation.filtered( + allProcesses.filter { processIDs.contains($0.process.pid) }, + categoryID: ProcessPresentation.allCategoryID, + searchText: searchText, + favoriteKeys: favoriteProcessKeys, + watchedKeys: watchedProcessKeys + ) + } else { + filtered = ProcessPresentation.filtered( + allProcesses, + categoryID: selectedCategoryID, + searchText: searchText, + favoriteKeys: favoriteProcessKeys, + watchedKeys: watchedProcessKeys + ) + } + return filtered.filter(matchesActivityType) + } + + private var intelligenceWorkflows: [DevWorkflow] { + store.workflows + } + + private var processRows: [ProcessTableRow] { + filteredProcesses + .enumerated() + .map { offset, item in + return ProcessTableRow( + item: item, + title: store.displayName(for: item), + isFavorite: ProcessPresentation.isSaved(item, in: favoriteProcessKeys), + isWatched: ProcessPresentation.isSaved(item, in: watchedProcessKeys), + automationBadges: AutomationPresentation.badges( + isAutomated: store.liveSnapshot.automationLinksByProcessID[item.process.pid] != nil, + isLongRunning: store.liveSnapshot.longRunningProcessIDs.contains(item.process.pid), + elapsed: item.process.resourceUsage?.elapsedTime ?? "-" + ), + snapshotOrder: offset + ) + } + .sorted(using: stableSortOrder) + } + + private var applicationFamilies: [ProcessApplicationFamily] { + ProcessScopePresentation.applicationFamilies( + for: activityFilteredProcesses, + searchText: searchText + ) + } + + private var hierarchyNodes: [ProcessHierarchyNode] { + ProcessScopePresentation.hierarchy( + for: activityFilteredProcesses, + searchText: searchText + ) + } + + private var activeVisibleProcesses: [ClassifiedDevProcess] { + switch activityScope { + case .applications: + processes(withIDs: applicationFamilies.flatMap(\.processIDs)) + case .processes, .workflows: + filteredProcesses + case .hierarchy: + processes(withIDs: ProcessScopePresentation.flattened(hierarchyNodes).map(\.id)) + } + } + + private var stableSortOrder: [KeyPathComparator] { + processSort.comparators + [ + KeyPathComparator(\ProcessTableRow.stableOrderKey, order: .forward) + ] + } + + private var categories: [DevProcessCategory] { + ProcessPresentation.categories( + for: allProcesses, + favoriteKeys: favoriteProcessKeys, + watchedKeys: watchedProcessKeys + ) + } + + private var selectedCategoryTitle: String { + if let selectedWorkflow { + return selectedWorkflow.title + } + + return categories.first { $0.id == selectedCategoryID }?.title ?? "All" + } + + private var activeScopeTitle: String { + switch activityScope { + case .applications, .hierarchy: + activityScope.title + case .processes, .workflows: + selectedCategoryTitle + } + } + + private var selectedWorkflow: DevWorkflow? { + intelligenceWorkflows.first { $0.id == selectedCategoryID } + } + + private var processEmptyTitle: String { + if store.statusMessage.localizedCaseInsensitiveContains("operation not permitted") + || store.statusMessage.localizedCaseInsensitiveContains("blocked by macOS") + || store.statusMessage.localizedCaseInsensitiveContains("native process") + { + return "Process scanning blocked" + } + + return "No matching running items" + } + + private var processEmptyDescription: String { + if processEmptyTitle == "Process scanning blocked" { + return + "This sandboxed build cannot inspect local process metadata. Open Settings > Access for permissions, diagnostics, and distribution guidance." + } + + return + "Refresh after opening an app, starting a service, running a script, or launching a build watcher." + } + + private var selectedProcess: ClassifiedDevProcess? { + guard let selectedProcessID else { + return nil + } + + return allProcesses.first { $0.process.pid == selectedProcessID } + } + + private var selectedProcessExistsInSnapshot: Bool { + guard let selectedProcessID else { + return false + } + + return allProcesses.contains { $0.process.pid == selectedProcessID } + } + + private var selectedProcessForDetail: ClassifiedDevProcess? { + if let selectedProcess { + return selectedProcess + } + + return selectedProcessExistsInSnapshot ? nil : retainedSelectedProcess + } + + private var selectedProcessIsEnded: Bool { + guard let selectedProcessID else { + return false + } + + return !store.isProcessLive(pid: selectedProcessID) + } + + private var selectedProcessIsFavorite: Bool { + guard let selectedProcessForDetail else { + return false + } + return ProcessPresentation.isSaved(selectedProcessForDetail, in: favoriteProcessKeys) + } + + private var selectedProcessIsWatched: Bool { + guard let selectedProcessForDetail else { + return false + } + return ProcessPresentation.isSaved(selectedProcessForDetail, in: watchedProcessKeys) + } + + private var selectedProcessMetricHistory: [DevProcessMetricSample] { + guard let selectedProcess = selectedProcessForDetail else { + return [] + } + + return store.metricHistory(for: selectedProcess.process.pid) + } + + private var selectedProcessFamilySummary: ProcessFamilySummary? { + guard let selectedProcess = selectedProcessForDetail else { + return nil + } + + return store.familySummary(for: selectedProcess.process) + } + + private var selectedProcessWorkflow: DevWorkflow? { + guard let selectedProcessForDetail else { + return nil + } + + return ProcessIntelligence.workflow( + containing: selectedProcessForDetail, in: intelligenceWorkflows) + } + + private var selectedProcessActionDecision: ProcessActionDecision? { + selectedProcessForDetail.map(store.actionDecision(for:)) + } + + private var selectedProcessInsight: DevProcessInsight? { + guard let selectedProcessForDetail else { + return nil + } + + return ProcessIntelligence.insight( + for: selectedProcessForDetail, + workflow: selectedProcessWorkflow, + familySummary: selectedProcessFamilySummary, + metricHistory: selectedProcessMetricHistory, + actionDecision: selectedProcessActionDecision ?? .allowed + ) + } + + private var selectedWorkflowNote: String? { + guard let selectedProcessWorkflow else { + return nil + } + + return store.workflowNotes[selectedProcessWorkflow.id]?.text + } + + private var dashboardStats: ProcessDashboardStats { + ProcessPresentation.dashboardStats( + visibleItems: activeVisibleProcesses, + totalItems: allProcesses, + dashboardMetricHistory: store.dashboardMetricHistory + ) + } + + var body: some View { + DevScopeGlassContainer { + ZStack(alignment: .top) { + mainWorkspace + if showLiveNotch { + LiveNotchPanelPresenter( + stats: dashboardStats, + metricHistory: store.dashboardMetricHistory, + selectedScope: activeScopeTitle, + isRefreshing: store.isRefreshing, + lastRefresh: store.lastRefresh, + refreshAction: { store.refresh() }, + displayName: store.displayName(for:) + ) + .frame(width: 0, height: 0) + .zIndex(3) + } + } + .padding(12) + } + .background(DevScopeBackground()) + .overlay(alignment: .topTrailing) { + if let feedback = store.actionFeedback { + ActionFeedbackBanner(feedback: feedback) + .padding(.top, 12) + .padding(.trailing, 14) + .transition(feedbackTransition) + } + } + .toolbar { + ToolbarItemGroup(placement: .primaryAction) { + if workspaceMode == .processes { + Button(action: exportVisibleProcesses) { Image(systemName: "tablecells") } + .accessibilityLabel("Copy visible rows") + .accessibilityHint("Copies the currently visible running items as redacted TSV.") + .help("Copy visible rows as redacted TSV") + + Button(action: restoreLastCopiedPayload) { Image(systemName: "clipboard") } + .disabled(lastCopiedPayload == nil) + .accessibilityLabel("Restore last copy") + .accessibilityHint("Restores the last command or export saved by DevScope to the clipboard.") + .help(lastCopiedPayload.map { "Restore last copy: \($0.label)" } ?? "No saved copy") + + Button(action: { store.refresh() }) { Image(systemName: "arrow.clockwise") } + .accessibilityLabel("Refresh running items") + .accessibilityHint("Scans local apps, services, agents, and processes now.") + .help("Refresh running items") + .keyboardShortcut("r", modifiers: [.command]) + } else { + Button(action: { automationStore.refresh() }) { Image(systemName: "arrow.clockwise") } + .disabled(automationStore.isRefreshing) + .accessibilityLabel("Refresh automations") + .accessibilityHint("Refreshes configured automation sources without changing them.") + .help("Refresh automation inventory") + .keyboardShortcut("r", modifiers: [.command]) + } + } + } + .onChange(of: categories) { _, categories in + if !categories.contains(where: { $0.id == selectedCategoryID }) + && !intelligenceWorkflows.contains(where: { $0.id == selectedCategoryID }) + { + selectedCategoryID = ProcessPresentation.allCategoryID + } + } + .onChange(of: intelligenceWorkflows) { _, workflows in + if !categories.contains(where: { $0.id == selectedCategoryID }) + && !workflows.contains(where: { $0.id == selectedCategoryID }) + { + selectedCategoryID = ProcessPresentation.allCategoryID + } + } + .onChange(of: activityScopeRawValue) { _, _ in + alignSelectionWithActivityScope() + } + .onChange(of: selectedProcessID) { _, _ in + scheduleRememberSelectedProcess() + } + .onChange(of: allProcesses) { _, _ in + if selectedProcessID != nil { + scheduleRememberSelectedProcess() + } + } + .onChange(of: useAppleNaming) { _, value in + store.usesAppleNaming = value + } + .onChange(of: store.actionFeedback) { _, feedback in + guard let feedback else { + return + } + announceFeedback(feedback) + } + .task { + store.usesAppleNaming = useAppleNaming + migrateLegacySavedIdentityKeys() + loadLastCopiedPayload() + store.startRealtimeUpdates() + } + .onDisappear { + store.stopRealtimeUpdates() + } + .onReceive(NotificationCenter.default.publisher(for: .devScopeRefreshRequested)) { _ in + if workspaceMode == .processes { + store.refresh() + } else { + automationStore.refresh() + } + } + .onReceive(NotificationCenter.default.publisher(for: .devScopeRestoreLastCopyRequested)) { _ in + restoreLastCopiedPayload() + } + .sheet(isPresented: $isSponsorPresented) { + SponsorSheetView() + } + .confirmationDialog( + pendingKillAction?.title ?? "Terminate process?", + isPresented: pendingKillBinding, + titleVisibility: .visible + ) { + if let action = pendingKillAction { + Button(action.buttonTitle, role: .destructive) { + switch action { + case .single(let process): + store.terminate(process) + case .tree(let process): + store.terminateTree(root: process) + case .forceSingle(let process): + store.forceTerminate(process) + case .forceTree(let process): + store.forceTerminateTree(root: process) + } + pendingKillAction = nil + } + } + + Button("Cancel", role: .cancel) { + pendingKillAction = nil + } + } message: { + if let action = pendingKillAction { + let descendants = store.familySummary(for: action.process.process).descendantCount + Text(action.consequence(descendantCount: descendants)) + } + } + .modifier(AutomationIntegrationModifier( + processStore: store, + automationStore: automationStore, + automationNotifier: automationNotifier + )) + } + + private var mainWorkspace: some View { + VStack(spacing: 10) { + Picker("Workspace", selection: workspaceModeBinding) { + Text("Processes").tag(DevScopeWorkspaceMode.processes) + Text("Automations").tag(DevScopeWorkspaceMode.automations) + } + .pickerStyle(.segmented) + .frame(maxWidth: 320) + .accessibilityHint("Switches between live running processes and configured automations without clearing either selection.") + + ZStack { + if workspaceMode == .processes { + processModeWorkspace + } + + AutomationWorkspaceView( + store: automationStore, + inventory: store.liveSnapshot.automationInventory, + processes: store.classifiedProcesses, + linksByProcessID: store.liveSnapshot.allAutomationLinksByProcessID, + searchText: $automationSearchText, + sourceFilter: $automationSourceFilter, + stateFilter: $automationStateFilter, + ownershipFilter: $automationOwnershipFilter, + includeAppleSystemServices: includeAppleSystemServices, + documentPanel: automationDocumentPanel + ) + .workspaceLayer(isActive: workspaceMode == .automations) + } + } + } + + private var processModeWorkspace: some View { + HSplitView { + CategoryRail( + selectedCategoryID: $selectedCategoryID, + activityScope: activityScopeBinding, + workflows: intelligenceWorkflows, + categories: categories, + isCollapsed: $isRailCollapsed, + isEnhancingWorkflows: store.isEnhancingWorkflows, + sponsorAction: { isSponsorPresented = true } + ) + .frame( + minWidth: isRailCollapsed ? 58 : 190, + idealWidth: isRailCollapsed ? 64 : 225, + maxWidth: isRailCollapsed ? 74 : 320 + ) + + VStack(spacing: 12) { + ControlStrip( + searchText: $searchText, + activityScope: activityScopeBinding, + selectedCategoryTitle: activeScopeTitle, + visibleProcessCount: activeVisibleProcesses.count, + totalProcessCount: allProcesses.count, + applicationCount: applicationFamilies.count, + activityTypeFilter: $activityTypeFilter + ) + selectedProcessCommandBar + monitoringWorkspace + } + .frame(minWidth: 620) + } + } + + @ViewBuilder private var selectedProcessCommandBar: some View { + if let selectedProcessForDetail { + ProcessCommandBar( + process: selectedProcessForDetail, + displayName: store.displayName(for: selectedProcessForDetail), + isEnded: selectedProcessIsEnded, + actionDecision: selectedProcessActionDecision ?? .allowed, + isFavorite: selectedProcessIsFavorite, + isWatched: selectedProcessIsWatched, + favoriteAction: toggleSelectedFavorite, + watchAction: toggleSelectedWatch, + exportAction: exportSelectedProcess, + copyCommandAction: copySelectedCommand, + openFolderAction: openSelectedFolder, + killAction: { pendingKillAction = .single(selectedProcessForDetail) }, + killTreeAction: { pendingKillAction = .tree(selectedProcessForDetail) }, + forceKillAction: { pendingKillAction = .forceSingle(selectedProcessForDetail) }, + forceKillTreeAction: { pendingKillAction = .forceTree(selectedProcessForDetail) } + ) + } + } + + @ViewBuilder + private var monitoringWorkspace: some View { + if isLiveActivityExpanded { + GeometryReader { proxy in + let resolvedHeight = LiveActivityLayoutPolicy.resolvedHeight( + preferredHeight: liveActivityPreferredHeight, + workspaceHeight: proxy.size.height + ) + + VSplitView { + processWorkspace + + liveActivityDock + .frame( + minHeight: LiveActivityLayoutPolicy.minimumHeight, + idealHeight: resolvedHeight, + maxHeight: LiveActivityLayoutPolicy.maximumHeight + ) + .background { + GeometryReader { dockProxy in + Color.clear.preference( + key: LiveActivityHeightPreferenceKey.self, + value: dockProxy.size.height + ) + } + } + } + .onPreferenceChange(LiveActivityHeightPreferenceKey.self) { measuredHeight in + let updatedHeight = LiveActivityLayoutPolicy.updatedPreferredHeight( + currentPreferredHeight: liveActivityPreferredHeight, + measuredHeight: measuredHeight, + workspaceHeight: proxy.size.height + ) + guard updatedHeight != liveActivityPreferredHeight else { return } + liveActivityPreferredHeight = updatedHeight + } + } + } else { + VStack(spacing: 12) { + processWorkspace + .layoutPriority(1) + + liveActivityDock + .frame(height: 60) + } + } + } + + private var processWorkspace: some View { + HSplitView { + activityBrowser + .frame(minWidth: 460) + .transaction { transaction in + transaction.animation = nil + } + + ProcessDetailView( + process: selectedProcessForDetail, + displayName: selectedProcessForDetail.map(store.displayName(for:)), + metricHistory: selectedProcessMetricHistory, + familySummary: selectedProcessFamilySummary, + showsMetricHistory: showProcessGraphs, + isEnded: selectedProcessIsEnded, + isFavorite: selectedProcessIsFavorite, + isWatched: selectedProcessIsWatched, + workflow: selectedProcessWorkflow, + processInsight: selectedProcessInsight, + workflowNote: selectedWorkflowNote, + automationLink: selectedProcessForDetail.flatMap { store.liveSnapshot.automationLinksByProcessID[$0.process.pid] }, + automationRecord: selectedProcessForDetail + .flatMap { store.liveSnapshot.automationLinksByProcessID[$0.process.pid] } + .flatMap { link in store.liveSnapshot.automationInventory.records.first { $0.id == link.recordID } }, + showInAutomations: { recordID in + automationStore.selectedRecordID = recordID + workspaceModeRaw = DevScopeWorkspaceMode.automations.rawValue + } + ) + .frame(minWidth: 300, idealWidth: 360) + } + .softPanel(cornerRadius: 18) + .frame(minHeight: 360) + } + + @ViewBuilder + private var activityBrowser: some View { + switch activityScope { + case .applications: + ApplicationFamilyListView( + families: applicationFamilies, + selection: $selectedProcessID, + expandedFamilyIDs: $expandedApplicationFamilyIDs, + displayName: store.displayName(for:) + ) + case .processes, .workflows: + ProcessTableView( + rows: processRows, + emptyTitle: processEmptyTitle, + emptyDescription: processEmptyDescription, + selection: $selectedProcessID, + sort: $processSort, + copyCommand: copyCommand + ) + case .hierarchy: + ProcessHierarchyView( + nodes: hierarchyNodes, + selection: $selectedProcessID, + displayName: store.displayName(for:) + ) + } + } + + private var liveActivityDock: some View { + LiveActivitiesDockView( + stats: dashboardStats, + metricHistory: store.dashboardMetricHistory, + isRefreshing: store.isRefreshing, + lastRefresh: store.lastRefresh, + isExpanded: $isLiveActivityExpanded + ) + } + + private var favoriteProcessKeys: Set { + decodeKeys(favoriteProcessKeysRaw) + } + + private var watchedProcessKeys: Set { + decodeKeys(watchedProcessKeysRaw) + } + + private func rememberSelectedProcess() { + let resolution = ProcessSelectionPolicy.reconcile( + selectedProcessID: selectedProcessID, + retainedProcess: retainedSelectedProcess, + currentProcesses: allProcesses + ) + if selectedProcessID != resolution.selectedProcessID { + selectedProcessID = resolution.selectedProcessID + } + if retainedSelectedProcess != resolution.retainedProcess { + retainedSelectedProcess = resolution.retainedProcess + } + } + + private func scheduleRememberSelectedProcess() { + DispatchQueue.main.async { + rememberSelectedProcess() + } + } + + private func toggleSelectedFavorite() { + guard let selectedProcessForDetail else { + return + } + + let willRemove = ProcessPresentation.isSaved(selectedProcessForDetail, in: favoriteProcessKeys) + favoriteProcessKeysRaw = toggledKeys(for: selectedProcessForDetail, in: favoriteProcessKeys) + store.presentFeedback( + title: willRemove ? "Favorite removed" : "Favorite added", + detail: store.displayName(for: selectedProcessForDetail), + symbolName: willRemove ? "star.slash" : "star.fill", + kind: .success + ) + } + + private func toggleSelectedWatch() { + guard let selectedProcessForDetail else { + return + } + + let willRemove = ProcessPresentation.isSaved(selectedProcessForDetail, in: watchedProcessKeys) + watchedProcessKeysRaw = toggledKeys(for: selectedProcessForDetail, in: watchedProcessKeys) + store.presentFeedback( + title: willRemove ? "Watch removed" : "Watch added", + detail: store.displayName(for: selectedProcessForDetail), + symbolName: willRemove ? "eye.slash" : "eye.fill", + kind: .success + ) + } + + private func exportSelectedProcess() { + guard let selectedProcessForDetail else { + return + } + + copyToPasteboard( + ProcessPresentation.exportRows([selectedProcessForDetail]), label: "Process export") + store.statusMessage = "Copied process export" + store.presentFeedback( + title: "Process export copied", + detail: store.displayName(for: selectedProcessForDetail), + symbolName: "square.and.arrow.up", + kind: .success + ) + } + + private func exportVisibleProcesses() { + copyToPasteboard( + ProcessPresentation.exportRows(activeVisibleProcesses), label: "Visible rows export") + store.statusMessage = "Copied \(activeVisibleProcesses.count) OS processes" + store.presentFeedback( + title: "Visible rows copied", + detail: + "\(activeVisibleProcesses.count) redacted OS process row\(activeVisibleProcesses.count == 1 ? "" : "s")", + symbolName: "square.and.arrow.up", + kind: .success + ) + } + + private func matchesActivityType(_ item: ClassifiedDevProcess) -> Bool { + activityTypeFilter.matches( + isAutomated: store.liveSnapshot.automationLinksByProcessID[item.process.pid] != nil, + isLongRunning: store.liveSnapshot.longRunningProcessIDs.contains(item.process.pid) + ) + } + + private func processes(withIDs processIDs: [Int32]) -> [ClassifiedDevProcess] { + let visibleIDs = Set(processIDs) + return activityFilteredProcesses.filter { visibleIDs.contains($0.process.pid) } + } + + private func alignSelectionWithActivityScope() { + switch activityScope { + case .workflows: + if selectedWorkflow == nil { + selectedCategoryID = intelligenceWorkflows.first?.id ?? ProcessPresentation.allCategoryID + } + case .processes: + if selectedWorkflow != nil { + selectedCategoryID = ProcessPresentation.allCategoryID + } + case .applications, .hierarchy: + break + } + } + + private func copySelectedCommand() { + guard let selectedProcessForDetail else { + return + } + + copyCommand(selectedProcessForDetail) + } + + private func copyCommand(_ item: ClassifiedDevProcess) { + copyToPasteboard( + item.process.command, + label: "Process command", + recoveryText: ProcessPresentation.redactedCommand(item.process.command) + ) + store.presentFeedback( + title: "Command copied", + detail: store.displayName(for: item), + symbolName: "doc.on.doc.fill", + kind: .success + ) + } + + private func openSelectedFolder() { + guard let selectedProcessForDetail, + let currentDirectory = selectedProcessForDetail.process.currentDirectory + else { + return + } + + let didOpen = NSWorkspace.shared.open( + URL(fileURLWithPath: currentDirectory, isDirectory: true) + ) + store.presentFeedback( + title: didOpen ? "Folder opened" : "Folder unavailable", + detail: didOpen ? currentDirectory : "macOS could not open this process folder.", + symbolName: didOpen ? "folder.fill" : "folder.badge.questionmark", + kind: didOpen ? .info : .warning + ) + } + + private func toggledKeys(for item: ClassifiedDevProcess, in keys: Set) -> String { + var updated = keys + let aliases = ProcessPresentation.identityKeys(for: item) + if !updated.isDisjoint(with: aliases) { + updated.subtract(aliases) + } else { + updated.insert(ProcessPresentation.identityKey(for: item)) + } + return encodeKeys(updated) + } + + private func decodeKeys(_ rawValue: String) -> Set { + Set(rawValue.split(separator: "\n").map(String.init)) + } + + private func encodeKeys(_ keys: Set) -> String { + keys.sorted().joined(separator: "\n") + } + + private func migrateLegacySavedIdentityKeys() { + favoriteProcessKeysRaw = encodeKeys( + ProcessPresentation.sanitizedSavedIdentityKeys(decodeKeys(favoriteProcessKeysRaw)) + ) + watchedProcessKeysRaw = encodeKeys( + ProcessPresentation.sanitizedSavedIdentityKeys(decodeKeys(watchedProcessKeysRaw)) + ) + } + + private func announceFeedback(_ feedback: ProcessActionFeedback) { + let announcement = "\(feedback.title). \(feedback.detail)" + let userInfo: [NSAccessibility.NotificationUserInfoKey: Any] = [ + .announcement: announcement, + .priority: NSAccessibilityPriorityLevel.high.rawValue, + ] + NSAccessibility.post( + element: NSApp as Any, + notification: .announcementRequested, + userInfo: userInfo + ) + } + + private func copyToPasteboard(_ value: String, label: String, recoveryText: String? = nil) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(value, forType: .string) + do { + lastCopiedPayload = try persistentCopyStore.save(text: recoveryText ?? value, label: label) + } catch { + store.presentFeedback( + title: "Copy recovery failed", + detail: error.localizedDescription, + symbolName: "externaldrive.badge.exclamationmark", + kind: .warning + ) + } + } + + private func loadLastCopiedPayload() { + do { + lastCopiedPayload = try persistentCopyStore.load() + } catch { + lastCopiedPayload = nil + store.presentFeedback( + title: "Copy recovery unavailable", + detail: error.localizedDescription, + symbolName: "externaldrive.badge.exclamationmark", + kind: .warning + ) + } + } + + private func restoreLastCopiedPayload() { + do { + guard let payload = try persistentCopyStore.load() else { + lastCopiedPayload = nil + store.presentFeedback( + title: "No saved copy", + detail: "Copy or export a process first.", + symbolName: "clipboard", + kind: .info + ) + return + } + + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(payload.text, forType: .string) + lastCopiedPayload = payload + store.presentFeedback( + title: "Redacted recovery copy restored", + detail: payload.isTruncated + ? "\(payload.label) · recovery cache was truncated" : payload.label, + symbolName: "clipboard.fill", + kind: payload.isTruncated ? .warning : .success + ) + } catch { + lastCopiedPayload = nil + store.presentFeedback( + title: "Restore failed", + detail: error.localizedDescription, + symbolName: "clipboard", + kind: .error + ) + } + } + + private var feedbackTransition: AnyTransition { + reduceMotion ? .identity : .move(edge: .top).combined(with: .opacity) + } + + private var workspaceMode: DevScopeWorkspaceMode { + DevScopeWorkspaceMode(rawValue: workspaceModeRaw) ?? .processes + } + + private var workspaceModeBinding: Binding { + Binding( + get: { workspaceMode }, + set: { workspaceModeRaw = $0.rawValue } + ) + } + + private var pendingKillBinding: Binding { + Binding( + get: { pendingKillAction != nil }, + set: { presented in + if !presented { pendingKillAction = nil } + } + ) + } +} + +private extension View { + func workspaceLayer(isActive: Bool) -> some View { + opacity(isActive ? 1 : 0) + .disabled(!isActive) + .allowsHitTesting(isActive) + .accessibilityHidden(!isActive) + } +} + +private struct AutomationIntegrationModifier: ViewModifier { + @ObservedObject var processStore: ProcessStore + @ObservedObject var automationStore: AutomationStore + let automationNotifier: AutomationNotifier + @AppStorage(DevScopeSettingsKey.longRunningThresholdSeconds) private var longRunningThresholdSeconds = 14_400.0 + @AppStorage(DevScopeSettingsKey.notifyLongRunningAutomation) private var notifyLongRunningAutomation = false + @AppStorage(DevScopeSettingsKey.notifyUnexpectedAutomationExit) private var notifyUnexpectedAutomationExit = false + @AppStorage(DevScopeSettingsKey.notifyRepeatedAutomationFailure) private var notifyRepeatedAutomationFailure = false + + func body(content: Content) -> some View { + content + .onChange(of: automationStore.snapshot) { _, snapshot in + processStore.updateAutomationContext( + inventory: snapshot, + longRunningThreshold: AutomationPresentationSettings.normalizedThreshold(longRunningThresholdSeconds) + ) + } + .onChange(of: longRunningThresholdSeconds) { _, threshold in + processStore.updateAutomationContext( + inventory: automationStore.snapshot, + longRunningThreshold: AutomationPresentationSettings.normalizedThreshold(threshold) + ) + } + .onChange(of: processStore.automationEvents) { _, events in + guard !events.isEmpty else { return } + Task { + await automationNotifier.consume(events) + } + } + .task { + processStore.updateAutomationContext( + inventory: automationStore.snapshot, + longRunningThreshold: AutomationPresentationSettings.normalizedThreshold(longRunningThresholdSeconds) + ) + await automationNotifier.synchronize(AutomationNotificationPreferences( + crossedLongRunningThreshold: notifyLongRunningAutomation, + unexpectedExit: notifyUnexpectedAutomationExit, + repeatedFailure: notifyRepeatedAutomationFailure + )) + automationStore.start() + } + .onDisappear { + automationStore.stop() + } + } +} + +private struct ActionFeedbackBanner: View { + let feedback: ProcessActionFeedback + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: feedback.symbolName) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(tint) + .frame(width: 18, height: 18) + + VStack(alignment: .leading, spacing: 2) { + Text(feedback.title) + .font(.callout.weight(.semibold)) + .lineLimit(1) + Text(feedback.detail) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + .truncationMode(.middle) + } + .frame(maxWidth: 300, alignment: .leading) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(tint.opacity(0.28), lineWidth: 1) + } + .shadow(color: .black.opacity(0.22), radius: 16, y: 8) + } + + private var tint: Color { + switch feedback.kind { + case .success: + .green + case .warning: + .orange + case .error: + .red + case .info: + DevScopePalette.accent + } + } +} + +private struct CategoryRail: View { + @Environment(\.openSettings) private var openSettings + @Binding var selectedCategoryID: String + @Binding var activityScope: ProcessActivityScope + let workflows: [DevWorkflow] + let categories: [DevProcessCategory] + @Binding var isCollapsed: Bool + let isEnhancingWorkflows: Bool + let sponsorAction: () -> Void + @State private var showsAllWorkflows = false + @State private var showsRuntimeFilters = true + + var body: some View { + VStack(spacing: 10) { + RailHeader(isCollapsed: $isCollapsed) + + ScrollView { + VStack(alignment: .leading, spacing: isCollapsed ? 6 : 8) { + if !savedCategories.isEmpty { + if !isCollapsed { + RailSectionTitle(title: "Saved", detail: nil) + } + + VStack(spacing: 3) { + ForEach(savedCategories) { category in + Button { + selectedCategoryID = category.id + activityScope = .processes + } label: { + CategoryRailItem( + category: category, + isSelected: selectedCategoryID == category.id, + isCollapsed: isCollapsed + ) + } + .buttonStyle(.plain) + .accessibilityLabel(category.title) + .accessibilityValue("\(category.count) item\(category.count == 1 ? "" : "s")") + .accessibilityHint("Filters the running activity list to \(category.title).") + .accessibilityAddTraits(selectedCategoryID == category.id ? .isSelected : []) + .help("Filter running activity by \(category.title)") + } + } + .padding(.bottom, isCollapsed ? 0 : 2) + } + + if !activityCategories.isEmpty { + if !isCollapsed { + RuntimeFiltersDisclosure( + categories: activityCategories, + selectedCategoryID: selectedCategoryID, + isExpanded: $showsRuntimeFilters + ) + } + + if isCollapsed || showsRuntimeFilters + || activityCategories.contains(where: { $0.id == selectedCategoryID }) + { + VStack(spacing: 2) { + ForEach(activityCategories) { category in + Button { + selectedCategoryID = category.id + activityScope = .processes + } label: { + RuntimeRailItem( + category: category, + isSelected: selectedCategoryID == category.id, + isCollapsed: isCollapsed + ) + } + .buttonStyle(.plain) + .accessibilityLabel(category.title) + .accessibilityValue("\(category.count) item\(category.count == 1 ? "" : "s")") + .accessibilityHint("Filters the running activity list to \(category.title).") + .accessibilityAddTraits(selectedCategoryID == category.id ? .isSelected : []) + .help("Filter running activity by \(category.title)") + } + } + .padding(.leading, isCollapsed ? 0 : 12) + .padding(.bottom, isCollapsed ? 0 : 4) + } + } + + if !workflows.isEmpty { + if !isCollapsed { + RailSectionTitle( + title: "Focus", + detail: isEnhancingWorkflows ? "Apple AI" : "\(workflows.count)" + ) + } + + VStack(spacing: 4) { + ForEach(visibleWorkflows) { workflow in + Button { + selectedCategoryID = workflow.id + activityScope = .workflows + } label: { + WorkflowRailItem( + workflow: workflow, + isSelected: selectedCategoryID == workflow.id, + isCollapsed: isCollapsed + ) + } + .buttonStyle(.plain) + .accessibilityLabel(workflow.title) + .accessibilityValue(workflow.subtitle) + .accessibilityHint(workflow.summary) + .accessibilityAddTraits(selectedCategoryID == workflow.id ? .isSelected : []) + .help(workflow.summary) + } + + if !isCollapsed && workflows.count > visibleWorkflows.count { + RailDisclosureButton( + title: showsAllWorkflows + ? "Show fewer" : "Show \(workflows.count - visibleWorkflows.count) more", + systemImage: showsAllWorkflows ? "chevron.up" : "chevron.down", + tint: DevScopePalette.accent + ) { + showsAllWorkflows.toggle() + } + } + } + } + } + } + + Divider() + + VStack(spacing: 3) { + RailActionButton( + title: "Support", symbolName: "cup.and.saucer.fill", tint: .pink, + isCollapsed: isCollapsed, action: sponsorAction) + RailActionButton( + title: "Settings", symbolName: "gearshape", tint: .secondary, isCollapsed: isCollapsed + ) { + openSettings() + } + } + } + .padding(6) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(.white.opacity(0.08), lineWidth: 1) + } + } + + private var savedCategories: [DevProcessCategory] { + categories.filter { category in + category.id == ProcessPresentation.favoritesCategoryID + || category.id == ProcessPresentation.watchedCategoryID + } + } + + private var activityCategories: [DevProcessCategory] { + categories.filter { category in + category.id != ProcessPresentation.favoritesCategoryID + && category.id != ProcessPresentation.watchedCategoryID + } + } + + private var visibleWorkflows: [DevWorkflow] { + guard !isCollapsed else { + return workflows + } + + return InterfacePresentation.visibleFocusWorkflows( + workflows, + selectedID: selectedCategoryID, + showsAll: showsAllWorkflows + ) + } +} + +private func disclosureAnimation(reduceMotion: Bool) -> Animation? { + reduceMotion ? nil : .snappy(duration: 0.18) +} + +private struct RailHeader: View { + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Binding var isCollapsed: Bool + + var body: some View { + HStack { + if !isCollapsed { + Text("Control") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + Spacer() + } + + Button { + withAnimation(disclosureAnimation(reduceMotion: reduceMotion)) { + isCollapsed.toggle() + } + } label: { + Image(systemName: isCollapsed ? "sidebar.left" : "sidebar.leading") + .accessibilityHidden(true) + } + .buttonStyle(.borderless) + .accessibilityLabel(isCollapsed ? "Expand control panel" : "Collapse control panel") + .accessibilityHint(isCollapsed ? "Shows the control panel." : "Hides the control panel.") + .help(isCollapsed ? "Expand panel" : "Collapse panel") + } + .padding(.horizontal, isCollapsed ? 2 : 8) + } +} + +private struct RuntimeFiltersDisclosure: View { + @Environment(\.accessibilityReduceMotion) private var reduceMotion + let categories: [DevProcessCategory] + let selectedCategoryID: String + @Binding var isExpanded: Bool + + var body: some View { + Button { + withAnimation(disclosureAnimation(reduceMotion: reduceMotion)) { + isExpanded.toggle() + } + } label: { + VStack(alignment: .leading, spacing: 8) { + HStack { + Label("Activity types", systemImage: "square.grid.2x2") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + + Spacer() + + Text("\(runtimeOnlyCategories.count)") + .font(.caption2.monospacedDigit().weight(.semibold)) + .foregroundStyle(.tertiary) + + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + } + + RuntimeMixBar(runtimeCounts: runtimeCounts, height: 7) + + HStack(spacing: 7) { + ForEach(topRuntimeCategories.prefix(3)) { category in + HStack(spacing: 4) { + Circle() + .fill(color(for: category)) + .frame(width: 6, height: 6) + Text(category.title) + .lineLimit(1) + Text("\(category.count)") + .monospacedDigit() + .foregroundStyle(.tertiary) + } + .font(.caption2) + .foregroundStyle(selectedCategoryID == category.id ? .primary : .secondary) + } + } + } + .padding(.horizontal, 8) + .padding(.vertical, 8) + .background( + .quaternary.opacity(0.20), in: RoundedRectangle(cornerRadius: 8, style: .continuous) + ) + .overlay { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(.white.opacity(0.08), lineWidth: 1) + } + .accessibilityHidden(true) + } + .buttonStyle(.plain) + .accessibilityLabel(isExpanded ? "Collapse activity types" : "Expand activity types") + .accessibilityValue(isExpanded ? "Expanded" : "Collapsed") + .accessibilityHint("Shows or hides dynamic activity filters.") + .help(isExpanded ? "Hide activity filters" : "Show activity filters") + } + + private var runtimeOnlyCategories: [DevProcessCategory] { + categories.filter { category in + DevRuntimeKind(rawValue: category.title) != nil + } + } + + private var runtimeCounts: [DevRuntimeKind: Int] { + Dictionary( + uniqueKeysWithValues: runtimeOnlyCategories.compactMap { category in + guard let kind = DevRuntimeKind(rawValue: category.title) else { + return nil + } + return (kind, category.count) + } + ) + } + + private var topRuntimeCategories: [DevProcessCategory] { + runtimeOnlyCategories.sorted { lhs, rhs in + if lhs.count != rhs.count { + return lhs.count > rhs.count + } + return lhs.title.localizedStandardCompare(rhs.title) == .orderedAscending + } + } + + private func color(for category: DevProcessCategory) -> Color { + if category.id == ProcessPresentation.allCategoryID { + return DevScopePalette.accent + } + guard let kind = DevRuntimeKind(rawValue: category.title) else { + return .secondary + } + return DevScopePalette.color(for: kind) + } +} + +private struct RuntimeRailItem: View { + let category: DevProcessCategory + let isSelected: Bool + let isCollapsed: Bool + + var body: some View { + HStack(spacing: isCollapsed ? 0 : 7) { + if !isCollapsed { + Capsule() + .fill(color.opacity(0.45)) + .frame(width: 2, height: 20) + } + + Image(systemName: category.symbolName) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(color) + .frame(width: isCollapsed ? 30 : 16, height: 22) + + if !isCollapsed { + Text(category.title) + .font(.caption.weight(isSelected ? .semibold : .regular)) + .foregroundStyle(isSelected ? .primary : .secondary) + .lineLimit(1) + .minimumScaleFactor(0.72) + + Spacer(minLength: 4) + + Text("\(category.count)") + .font(.caption2.monospacedDigit().weight(.semibold)) + .foregroundStyle(isSelected ? .primary : .tertiary) + .contentTransition(.numericText()) + .frame(width: 22, alignment: .trailing) + } + } + .padding(.horizontal, isCollapsed ? 5 : 7) + .padding(.vertical, 4) + .background { + if isSelected { + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill(color.opacity(0.14)) + } + } + .overlay { + if isSelected { + RoundedRectangle(cornerRadius: 7, style: .continuous) + .stroke(color.opacity(0.28), lineWidth: 1) + } + } + .contentShape(RoundedRectangle(cornerRadius: 7, style: .continuous)) + .accessibilityHidden(true) + } + + private var color: Color { + if category.id == ProcessPresentation.allCategoryID { + return DevScopePalette.accent + } + guard let kind = DevRuntimeKind(rawValue: category.title) else { + return .secondary + } + return DevScopePalette.color(for: kind) + } +} + +private struct RailDisclosureButton: View { + @Environment(\.accessibilityReduceMotion) private var reduceMotion + let title: String + let systemImage: String + let tint: Color + let action: () -> Void + + var body: some View { + Button { + withAnimation(disclosureAnimation(reduceMotion: reduceMotion)) { + action() + } + } label: { + Label(title, systemImage: systemImage) + .font(.caption.weight(.semibold)) + .foregroundStyle(tint) + .frame(maxWidth: .infinity) + .padding(.vertical, 5) + .background(tint.opacity(0.08), in: RoundedRectangle(cornerRadius: 7, style: .continuous)) + .accessibilityHidden(true) + } + .buttonStyle(.plain) + .accessibilityLabel(title) + .accessibilityHint(title) + .help(title) + } +} + +private struct RailSectionTitle: View { + let title: String + let detail: String? + + var body: some View { + HStack { + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + Spacer() + if let detail { + Text(detail) + .font(.caption2.monospacedDigit()) + .foregroundStyle(.tertiary) + } + } + .padding(.horizontal, 7) + .padding(.top, 3) + } +} + +private struct WorkflowRailItem: View { + let workflow: DevWorkflow + let isSelected: Bool + let isCollapsed: Bool + + var body: some View { + HStack(spacing: isCollapsed ? 0 : 8) { + Image(systemName: workflow.kind.symbolName) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(workflowColor) + .frame(width: isCollapsed ? 30 : 34, height: 24) + + if !isCollapsed { + VStack(alignment: .leading, spacing: 2) { + Text(workflow.title) + .font(.callout.weight(isSelected ? .semibold : .regular)) + .foregroundStyle(isSelected ? .primary : .secondary) + .lineLimit(1) + + Text(workflow.subtitle) + .font(.caption2) + .foregroundStyle(.tertiary) + .lineLimit(1) + } + .layoutPriority(1) + + Spacer(minLength: 4) + + Text(riskLabel) + .font(.caption2.weight(.semibold)) + .foregroundStyle(riskColor) + .lineLimit(1) + .frame(width: 38, alignment: .trailing) + } + } + .padding(.horizontal, isCollapsed ? 5 : 8) + .padding(.vertical, 5) + .frame(minHeight: isCollapsed ? 34 : 38, alignment: .center) + .frame(maxWidth: .infinity, alignment: .leading) + .background { + if isSelected { + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill(workflowColor.opacity(0.16)) + } + } + .overlay { + if isSelected { + RoundedRectangle(cornerRadius: 7, style: .continuous) + .stroke(workflowColor.opacity(0.34), lineWidth: 1) + } + } + .contentShape(RoundedRectangle(cornerRadius: 7, style: .continuous)) + .accessibilityHidden(true) + } + + private var workflowColor: Color { + switch workflow.kind { + case .aiMLLab, .localLLMStack: + .pink + case .notebookSession, .dataApp: + .cyan + case .trainingRun: + .orange + case .apiService, .webWorkspace: + .indigo + case .vectorDatabase: + .purple + case .buildWorkspace: + .blue + case .projectWorkspace, .runtimeGroup: + DevScopePalette.accent + } + } + + private var riskLabel: String { + switch workflow.risk { + case .normal: + "" + case .busy: + "Busy" + case .heavy: + "Heavy" + } + } + + private var riskColor: Color { + switch workflow.risk { + case .normal: + .secondary + case .busy: + .orange + case .heavy: + .red + } + } +} + +private struct CategoryRailItem: View { + let category: DevProcessCategory + let isSelected: Bool + let isCollapsed: Bool + + var body: some View { + HStack(spacing: isCollapsed ? 0 : 8) { + Image(systemName: category.symbolName) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(iconColor) + .frame(width: isCollapsed ? 30 : 18, height: 24) + + if !isCollapsed { + Text(category.title) + .font(.subheadline.weight(isSelected ? .semibold : .regular)) + .foregroundStyle(isSelected ? .primary : .secondary) + .lineLimit(1) + .minimumScaleFactor(0.72) + .layoutPriority(1) + + Spacer() + + Text("\(category.count)") + .font(.caption.monospacedDigit().weight(.semibold)) + .foregroundStyle(isSelected ? .primary : .tertiary) + .contentTransition(.numericText()) + .lineLimit(1) + .minimumScaleFactor(0.75) + .frame(width: 26, alignment: .trailing) + } + } + .padding(.horizontal, isCollapsed ? 5 : 8) + .padding(.vertical, 6) + .background { + if isSelected { + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill(DevScopePalette.accent.opacity(0.16)) + } + } + .overlay { + if isSelected { + RoundedRectangle(cornerRadius: 7, style: .continuous) + .stroke(DevScopePalette.accent.opacity(0.36), lineWidth: 1) + } + } + .contentShape(RoundedRectangle(cornerRadius: 7, style: .continuous)) + .accessibilityHidden(true) + } + + private var iconColor: Color { + if category.id == ProcessPresentation.allCategoryID { + DevScopePalette.accent + } else if category.id == ProcessPresentation.favoritesCategoryID { + .yellow + } else if category.id == ProcessPresentation.watchedCategoryID { + .cyan + } else if let kind = DevRuntimeKind(rawValue: category.title) { + DevScopePalette.color(for: kind) + } else { + .secondary + } + } +} + +private struct RailActionButton: View { + let title: String + let symbolName: String + let tint: Color + let isCollapsed: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: isCollapsed ? 0 : 8) { + Image(systemName: symbolName) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(tint) + .frame(width: isCollapsed ? 30 : 18, height: 24) + if !isCollapsed { + Text(title) + .font(.subheadline) + .foregroundStyle(.secondary) + Spacer() + } + } + .padding(.horizontal, isCollapsed ? 5 : 8) + .padding(.vertical, 6) + .contentShape(RoundedRectangle(cornerRadius: 7, style: .continuous)) + .accessibilityHidden(true) + } + .buttonStyle(.plain) + .accessibilityLabel(title) + .accessibilityHint("Opens \(title).") + .help(title) + } +} + +private struct ControlStrip: View { + @Binding var searchText: String + @Binding var activityScope: ProcessActivityScope + let selectedCategoryTitle: String + let visibleProcessCount: Int + let totalProcessCount: Int + let applicationCount: Int + @Binding var activityTypeFilter: AutomationActivityTypeFilter + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 12) { + ProcessScopePicker(selection: $activityScope) + + Spacer(minLength: 12) + + Text(resultText) + .font(.callout.monospacedDigit()) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + HStack(spacing: 12) { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .foregroundStyle(.tertiary) + + TextField("Search application, project, command, PID, folder", text: $searchText) + .textFieldStyle(.plain) + + if !searchText.isEmpty { + Button { + searchText = "" + } label: { + Image(systemName: "xmark.circle.fill") + } + .buttonStyle(.plain) + .foregroundStyle(.tertiary) + .accessibilityLabel("Clear search") + .help("Clear search") + .transition(.scale.combined(with: .opacity)) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .softPanel(cornerRadius: 10) + .frame(maxWidth: 520) + + Spacer() + + Picker("Activity Type", selection: $activityTypeFilter) { + ForEach(AutomationActivityTypeFilter.allCases, id: \.self) { filter in + Text(filter.displayTitle).tag(filter) + } + } + .frame(width: 170) + .accessibilityHint("Filters Automated and Long Running signals independently or together.") + } + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background(.bar, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(.white.opacity(0.08), lineWidth: 1) + } + } + + private var resultText: String { + switch activityScope { + case .applications: + return "\(applicationCount) applications · \(visibleProcessCount) OS processes" + case .processes, .hierarchy, .workflows: + if selectedCategoryTitle == "All" + && searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && activityTypeFilter == .all + { + return "\(totalProcessCount) OS processes" + } + return + "\(visibleProcessCount) of \(totalProcessCount) OS processes · \(selectedCategoryTitle)" + } + } +} + +private extension AutomationActivityTypeFilter { + var displayTitle: String { + switch self { + case .all: "All Activity" + case .automated: "Automated" + case .longRunning: "Long Running" + case .both: "Both" + } + } +} + +struct DevScopeBackground: View { + var body: some View { + ZStack { + LinearGradient( + colors: [ + DevScopePalette.backgroundTop, + DevScopePalette.backgroundBottom, + Color(nsColor: .controlBackgroundColor), + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + + RadialGradient( + colors: [ + DevScopePalette.accent.opacity(0.18), + .clear, + ], + center: .topLeading, + startRadius: 40, + endRadius: 520 + ) + .opacity(0.65) + } + .ignoresSafeArea() + } +} + +private struct LiveActivityHeightPreferenceKey: PreferenceKey { + static let defaultValue: CGFloat = 0 + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = nextValue() + } +} + +private enum KillAction: Identifiable { + case single(ClassifiedDevProcess) + case tree(ClassifiedDevProcess) + case forceSingle(ClassifiedDevProcess) + case forceTree(ClassifiedDevProcess) + + var process: ClassifiedDevProcess { + switch self { + case .single(let process), .tree(let process), + .forceSingle(let process), .forceTree(let process): + return process + } + } + + var id: String { + switch self { + case .single(let process): + "single-\(process.process.pid)" + case .tree(let process): + "tree-\(process.process.pid)" + case .forceSingle(let process): + "force-single-\(process.process.pid)" + case .forceTree(let process): + "force-tree-\(process.process.pid)" + } + } + + var title: String { + switch self { + case .single(let process): + "Terminate \(process.classification.displayName)?" + case .tree(let process): + "Terminate \(process.classification.displayName) and children?" + case .forceSingle(let process): + "Force kill \(process.classification.displayName)?" + case .forceTree(let process): + "Force kill \(process.classification.displayName) and children?" + } + } + + var buttonTitle: String { + switch self { + case .single: + "Send TERM" + case .tree: + "Send TERM to Tree" + case .forceSingle: + "Send KILL" + case .forceTree: + "Send KILL to Tree" + } + } + + private var terminationAction: ProcessTerminationAction { + switch self { + case .single: + .single + case .tree: + .tree + case .forceSingle: + .forceSingle + case .forceTree: + .forceTree + } + } + + func consequence(descendantCount: Int) -> String { + terminationAction.consequence( + pid: process.process.pid, + descendantCount: descendantCount + ) + } +} diff --git a/Sources/DevScope/Views/DevScopeStyle.swift b/Sources/DevScope/Views/DevScopeStyle.swift new file mode 100644 index 0000000..6722d41 --- /dev/null +++ b/Sources/DevScope/Views/DevScopeStyle.swift @@ -0,0 +1,131 @@ +import DevScopeCore +import SwiftUI + +enum DevScopePalette { + static let accent = Color(nsColor: .controlAccentColor) + static let backgroundTop = Color(nsColor: .windowBackgroundColor) + static let backgroundBottom = Color(nsColor: .underPageBackgroundColor) + + static func color(for kind: DevRuntimeKind) -> Color { + switch kind { + case .javascript: + .yellow + case .python: + .green + case .swift: + .blue + case .rust: + .orange + case .go: + .cyan + case .flutter: + .blue + case .java: + .red + case .database: + .purple + case .container: + .mint + case .webServer: + .indigo + case .ai: + .pink + case .mcp: + .indigo + case .browser: + .teal + case .macApp: + .blue + case .backgroundAgent: + .gray + case .systemService: + .brown + case .shell: + .yellow + case .other: + .secondary + } + } + + static func color(for automationKind: AutomationKind) -> Color { + AutomationVisualIdentity.fallback(for: automationKind).color + } +} + +extension AutomationColorToken { + var color: Color { + switch self { + case .accent: + DevScopePalette.accent + case .blue: + .blue + case .indigo: + .indigo + case .cyan: + .cyan + case .purple: + .purple + case .green: + .green + case .orange: + .orange + case .red: + .red + case .secondary: + .secondary + } + } +} + +extension AutomationVisualIdentity { + var color: Color { colorToken.color } +} + +struct DevScopeGlassContainer: View { + private let spacing: CGFloat + private let content: Content + + init(spacing: CGFloat = 18, @ViewBuilder content: () -> Content) { + self.spacing = spacing + self.content = content() + } + + var body: some View { + if #available(macOS 26.0, *) { + GlassEffectContainer(spacing: spacing) { + content + } + } else { + content + } + } +} + +extension View { + @ViewBuilder + func devScopeGlass(cornerRadius: CGFloat = 14, interactive: Bool = false) -> some View { + let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + + if #available(macOS 26.0, *) { + if interactive { + self.glassEffect(.regular.interactive(), in: .rect(cornerRadius: cornerRadius)) + } else { + self.glassEffect(.regular, in: .rect(cornerRadius: cornerRadius)) + } + } else { + self + .background(.regularMaterial, in: shape) + .overlay { + shape.stroke(.white.opacity(0.14), lineWidth: 1) + } + } + } + + func softPanel(cornerRadius: CGFloat = 14) -> some View { + background(.regularMaterial, in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .stroke(.white.opacity(0.12), lineWidth: 1) + } + } +} diff --git a/Sources/DevScope/Views/LiveNotchPanelPresenter.swift b/Sources/DevScope/Views/LiveNotchPanelPresenter.swift new file mode 100644 index 0000000..d337014 --- /dev/null +++ b/Sources/DevScope/Views/LiveNotchPanelPresenter.swift @@ -0,0 +1,269 @@ +import AppKit +import DevScopeCore +import SwiftUI + +@MainActor +final class LiveNotchPresentationModel: ObservableObject { + @Published private(set) var state = LiveNotchPresentationState.collapsed + + func publish(_ state: LiveNotchPresentationState) { + self.state = state + } +} + +@MainActor +struct LiveNotchPanelPresenter: NSViewRepresentable { + let stats: ProcessDashboardStats + let metricHistory: [DevProcessMetricSample] + let selectedScope: String + let isRefreshing: Bool + let lastRefresh: Date? + let refreshAction: @MainActor () -> Void + let displayName: @MainActor (ClassifiedDevProcess) -> String + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + func makeNSView(context: Context) -> NSView { + let view = NSView(frame: .zero) + context.coordinator.update(from: self) + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + context.coordinator.update(from: self) + } + + static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) { + coordinator.close() + } + + @MainActor + final class Coordinator { + private static let screenTopInset: CGFloat = 3 + private static let visibleFrameLift: CGFloat = 6 + private static let activationBandHeight: CGFloat = 12 + private var panel: NSPanel? + private var isClosed = false + private var isApplyingPresentationState = false + private let presentationModel = LiveNotchPresentationModel() + + func update(from presenter: LiveNotchPanelPresenter) { + guard !isClosed else { + return + } + + let createdPanel = panel == nil + let panel = panel ?? makePanel() + self.panel = panel + + let rootView = DynamicIslandStatusView( + stats: presenter.stats, + metricHistory: presenter.metricHistory, + selectedScope: presenter.selectedScope, + isRefreshing: presenter.isRefreshing, + lastRefresh: presenter.lastRefresh, + refreshAction: presenter.refreshAction, + displayName: presenter.displayName, + presentationModel: presentationModel + ) + + if let hostingView = panel.contentView as? LiveNotchHostingView { + hostingView.rootView = rootView + } else { + let hostingView = LiveNotchHostingView(rootView: rootView) + hostingView.onPointerEvent = { [weak self] pointerEvent in + self?.handle(pointerEvent) + } + panel.contentView = hostingView + } + + if createdPanel || !panel.isVisible { + panel.orderFrontRegardless() + } + } + + func close() { + guard !isClosed else { + return + } + + if let hostingView = panel?.contentView as? LiveNotchHostingView { + hostingView.tearDown() + } + applyPresentationState(.collapsed) + isClosed = true + panel?.orderOut(nil) + panel?.close() + panel = nil + } + + private func makePanel() -> NSPanel { + let panel = NSPanel( + contentRect: frame(for: .collapsed), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + panel.backgroundColor = .clear + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .ignoresCycle] + panel.acceptsMouseMovedEvents = true + panel.hasShadow = false + panel.hidesOnDeactivate = false + panel.ignoresMouseEvents = false + panel.isMovable = false + panel.isOpaque = false + panel.isReleasedWhenClosed = false + panel.level = .statusBar + panel.title = "DevScope Live Notch" + panel.titleVisibility = .hidden + panel.titlebarAppearsTransparent = true + return panel + } + + private func handle(_ pointerEvent: LiveNotchPointerEvent) { + guard !isClosed, !isApplyingPresentationState, let panel else { + return + } + + let pointerLocation = pointerEvent.screenLocation + guard panel.frame.contains(pointerLocation) else { + applyPresentationState(.collapsed) + return + } + + switch (presentationModel.state, pointerEvent.kind) { + case (.collapsed, .entered), (.collapsed, .moved): + applyPresentationState(.compact) + case (.compact, .moved) where !isInActivationBand(pointerLocation, panelFrame: panel.frame): + applyPresentationState(.expanded) + case (.expanded, .moved) where isInActivationBand(pointerLocation, panelFrame: panel.frame): + applyPresentationState(.compact) + default: + break + } + } + + private func applyPresentationState(_ state: LiveNotchPresentationState) { + guard !isClosed, let panel else { + return + } + + let frame = frame(for: state) + guard presentationModel.state != state || !panel.frame.nearlyEquals(frame) else { + return + } + + isApplyingPresentationState = true + NSAnimationContext.runAnimationGroup { context in + context.duration = 0 + context.allowsImplicitAnimation = false + panel.setFrame(frame, display: true) + } + presentationModel.publish(state) + isApplyingPresentationState = false + + let pointerLocation = NSEvent.mouseLocation + if state != .collapsed, !frame.contains(pointerLocation) { + applyPresentationState(.collapsed) + } + } + + private func isInActivationBand(_ pointerLocation: NSPoint, panelFrame: NSRect) -> Bool { + pointerLocation.y >= panelFrame.maxY - Self.activationBandHeight + } + + private func frame(for state: LiveNotchPresentationState) -> NSRect { + let screen = NSScreen.main ?? NSScreen.screens.first + let screenFrame = screen?.frame ?? NSRect(x: 0, y: 0, width: 1440, height: 900) + let visibleFrame = screen?.visibleFrame ?? screenFrame + let panelSize = state.panelSize + let x = screenFrame.midX - panelSize.width / 2 + let topEdge = min(screenFrame.maxY - Self.screenTopInset, visibleFrame.maxY + Self.visibleFrameLift) + let y = topEdge - panelSize.height + return NSRect(x: x, y: y, width: panelSize.width, height: panelSize.height) + } + } +} + +@MainActor +private final class LiveNotchHostingView: NSHostingView { + var onPointerEvent: ((LiveNotchPointerEvent) -> Void)? + private var pointerTrackingArea: NSTrackingArea? + private var isTornDown = false + + override func updateTrackingAreas() { + super.updateTrackingAreas() + guard !isTornDown, pointerTrackingArea == nil else { + return + } + + let trackingArea = NSTrackingArea( + rect: .zero, + options: [.mouseEnteredAndExited, .mouseMoved, .activeAlways, .inVisibleRect], + owner: self, + userInfo: nil + ) + addTrackingArea(trackingArea) + pointerTrackingArea = trackingArea + } + + func tearDown() { + isTornDown = true + onPointerEvent = nil + if let pointerTrackingArea { + removeTrackingArea(pointerTrackingArea) + self.pointerTrackingArea = nil + } + } + + override func mouseEntered(with event: NSEvent) { + super.mouseEntered(with: event) + onPointerEvent?(LiveNotchPointerEvent(kind: .entered, screenLocation: NSEvent.mouseLocation)) + } + + override func mouseMoved(with event: NSEvent) { + super.mouseMoved(with: event) + onPointerEvent?(LiveNotchPointerEvent(kind: .moved, screenLocation: NSEvent.mouseLocation)) + } + + override func mouseExited(with event: NSEvent) { + super.mouseExited(with: event) + onPointerEvent?(LiveNotchPointerEvent(kind: .exited, screenLocation: NSEvent.mouseLocation)) + } +} + +private struct LiveNotchPointerEvent { + enum Kind { + case entered + case exited + case moved + } + + let kind: Kind + let screenLocation: NSPoint +} + +private extension NSRect { + func nearlyEquals(_ other: NSRect) -> Bool { + abs(origin.x - other.origin.x) < 0.5 && + abs(origin.y - other.origin.y) < 0.5 && + abs(size.width - other.size.width) < 0.5 && + abs(size.height - other.size.height) < 0.5 + } +} + +private extension LiveNotchPresentationState { + var panelSize: NSSize { + switch self { + case .collapsed: + NSSize(width: 128, height: 18) + case .compact: + NSSize(width: 640, height: 52) + case .expanded: + NSSize(width: 780, height: 178) + } + } + +} diff --git a/Sources/DevScope/Views/ProcessCommandBar.swift b/Sources/DevScope/Views/ProcessCommandBar.swift new file mode 100644 index 0000000..daa4573 --- /dev/null +++ b/Sources/DevScope/Views/ProcessCommandBar.swift @@ -0,0 +1,166 @@ +import AppKit +import DevScopeCore +import SwiftUI + +struct ProcessCommandBar: View { + let process: ClassifiedDevProcess + let displayName: String + let isEnded: Bool + let actionDecision: ProcessActionDecision + let isFavorite: Bool + let isWatched: Bool + let favoriteAction: () -> Void + let watchAction: () -> Void + let exportAction: () -> Void + let copyCommandAction: () -> Void + let openFolderAction: () -> Void + let killAction: () -> Void + let killTreeAction: () -> Void + let forceKillAction: () -> Void + let forceKillTreeAction: () -> Void + + private var terminatePresentation: ProcessActionControlPresentation { + ProcessActionPresentation.control(.terminate, isEnded: isEnded, actionDecision: actionDecision) + } + + private var terminateTreePresentation: ProcessActionControlPresentation { + ProcessActionPresentation.control(.terminateTree, isEnded: isEnded, actionDecision: actionDecision) + } + + private var forceOptionsPresentation: ProcessActionControlPresentation { + ProcessActionPresentation.control(.forceOptions, isEnded: isEnded, actionDecision: actionDecision) + } + + var body: some View { + HStack(spacing: 8) { + HStack(spacing: 8) { + Image(systemName: "terminal") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: 2) { + Text(displayName) + .font(.caption.weight(.semibold)) + .lineLimit(1) + Text("Command") + .font(.caption2) + .foregroundStyle(.tertiary) + .lineLimit(1) + } + } + .frame(width: 132, alignment: .leading) + + Text(process.process.command) + .font(.system(.caption, design: .monospaced).weight(.semibold)) + .textSelection(.enabled) + .lineLimit(1) + .truncationMode(.middle) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background(.quaternary.opacity(0.35), in: RoundedRectangle(cornerRadius: 7, style: .continuous)) + + HStack(spacing: 6) { + CommandBarButton( + title: isFavorite ? "Unfavorite" : "Favorite", + symbolName: isFavorite ? "star.fill" : "star", + tint: .yellow, + action: favoriteAction + ) + CommandBarButton( + title: isWatched ? "Unwatch" : "Watch", + symbolName: isWatched ? "eye.fill" : "eye", + tint: .cyan, + action: watchAction + ) + CommandBarButton(title: "Export", symbolName: "square.and.arrow.up", tint: .blue, action: exportAction) + CommandBarButton(title: "Copy", symbolName: "doc.on.doc", tint: .blue, action: copyCommandAction) + CommandBarButton( + title: "Folder", + symbolName: "folder", + tint: .blue, + isDisabled: process.process.currentDirectory == nil, + action: openFolderAction + ) + } + + Divider() + .frame(height: 28) + + Menu { + Button(role: .destructive, action: killAction) { + Label("Terminate Process…", systemImage: "xmark.circle") + } + .disabled(terminatePresentation.isDisabled) + .accessibilityHint(terminatePresentation.accessibilityHint) + .help(terminatePresentation.help) + + Button(role: .destructive, action: killTreeAction) { + Label("Terminate Process Tree…", systemImage: "point.3.connected.trianglepath.dotted") + } + .disabled(terminateTreePresentation.isDisabled) + .accessibilityHint(terminateTreePresentation.accessibilityHint) + .help(terminateTreePresentation.help) + + Divider() + + Button(role: .destructive, action: forceKillAction) { + Label("Force Kill Process…", systemImage: "bolt.fill") + } + .disabled(forceOptionsPresentation.isDisabled) + .accessibilityHint(forceOptionsPresentation.accessibilityHint) + .help(forceOptionsPresentation.help) + + Button(role: .destructive, action: forceKillTreeAction) { + Label("Force Kill Process Tree…", systemImage: "bolt.horizontal.fill") + } + .disabled(forceOptionsPresentation.isDisabled) + .accessibilityHint(forceOptionsPresentation.accessibilityHint) + .help(forceOptionsPresentation.help) + } label: { + Label("Terminate…", systemImage: "xmark.circle") + } + .menuStyle(.button) + .tint(.red) + .controlSize(.small) + .disabled( + terminatePresentation.isDisabled && + terminateTreePresentation.isDisabled && + forceOptionsPresentation.isDisabled + ) + .accessibilityLabel("Process termination options") + .accessibilityHint("Choose whether to terminate this process, its process tree, or use a force-kill option.") + .help("Process termination options") + } + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background(.bar, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(.white.opacity(0.10), lineWidth: 1) + } + .transition(.move(edge: .top).combined(with: .opacity)) + } +} + +private struct CommandBarButton: View { + let title: String + let symbolName: String + let tint: Color + var isDisabled = false + let action: () -> Void + + var body: some View { + Button(action: action) { + Label(title, systemImage: symbolName) + .labelStyle(.iconOnly) + .frame(width: 24) + } + .buttonStyle(.bordered) + .tint(tint) + .controlSize(.small) + .disabled(isDisabled) + .accessibilityLabel(title) + .help(title) + } +} diff --git a/Sources/DevScope/Views/ProcessDetailView.swift b/Sources/DevScope/Views/ProcessDetailView.swift new file mode 100644 index 0000000..5191005 --- /dev/null +++ b/Sources/DevScope/Views/ProcessDetailView.swift @@ -0,0 +1,635 @@ +import AppKit +import DevScopeCore +import SwiftUI + +struct ProcessDetailView: View { + @Environment(\.accessibilityReduceMotion) private var reduceMotion + let process: ClassifiedDevProcess? + let displayName: String? + let metricHistory: [DevProcessMetricSample] + let familySummary: ProcessFamilySummary? + let showsMetricHistory: Bool + let isEnded: Bool + let isFavorite: Bool + let isWatched: Bool + let workflow: DevWorkflow? + let processInsight: DevProcessInsight? + let workflowNote: String? + let automationLink: AutomationProcessLink? + let automationRecord: AutomationRecord? + let showInAutomations: (AutomationRecord.ID) -> Void + + var body: some View { + Group { + if let process { + VStack(spacing: 0) { + VStack(alignment: .leading, spacing: 12) { + DetailHeader(process: process, title: displayName ?? process.classification.displayName) + + if let processInsight { + ProcessSafetySummary(insight: processInsight) + } + + if isEnded { + Label( + "This process ended. Last known details are retained for review.", + systemImage: "checkmark.circle" + ) + .font(.callout.weight(.medium)) + .foregroundStyle(.secondary) + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .softPanel(cornerRadius: 10) + } + } + .padding(16) + + Divider() + + ScrollView { + VStack(alignment: .leading, spacing: 16) { + DetailSection(title: "Status", systemImage: "checklist") { + DetailGrid { + DetailChip(label: "State", value: isEnded ? "Ended" : "Live") + DetailChip(label: "Favorite", value: isFavorite ? "Yes" : "No") + DetailChip(label: "Watched", value: isWatched ? "Yes" : "No") + DetailChip(label: "PID", value: "\(process.process.pid)") + DetailChip(label: "PPID", value: "\(process.process.parentPID)") + if let familySummary { + DetailChip(label: "Children", value: "\(familySummary.childCount)") + DetailChip(label: "Tree", value: "\(familySummary.descendantCount)") + } + } + } + + DetailSection(title: "Resources", systemImage: "gauge.with.dots.needle.67percent") { + DetailGrid { + DetailChip( + label: "CPU", + value: ProcessMetricFormat.cpu(process.process.resourceUsage?.cpuPercent)) + DetailChip(label: "CPU Avg", value: ProcessMetricFormat.cpu(averageCPU)) + DetailChip(label: "CPU Peak", value: ProcessMetricFormat.cpu(peakCPU)) + DetailChip( + label: "Memory", + value: ProcessMetricFormat.memory( + process.process.resourceUsage?.residentMemoryBytes)) + DetailChip(label: "Mem Avg", value: ProcessMetricFormat.memory(averageMemory)) + DetailChip(label: "Mem Peak", value: ProcessMetricFormat.memory(peakMemory)) + DetailChip(label: "GPU", value: ProcessMetricFormat.gpu(latestGPU)) + DetailChip( + label: "Running", value: process.process.resourceUsage?.elapsedTime ?? "-") + DetailChip(label: "Samples", value: "\(metricHistory.count)") + } + } + + if !process.classification.tags.isEmpty { + DetailSection(title: "Tags", systemImage: "tag") { + FlowTagList(tags: process.classification.tags) + } + } + + if let workflow { + WorkflowContextCard( + workflow: workflow, + workflowNote: workflowNote + ) + } + + if let automationLink, let automationRecord { + DetailSection(title: "Automation", systemImage: "gearshape.2") { + DetailGrid { + DetailChip(label: "Definition", value: automationRecord.label) + DetailChip(label: "Source", value: automationRecord.sourceKind.displayTitle) + DetailChip(label: "Trigger", value: automationRecord.schedule.summary) + DetailChip(label: "Evidence", value: automationLink.strength == .strong ? "Strong" : "Weak") + } + ForEach(automationLink.evidence, id: \.self) { evidence in + Text("\(evidence.source): \(evidence.detail)") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Button("Show in Automations") { + showInAutomations(automationRecord.id) + } + .buttonStyle(.borderedProminent) + .accessibilityHint("Switches workspace mode and selects this exact automation definition.") + .help("Show this definition in Automations") + } + } + + if showsMetricHistory { + ProcessMetricHistoryView(samples: metricHistory) + } + + Divider() + + DetailSection(title: "Identity", systemImage: "person.text.rectangle") { + DetailGrid { + DetailChip(label: "Kind", value: process.classification.kind.rawValue) + DetailChip( + label: "Name", value: displayName ?? process.classification.displayName) + DetailChip( + label: "Executable", + value: URL(fileURLWithPath: ProcessPresentation.executablePath(for: process)) + .lastPathComponent) + DetailChip( + label: "Project", + value: ProcessPresentation.projectName(for: process) ?? "Unknown") + } + + DetailField( + label: "Executable Path", value: ProcessPresentation.executablePath(for: process)) + DetailField( + label: "Current Directory", value: process.process.currentDirectory ?? "Unknown") + } + + DetailSection(title: "Command", systemImage: "terminal") { + DetailGrid { + DetailChip(label: "Arguments", value: "\(commandArguments.count)") + DetailChip(label: "Length", value: "\(process.process.command.count) chars") + DetailChip( + label: "Redacted", + value: redactedCommand == process.process.command ? "No" : "Yes") + } + + DetailField(label: "Command", value: process.process.command, lineLimit: 10) + DetailField(label: "Redacted Command", value: redactedCommand, lineLimit: 8) + + if !commandArguments.isEmpty { + ArgumentTokenList(arguments: commandArguments) + } + } + } + .padding(16) + } + } + .transition(reduceMotion ? .identity : .opacity.combined(with: .scale(scale: 0.985))) + } else { + EmptyProcessInspector() + } + } + } + + private var redactedCommand: String { + guard let process else { + return "" + } + + return ProcessPresentation.redactedCommand(process.process.command) + } + + private var commandArguments: [String] { + guard let process else { + return [] + } + + return process.process.command + .split(whereSeparator: { $0.isWhitespace }) + .dropFirst() + .map(String.init) + } + + private var latestGPU: Double? { + metricHistory.last { $0.gpuPercent != nil }?.gpuPercent + } + + private var averageCPU: Double? { + average(metricHistory.map(\.cpuPercent)) + } + + private var peakCPU: Double? { + metricHistory.map(\.cpuPercent).max() + } + + private var averageMemory: Int64? { + let values = metricHistory.map(\.residentMemoryBytes) + guard !values.isEmpty else { + return nil + } + + return values.reduce(Int64(0), +) / Int64(values.count) + } + + private var peakMemory: Int64? { + metricHistory.map(\.residentMemoryBytes).max() + } + + private func average(_ values: [Double]) -> Double? { + guard !values.isEmpty else { + return nil + } + + return values.reduce(0, +) / Double(values.count) + } +} + +private struct EmptyProcessInspector: View { + var body: some View { + VStack(spacing: 16) { + Spacer(minLength: 24) + + Image(systemName: "cursorarrow.rays") + .font(.system(size: 38, weight: .medium)) + .foregroundStyle(.tertiary) + .frame(width: 68, height: 68) + .softPanel(cornerRadius: 16) + + VStack(spacing: 6) { + Text("Select a process") + .font(.title3.weight(.semibold)) + .lineLimit(1) + Text( + "Choose any row to inspect identity, command, resources, process tree, workflow context, and safe actions." + ) + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: 300) + } + + VStack(alignment: .leading, spacing: 8) { + EmptyInspectorLine( + symbolName: "gauge.with.dots.needle.67percent", title: "Resources", + detail: "CPU, memory, GPU sample, peaks, averages, and sample count") + EmptyInspectorLine( + symbolName: "point.3.connected.trianglepath.dotted", title: "Tree", + detail: "Parent, children, and retained ended-process state") + EmptyInspectorLine( + symbolName: "terminal", title: "Command", + detail: "Full command, redacted command, argument count, and copyable fields") + } + .padding(14) + .frame(maxWidth: 340, alignment: .leading) + .softPanel(cornerRadius: 14) + + Spacer(minLength: 24) + } + .padding(18) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +private struct EmptyInspectorLine: View { + let symbolName: String + let title: String + let detail: String + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: symbolName) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(DevScopePalette.accent) + .frame(width: 18) + + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.caption.weight(.semibold)) + Text(detail) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } +} + +private struct DetailSection: View { + let title: String + let systemImage: String + @ViewBuilder let content: Content + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Label(title, systemImage: systemImage) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.primary) + + VStack(alignment: .leading, spacing: 10) { + content + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} + +private struct DetailGrid: View { + @ViewBuilder let content: Content + private let columns = [ + GridItem(.adaptive(minimum: 82, maximum: 160), spacing: 8, alignment: .topLeading) + ] + + var body: some View { + LazyVGrid(columns: columns, alignment: .leading, spacing: 8) { + content + } + } +} + +private struct FlowTagList: View { + let tags: [DevProcessTag] + + var body: some View { + HStack(spacing: 6) { + ForEach(tags) { tag in + ProcessTagBadge(tag: tag) + } + } + } +} + +private struct WorkflowContextCard: View { + let workflow: DevWorkflow + let workflowNote: String? + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .top, spacing: 10) { + Image(systemName: workflow.kind.symbolName) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(workflowColor) + .frame(width: 24) + + VStack(alignment: .leading, spacing: 4) { + Text(workflow.title) + .font(.headline) + .lineLimit(1) + Text(workflow.summary) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer() + + Text(workflow.risk.rawValue) + .font(.caption.weight(.semibold)) + .foregroundStyle(riskColor) + } + + if let workflowNote { + Label(workflowNote, systemImage: "sparkles") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + } + .padding(12) + .softPanel(cornerRadius: 12) + } + + private var workflowColor: Color { + switch workflow.kind { + case .aiMLLab, .localLLMStack: + .pink + case .notebookSession, .dataApp: + .cyan + case .trainingRun: + .orange + case .apiService, .webWorkspace: + .indigo + case .vectorDatabase: + .purple + case .buildWorkspace: + .blue + case .projectWorkspace, .runtimeGroup: + DevScopePalette.accent + } + } + + private var riskColor: Color { + switch workflow.risk { + case .normal: + .secondary + case .busy: + .orange + case .heavy: + .red + } + } +} + +private struct ProcessSafetySummary: View { + let insight: DevProcessInsight + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Label("At a glance", systemImage: "lightbulb") + .font(.subheadline.weight(.semibold)) + + VStack(alignment: .leading, spacing: 6) { + InsightLine(title: "Role", value: insight.role) + InsightLine(title: "Impact", value: insight.resourceBehavior) + InsightLine(title: "Context", value: insight.workflowContext) + InsightLine(title: "Safe action", value: insight.safeAction) + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .softPanel(cornerRadius: 12) + } +} + +private struct InsightLine: View { + let title: String + let value: String + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.caption2.weight(.semibold)) + .foregroundStyle(.tertiary) + Text(value) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } +} + +private struct DetailHeader: View { + let process: ClassifiedDevProcess + let title: String + + var body: some View { + HStack(alignment: .top, spacing: 12) { + ZStack { + RoundedRectangle(cornerRadius: 13, style: .continuous) + .fill(DevScopePalette.color(for: process.classification.kind).opacity(0.18)) + Image(systemName: process.classification.kind.symbolName) + .font(.system(size: 22, weight: .semibold)) + .foregroundStyle(DevScopePalette.color(for: process.classification.kind)) + } + .frame(width: 48, height: 48) + .devScopeGlass(cornerRadius: 13) + + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.title3.weight(.semibold)) + .lineLimit(2) + + Text( + process.classification.displayName == title + ? ProcessPresentation.contextLabel(for: process) : process.classification.displayName + ) + .font(.callout) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Spacer() + } + } +} + +private struct DetailChip: View { + let label: String + let value: String + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(label) + .font(.caption2.weight(.medium)) + .foregroundStyle(.tertiary) + Text(value) + .font(.caption.monospacedDigit().weight(.semibold)) + .lineLimit(1) + .truncationMode(.middle) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 10) + .padding(.vertical, 7) + .softPanel(cornerRadius: 10) + } +} + +private struct DetailField: View { + let label: String + let value: String + var lineLimit: Int = 4 + + var body: some View { + VStack(alignment: .leading, spacing: 7) { + HStack { + Text(label) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + + Spacer() + + Button { + copy(value) + } label: { + Image(systemName: "doc.on.doc") + } + .buttonStyle(.borderless) + .accessibilityLabel("Copy \(label.lowercased())") + .accessibilityHint("Copies the \(label.lowercased()) value to the clipboard.") + .help("Copy \(label.lowercased())") + } + + Text(value) + .font(.system(.callout, design: .monospaced)) + .textSelection(.enabled) + .lineLimit(lineLimit) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(11) + .background( + .quaternary.opacity(0.34), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + } + } + + private func copy(_ value: String) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(value, forType: .string) + } +} + +private struct ArgumentTokenList: View { + let arguments: [String] + private let maxVisibleArguments = 18 + + var body: some View { + VStack(alignment: .leading, spacing: 7) { + Text("Arguments") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + + FlowLayout(spacing: 6) { + ForEach(Array(arguments.prefix(maxVisibleArguments).enumerated()), id: \.offset) { + _, argument in + Text(argument) + .font(.caption2.monospaced()) + .lineLimit(1) + .truncationMode(.middle) + .padding(.horizontal, 7) + .padding(.vertical, 4) + .background(.quaternary.opacity(0.34), in: Capsule()) + .help(argument) + } + + if arguments.count > maxVisibleArguments { + Text("+\(arguments.count - maxVisibleArguments)") + .font(.caption2.monospaced().weight(.semibold)) + .foregroundStyle(.secondary) + .padding(.horizontal, 7) + .padding(.vertical, 4) + .background(.quaternary.opacity(0.34), in: Capsule()) + } + } + } + } +} + +private struct FlowLayout: Layout { + var spacing: CGFloat = 6 + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + layout(in: proposal.width ?? 320, subviews: subviews).size + } + + func placeSubviews( + in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout () + ) { + let result = layout(in: bounds.width, subviews: subviews) + for item in result.items { + subviews[item.index].place( + at: CGPoint(x: bounds.minX + item.origin.x, y: bounds.minY + item.origin.y), + proposal: ProposedViewSize(item.size) + ) + } + } + + private func layout(in width: CGFloat, subviews: Subviews) -> ( + items: [(index: Int, origin: CGPoint, size: CGSize)], size: CGSize + ) { + var items: [(index: Int, origin: CGPoint, size: CGSize)] = [] + var x: CGFloat = 0 + var y: CGFloat = 0 + var rowHeight: CGFloat = 0 + let availableWidth = max(width, 1) + + for index in subviews.indices { + let measuredSize = subviews[index].sizeThatFits( + ProposedViewSize(width: availableWidth, height: nil) + ) + let size = CGSize( + width: min(measuredSize.width, availableWidth), + height: measuredSize.height + ) + if x > 0, x + size.width > availableWidth { + x = 0 + y += rowHeight + spacing + rowHeight = 0 + } + + items.append((index: index, origin: CGPoint(x: x, y: y), size: size)) + x += size.width + spacing + rowHeight = max(rowHeight, size.height) + } + + return (items, CGSize(width: availableWidth, height: y + rowHeight)) + } +} diff --git a/Sources/DevScope/Views/ProcessHierarchyView.swift b/Sources/DevScope/Views/ProcessHierarchyView.swift new file mode 100644 index 0000000..b59167a --- /dev/null +++ b/Sources/DevScope/Views/ProcessHierarchyView.swift @@ -0,0 +1,69 @@ +import DevScopeCore +import SwiftUI + +struct ProcessHierarchyView: View { + let nodes: [ProcessHierarchyNode] + @Binding var selection: Int32? + let displayName: (ClassifiedDevProcess) -> String + + var body: some View { + ZStack { + List(selection: $selection) { + OutlineGroup(nodes, children: \.outlineChildren) { node in + hierarchyRow(node) + .tag(node.id) + } + } + .listStyle(.inset) + + if nodes.isEmpty { + ContentUnavailableView( + "No matching hierarchy", + systemImage: "point.3.connected.trianglepath.dotted", + description: Text("Try a different search or refresh the process snapshot.") + ) + } + } + } + + private func hierarchyRow(_ node: ProcessHierarchyNode) -> some View { + let item = node.item + + return HStack(spacing: 10) { + Image(systemName: item.classification.kind.symbolName) + .font(.caption.weight(.semibold)) + .foregroundStyle(DevScopePalette.color(for: item.classification.kind)) + .frame(width: 18) + + VStack(alignment: .leading, spacing: 2) { + Text(displayName(item)) + .font(.callout) + .lineLimit(1) + HStack(spacing: 8) { + Text("PID \(item.process.pid)") + Text("PPID \(item.process.parentPID)") + if !node.children.isEmpty { + Text("\(node.children.count) child\(node.children.count == 1 ? "" : "ren")") + } + } + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + + Spacer(minLength: 12) + + Text(ProcessMetricFormat.cpu(item.process.resourceUsage?.cpuPercent)) + .font(.callout.monospacedDigit()) + .frame(width: 76, alignment: .trailing) + Text(ProcessMetricFormat.memory(item.process.resourceUsage?.residentMemoryBytes)) + .font(.callout.monospacedDigit()) + .frame(width: 96, alignment: .trailing) + } + .padding(.vertical, 3) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(displayName(item)), PID \(item.process.pid)") + .accessibilityValue( + "\(node.children.count) direct children, \(ProcessMetricFormat.cpu(item.process.resourceUsage?.cpuPercent)), \(ProcessMetricFormat.memory(item.process.resourceUsage?.residentMemoryBytes))" + ) + } +} diff --git a/Sources/DevScope/Views/ProcessMetricHistoryView.swift b/Sources/DevScope/Views/ProcessMetricHistoryView.swift new file mode 100644 index 0000000..7935d10 --- /dev/null +++ b/Sources/DevScope/Views/ProcessMetricHistoryView.swift @@ -0,0 +1,171 @@ +import DevScopeCore +import SwiftUI + +struct ProcessMetricHistoryView: View { + let samples: [DevProcessMetricSample] + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("Behavior") + .font(.headline) + Spacer() + Text(windowText) + .font(.caption.monospacedDigit()) + .foregroundStyle(.tertiary) + } + + MetricChart(samples: samples) + .frame(height: 120) + + HStack(spacing: 12) { + LegendSwatch(color: .cyan, label: "CPU") + LegendSwatch(color: .purple, label: "Memory") + if hasGPUSamples { + LegendSwatch(color: .orange, label: "GPU") + } + Spacer() + Text(summaryText) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .padding(12) + .softPanel(cornerRadius: 12) + } + + private var windowText: String { + guard let first = samples.first?.timestamp, + let last = samples.last?.timestamp, + last > first + else { + return "collecting" + } + + return "\(Int(last.timeIntervalSince(first)))s" + } + + private var summaryText: String { + guard let latest = samples.last else { + return "No samples yet" + } + + return + "\(ProcessMetricFormat.cpu(latest.cpuPercent)) · \(ProcessMetricFormat.memory(latest.residentMemoryBytes)) · GPU \(ProcessMetricFormat.gpu(latest.gpuPercent))" + } + + private var hasGPUSamples: Bool { + samples.contains { $0.gpuPercent != nil } + } +} + +private struct MetricChart: View { + let samples: [DevProcessMetricSample] + + var body: some View { + Canvas { context, size in + let rect = CGRect(origin: .zero, size: size).insetBy(dx: 8, dy: 8) + drawGrid(in: rect, context: &context) + + guard samples.count > 1 else { + return + } + + let cpuMax = max(100, samples.map(\.cpuPercent).max() ?? 100) + let memoryMax = max(1, samples.map(\.residentMemoryBytes).max() ?? 1) + + context.stroke( + path(for: samples.map(\.cpuPercent), maxValue: cpuMax, in: rect), + with: .color(.cyan), + style: StrokeStyle(lineWidth: 2.2, lineCap: .round, lineJoin: .round) + ) + context.stroke( + path( + for: samples.map { Double($0.residentMemoryBytes) }, maxValue: Double(memoryMax), in: rect + ), + with: .color(.purple), + style: StrokeStyle(lineWidth: 2.2, lineCap: .round, lineJoin: .round) + ) + + let gpuValues = samples.compactMap(\.gpuPercent) + if gpuValues.count == samples.count { + context.stroke( + path(for: gpuValues, maxValue: 100, in: rect), + with: .color(.orange), + style: StrokeStyle(lineWidth: 2.2, lineCap: .round, lineJoin: .round) + ) + } + } + .background( + .quaternary.opacity(0.24), in: RoundedRectangle(cornerRadius: 10, style: .continuous) + ) + .overlay { + if samples.count < 2 { + Text("Collecting live samples") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel("Process resource history") + .accessibilityValue(accessibilitySummary) + } + + private var accessibilitySummary: String { + guard let first = samples.first, let last = samples.last else { + return "No samples yet" + } + let cpuValues = samples.map(\.cpuPercent) + let memoryValues = samples.map(\.residentMemoryBytes) + let duration = max(0, Int(last.timestamp.timeIntervalSince(first.timestamp))) + return + "\(samples.count) samples over \(duration) seconds. CPU from \(ProcessMetricFormat.cpu(cpuValues.min())) to \(ProcessMetricFormat.cpu(cpuValues.max())). Memory from \(ProcessMetricFormat.memory(memoryValues.min())) to \(ProcessMetricFormat.memory(memoryValues.max()))." + } + + private func drawGrid(in rect: CGRect, context: inout GraphicsContext) { + var grid = Path() + for fraction in stride(from: 0.25, through: 0.75, by: 0.25) { + let y = rect.minY + rect.height * fraction + grid.move(to: CGPoint(x: rect.minX, y: y)) + grid.addLine(to: CGPoint(x: rect.maxX, y: y)) + } + context.stroke(grid, with: .color(.secondary.opacity(0.18)), lineWidth: 1) + } + + private func path(for values: [Double], maxValue: Double, in rect: CGRect) -> Path { + var path = Path() + let denominator = max(1, values.count - 1) + + for (index, value) in values.enumerated() { + let x = rect.minX + rect.width * CGFloat(index) / CGFloat(denominator) + let normalized = max(0, min(1, value / maxValue)) + let y = rect.maxY - rect.height * CGFloat(normalized) + let point = CGPoint(x: x, y: y) + + if index == 0 { + path.move(to: point) + } else { + path.addLine(to: point) + } + } + + return path + } +} + +private struct LegendSwatch: View { + let color: Color + let label: String + + var body: some View { + HStack(spacing: 5) { + Circle() + .fill(color) + .frame(width: 7, height: 7) + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + } + } +} diff --git a/Sources/DevScope/Views/ProcessScopePicker.swift b/Sources/DevScope/Views/ProcessScopePicker.swift new file mode 100644 index 0000000..0ca75c7 --- /dev/null +++ b/Sources/DevScope/Views/ProcessScopePicker.swift @@ -0,0 +1,22 @@ +import DevScopeCore +import SwiftUI + +struct ProcessScopePicker: View { + @Binding var selection: ProcessActivityScope + + var body: some View { + Picker("Activity scope", selection: $selection) { + ForEach(ProcessActivityScope.allCases) { scope in + Label(scope.title, systemImage: scope.symbolName) + .tag(scope) + } + } + .pickerStyle(.segmented) + .controlSize(.small) + .fixedSize() + .accessibilityHint( + "Switches between application families, flat OS processes, parent-child hierarchy, and project workflows." + ) + .help("Choose how running OS processes are grouped") + } +} diff --git a/Sources/DevScope/Views/ProcessSelectionPolicy.swift b/Sources/DevScope/Views/ProcessSelectionPolicy.swift new file mode 100644 index 0000000..8c96223 --- /dev/null +++ b/Sources/DevScope/Views/ProcessSelectionPolicy.swift @@ -0,0 +1,36 @@ +import DevScopeCore + +struct ProcessSelectionResolution: Equatable { + let selectedProcessID: Int32? + let retainedProcess: ClassifiedDevProcess? +} + +enum ProcessSelectionPolicy { + static func reconcile( + selectedProcessID: Int32?, + retainedProcess: ClassifiedDevProcess?, + currentProcesses: [ClassifiedDevProcess] + ) -> ProcessSelectionResolution { + guard let selectedProcessID else { + return ProcessSelectionResolution(selectedProcessID: nil, retainedProcess: nil) + } + guard let current = currentProcesses.first(where: { $0.process.pid == selectedProcessID }) else { + return ProcessSelectionResolution( + selectedProcessID: selectedProcessID, + retainedProcess: retainedProcess + ) + } + if let retainedProcess, + retainedProcess.process.pid == selectedProcessID { + guard let retainedBirth = retainedProcess.process.birthToken, + let currentBirth = current.process.birthToken, + retainedBirth == currentBirth else { + return ProcessSelectionResolution(selectedProcessID: nil, retainedProcess: nil) + } + } + return ProcessSelectionResolution( + selectedProcessID: selectedProcessID, + retainedProcess: current + ) + } +} diff --git a/Sources/DevScope/Views/ProcessStatsPanelView.swift b/Sources/DevScope/Views/ProcessStatsPanelView.swift new file mode 100644 index 0000000..c90b932 --- /dev/null +++ b/Sources/DevScope/Views/ProcessStatsPanelView.swift @@ -0,0 +1,1199 @@ +import DevScopeCore +import Foundation +import SwiftUI + +private enum SystemLoadFormat { + static var cpuCapacity: Double { + max(1, Double(ProcessInfo.processInfo.activeProcessorCount) * 100) + } + + static func cpuCapacityPercent(_ aggregateCPU: Double?) -> String { + guard let aggregateCPU else { + return "-" + } + + return "\(Int((cpuLoadFraction(aggregateCPU) * 100).rounded()))% cap" + } + + static func cpuLoadPercent(_ aggregateCPU: Double) -> Double { + cpuLoadFraction(aggregateCPU) * 100 + } + + static func cpuLoadFraction(_ aggregateCPU: Double) -> Double { + min(max(aggregateCPU / cpuCapacity, 0), 1) + } + + static func pressureScore(for sample: DevProcessMetricSample?) -> Double { + guard let sample else { + return 0 + } + + let cpuLoad = cpuLoadFraction(sample.cpuPercent) + let gpuLoad = min(max((sample.gpuPercent ?? 0) / 100, 0), 1) + let memoryLoad = min( + max(Double(sample.residentMemoryBytes) / Double(ProcessInfo.processInfo.physicalMemory), 0), 1 + ) + let weighted = cpuLoad * 0.40 + gpuLoad * 0.35 + memoryLoad * 0.25 + + return min(max(max(weighted, max(cpuLoad, gpuLoad, memoryLoad) * 0.72), 0), 1) + } + + static func pressureLabel(for score: Double) -> String { + switch score { + case 0..<0.25: + "Low" + case 0.25..<0.50: + "Normal" + case 0.50..<0.75: + "Busy" + case 0.75..<0.90: + "Heavy" + default: + "Critical" + } + } + + static func pressureTint(for score: Double) -> Color { + switch score { + case 0..<0.25: + .green + case 0.25..<0.50: + DevScopePalette.accent + case 0.50..<0.75: + .orange + default: + .red + } + } +} + +enum LiveNotchPresentationState: Equatable { + case collapsed + case compact + case expanded + + var isVisible: Bool { + self != .collapsed + } + + var isExpanded: Bool { + self == .expanded + } +} + +struct DynamicIslandStatusView: View { + @Environment(\.accessibilityReduceMotion) private var reduceMotion + let stats: ProcessDashboardStats + let metricHistory: [DevProcessMetricSample] + let selectedScope: String + let isRefreshing: Bool + let lastRefresh: Date? + let refreshAction: @MainActor () -> Void + let displayName: @MainActor (ClassifiedDevProcess) -> String + @ObservedObject var presentationModel: LiveNotchPresentationModel + @State private var isSystemStatsPresented = false + + private var presentationAnimation: Animation? { + reduceMotion ? nil : .spring(response: 0.28, dampingFraction: 0.86) + } + + private var islandTransition: AnyTransition { + if reduceMotion { + return .opacity + } + + return .asymmetric( + insertion: .opacity.combined(with: .move(edge: .top)).combined( + with: .scale(scale: 0.96, anchor: .top)), + removal: .opacity.combined(with: .move(edge: .top)) + ) + } + + private var expandedContentTransition: AnyTransition { + reduceMotion ? .opacity : .opacity.combined(with: .move(edge: .top)) + } + + private var isVisible: Bool { + presentationModel.state.isVisible + } + + private var isExpanded: Bool { + presentationModel.state.isExpanded + } + + var body: some View { + ZStack(alignment: .top) { + notchHoverTarget + + ZStack(alignment: .top) { + if isVisible { + islandSurface + .padding(.top, 5) + .transition(islandTransition) + } + } + .animation(presentationAnimation, value: presentationModel.state) + } + .frame( + width: isVisible ? (isExpanded ? 780 : 640) : 128, + height: isVisible ? (isExpanded ? 178 : 52) : 18, alignment: .top + ) + .sheet(isPresented: $isSystemStatsPresented) { + SystemStatsSheet(samples: metricHistory, runtimeCounts: stats.runtimeCounts) + } + } + + private var notchHoverTarget: some View { + Capsule() + .fill(isVisible ? Color.primary.opacity(0.10) : Color.black.opacity(0.001)) + .overlay { + Capsule() + .stroke(isVisible ? Color.white.opacity(0.10) : Color.clear, lineWidth: 1) + } + .frame(width: 112, height: 12) + .contentShape(Capsule()) + .accessibilityLabel("DevScope live notch") + .accessibilityHint("Hover to reveal live process status.") + .help("Hover to reveal DevScope Live") + } + + private var islandSurface: some View { + VStack(alignment: .leading, spacing: isExpanded ? 10 : 0) { + compactRow + .contentShape(Capsule()) + + if isExpanded { + expandedContent + .transition(expandedContentTransition) + } + } + .padding(.horizontal, isExpanded ? 14 : 12) + .padding(.vertical, isExpanded ? 12 : 9) + .frame(width: isExpanded ? 760 : 620, alignment: .leading) + .background { + RoundedRectangle(cornerRadius: isExpanded ? 30 : 24, style: .continuous) + .fill(.black.opacity(0.70)) + .background( + .regularMaterial, + in: RoundedRectangle(cornerRadius: isExpanded ? 30 : 24, style: .continuous) + ) + .overlay { + RoundedRectangle(cornerRadius: isExpanded ? 30 : 24, style: .continuous) + .stroke(.white.opacity(isExpanded ? 0.16 : 0.12), lineWidth: 1) + } + .overlay(alignment: .top) { + Capsule() + .fill(.white.opacity(isExpanded ? 0.16 : 0.10)) + .frame(width: isExpanded ? 58 : 44, height: 2) + .padding(.top, 4) + } + } + .overlay { + RoundedRectangle(cornerRadius: isExpanded ? 30 : 24, style: .continuous) + .stroke(pressureTint.opacity(isExpanded ? 0.16 : 0.10), lineWidth: 1) + .blendMode(.plusLighter) + } + .shadow(color: .black.opacity(0.28), radius: isExpanded ? 20 : 14, y: isExpanded ? 10 : 7) + .animation(presentationAnimation, value: isExpanded) + .accessibilityElement(children: .combine) + .accessibilityLabel("Live DevScope status") + .accessibilityValue(accessibilityStatus) + .help("Hover the top-center notch to reveal live status.") + } + + private var compactRow: some View { + HStack(spacing: 8) { + LivePulseDot(tint: pressureTint, isActive: isRefreshing || stats.totalCount > 0) + + Text("DevScope") + .font(.callout.weight(.semibold)) + .foregroundStyle(.white) + .lineLimit(1) + .layoutPriority(3) + + Text("\(stats.totalCount)") + .font(.callout.monospacedDigit().weight(.bold)) + .foregroundStyle(DevScopePalette.accent) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(DevScopePalette.accent.opacity(0.18), in: Capsule()) + + Text(selectedScope) + .font(.caption.weight(.medium)) + .foregroundStyle(.white.opacity(0.70)) + .lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: 110, alignment: .leading) + .layoutPriority(1) + + Spacer(minLength: 8) + + LiveIslandMetricLabel(title: "CPU", value: cpuText, tint: .orange) + LiveIslandMetricLabel(title: "GPU", value: gpuText, tint: .green) + LiveIslandMetricLabel(title: "MEM", value: memoryText, tint: .cyan) + + Label(pressureLabel, systemImage: "gauge.with.dots.needle.67percent") + .font(.caption.weight(.semibold)) + .foregroundStyle(pressureTint) + .labelStyle(.titleAndIcon) + .lineLimit(1) + .layoutPriority(1) + + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + .font(.caption.weight(.bold)) + .foregroundStyle(.white.opacity(0.55)) + } + } + + private var expandedContent: some View { + VStack(alignment: .leading, spacing: 10) { + Divider() + .overlay(.white.opacity(0.14)) + + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + GraphLegendItem(title: "CPU", color: .orange) + GraphLegendItem(title: "GPU", color: .green) + GraphLegendItem(title: "Memory", color: .cyan) + Spacer() + Text(lastRefreshText) + .font(.caption2.monospacedDigit()) + .foregroundStyle(.white.opacity(0.46)) + } + + LiveIslandSparkline(samples: metricHistory) + .frame(height: 42) + } + .frame(minWidth: 300, maxWidth: .infinity, alignment: .leading) + + VStack(alignment: .leading, spacing: 8) { + LiveIslandTopProcessRow( + title: "Top Memory", process: stats.topMemory, displayName: displayName) + } + .frame(width: 170, alignment: .leading) + + VStack(spacing: 8) { + LiveIslandActionButton( + title: "Refresh", symbolName: "arrow.clockwise", tint: DevScopePalette.accent + ) { + refreshAction() + } + + LiveIslandActionButton(title: "Inspect", symbolName: "chart.xyaxis.line", tint: .orange) { + isSystemStatsPresented = true + } + } + .frame(width: 112) + } + } + } + + private var latest: DevProcessMetricSample? { + metricHistory.last + } + + private var cpuText: String { + SystemLoadFormat.cpuCapacityPercent(latest?.cpuPercent) + } + + private var gpuText: String { + ProcessMetricFormat.gpu(latest?.gpuPercent) + } + + private var memoryText: String { + ProcessMetricFormat.memory(latest?.residentMemoryBytes) + } + + private var pressureScore: Double { + SystemLoadFormat.pressureScore(for: latest) + } + + private var pressureLabel: String { + switch pressureScore { + case 0..<0.25: + "Low" + case 0.25..<0.50: + "Normal" + case 0.50..<0.75: + "Busy" + case 0.75..<0.90: + "Heavy" + default: + "Critical" + } + } + + private var pressureTint: Color { + switch pressureScore { + case 0..<0.25: + .green + case 0.25..<0.50: + DevScopePalette.accent + case 0.50..<0.75: + .orange + default: + .red + } + } + + private var lastRefreshText: String { + guard let lastRefresh else { + return isRefreshing ? "collecting" : "waiting" + } + + return "updated \(lastRefresh.formatted(date: .omitted, time: .standard))" + } + + private var accessibilityStatus: String { + "\(stats.totalCount) running items, CPU \(cpuText), GPU \(gpuText), memory \(memoryText), machine load \(pressureLabel)" + } +} + +private struct LivePulseDot: View { + @Environment(\.accessibilityReduceMotion) private var reduceMotion + let tint: Color + let isActive: Bool + + var body: some View { + Group { + if reduceMotion { + pulse(phase: 0) + } else { + TimelineView(.animation(minimumInterval: 0.8)) { timeline in + let phase = + isActive + ? timeline.date.timeIntervalSinceReferenceDate.truncatingRemainder(dividingBy: 1.6) + / 1.6 : 0 + pulse(phase: phase) + } + } + } + .frame(width: 22, height: 22) + } + + private func pulse(phase: Double) -> some View { + ZStack { + Circle() + .fill(tint.opacity(0.24)) + .frame(width: 20, height: 20) + .scaleEffect(1 + phase * 0.55) + .opacity(isActive ? 1 - phase : 0.35) + + Circle() + .fill(tint) + .frame(width: 8, height: 8) + .shadow(color: tint.opacity(0.55), radius: 8) + } + } +} + +private struct LiveIslandMetricLabel: View { + let title: String + let value: String + let tint: Color + + var body: some View { + HStack(spacing: 4) { + Circle() + .fill(tint) + .frame(width: 6, height: 6) + Text(title) + .foregroundStyle(.white.opacity(0.48)) + Text(value) + .foregroundStyle(.white.opacity(0.82)) + .monospacedDigit() + } + .font(.caption2.weight(.semibold)) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + } +} + +private struct LiveIslandSparkline: View { + let samples: [DevProcessMetricSample] + + var body: some View { + Canvas { context, size in + drawGrid(context: &context, size: size) + drawLine( + values: samples.map { SystemLoadFormat.cpuLoadPercent($0.cpuPercent) }, + maxValue: 100, + color: .orange, + context: &context, + size: size + ) + drawLine( + values: samples.map { $0.gpuPercent ?? 0 }, + maxValue: 100, + color: .green, + context: &context, + size: size + ) + drawLine( + values: samples.map { Double($0.residentMemoryBytes) }, + maxValue: max(1, Double(samples.map(\.residentMemoryBytes).max() ?? 1)), + color: .cyan, + context: &context, + size: size + ) + } + .background(.white.opacity(0.055), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(.white.opacity(0.10), lineWidth: 1) + } + } + + private func drawGrid(context: inout GraphicsContext, size: CGSize) { + var path = Path() + for fraction in [0.33, 0.66] { + let y = size.height * fraction + path.move(to: CGPoint(x: 0, y: y)) + path.addLine(to: CGPoint(x: size.width, y: y)) + } + context.stroke(path, with: .color(.white.opacity(0.08)), lineWidth: 1) + } + + private func drawLine( + values: [Double], + maxValue: Double, + color: Color, + context: inout GraphicsContext, + size: CGSize + ) { + guard values.count > 1, maxValue > 0 else { + return + } + + var path = Path() + for index in values.indices { + let progress = Double(index) / Double(values.count - 1) + let normalizedValue = min(max(values[index] / maxValue, 0), 1) + let point = CGPoint( + x: progress * size.width, + y: size.height - normalizedValue * size.height + ) + if index == values.startIndex { + path.move(to: point) + } else { + path.addLine(to: point) + } + } + + context.stroke(path, with: .color(color.opacity(0.88)), lineWidth: 1.6) + } +} + +private struct LiveIslandTopProcessRow: View { + let title: String + let process: ClassifiedDevProcess? + let displayName: (ClassifiedDevProcess) -> String + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.caption2.weight(.medium)) + .foregroundStyle(.white.opacity(0.46)) + Text(process.map(displayName) ?? "No process") + .font(.caption.weight(.semibold)) + .foregroundStyle(.white.opacity(0.84)) + .lineLimit(1) + .truncationMode(.tail) + } + } +} + +private struct LiveIslandActionButton: View { + let title: String + let symbolName: String + let tint: Color + let action: () -> Void + + var body: some View { + Button(action: action) { + Label(title, systemImage: symbolName) + .font(.caption.weight(.semibold)) + .lineLimit(1) + .frame(maxWidth: .infinity, minHeight: 28) + } + .buttonStyle(.plain) + .foregroundStyle(tint) + .background(tint.opacity(0.14), in: Capsule()) + .overlay { + Capsule() + .stroke(tint.opacity(0.26), lineWidth: 1) + } + } +} + +struct LiveActivitiesDockView: View { + @Environment(\.accessibilityReduceMotion) private var reduceMotion + let stats: ProcessDashboardStats + let metricHistory: [DevProcessMetricSample] + let isRefreshing: Bool + let lastRefresh: Date? + @Binding var isExpanded: Bool + @State private var isSystemStatsPresented = false + + var body: some View { + GeometryReader { proxy in + let mode = LiveActivityLayoutPolicy.mode(availableWidth: proxy.size.width) + let verticalMode = LiveActivityLayoutPolicy.verticalMode( + availableHeight: proxy.size.height, + layoutMode: mode + ) + + VStack(alignment: .leading, spacing: isExpanded ? 10 : 0) { + responsiveHeader + + if isExpanded { + expandedMetrics(verticalMode: verticalMode) + .transition(reduceMotion ? .opacity : .opacity.combined(with: .move(edge: .bottom))) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .padding(12) + .devScopeGlass(cornerRadius: 16) + } + .animation(reduceMotion ? nil : .snappy(duration: 0.18), value: isExpanded) + .sheet(isPresented: $isSystemStatsPresented) { + SystemStatsSheet(samples: metricHistory, runtimeCounts: stats.runtimeCounts) + } + } + + private var responsiveHeader: some View { + ViewThatFits(in: .horizontal) { + wideHeader + compactHeader + } + } + + private var wideHeader: some View { + HStack(spacing: 12) { + headerIdentity + + Spacer(minLength: 8) + + compactMetricSummary + loadIndicator + expansionButton + } + } + + private var compactHeader: some View { + HStack(spacing: 8) { + headerIdentity + + Spacer(minLength: 4) + + loadIndicator + expansionButton + } + } + + private var headerIdentity: some View { + HStack(spacing: 12) { + Label("Live Activity", systemImage: "dock.rectangle") + .font(.callout.weight(.semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + + Text(statusText) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + + private var compactMetricSummary: some View { + HStack(spacing: 12) { + CompactLiveMetric( + title: "CPU", + value: SystemLoadFormat.cpuCapacityPercent(latestSample?.cpuPercent), + color: .orange + ) + CompactLiveMetric( + title: "GPU", + value: latestSample?.gpuPercent.map(ProcessMetricFormat.gpu) ?? "-", + color: .green + ) + CompactLiveMetric( + title: "Memory", + value: ProcessMetricFormat.memory(latestSample?.residentMemoryBytes), + color: .cyan + ) + } + } + + private var loadIndicator: some View { + Label(loadLabel, systemImage: "gauge.with.dots.needle.67percent") + .font(.caption.weight(.semibold)) + .foregroundStyle(loadTint) + .lineLimit(1) + } + + private var expansionButton: some View { + Button { + isExpanded.toggle() + } label: { + Image(systemName: isExpanded ? "chevron.down" : "chevron.up") + .frame(width: 20, height: 20) + } + .buttonStyle(.borderless) + .accessibilityLabel(isExpanded ? "Hide live activity details" : "Show live activity details") + .accessibilityHint("Toggles the detailed CPU, GPU, and memory history graph.") + .help(isExpanded ? "Hide live activity details" : "Show live activity details") + } + + @ViewBuilder + private func expandedMetrics( + verticalMode: LiveActivityVerticalMode + ) -> some View { + historyGraph(density: verticalMode == .condensed ? .condensed : .standard) + } + + private func historyGraph( + density: DashboardMetricGraphDensity = .standard + ) -> some View { + DashboardMetricGraph( + samples: metricHistory, + density: density, + showsLegend: false, + showsInspectButton: false, + showsSummary: false, + fillsAvailableHeight: true + ) { + isSystemStatsPresented = true + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + } + + private var statusText: String { + guard let lastRefresh else { + return isRefreshing ? "collecting" : "waiting" + } + + return "updated \(lastRefresh.formatted(date: .omitted, time: .standard))" + } + + private var latestSample: DevProcessMetricSample? { + metricHistory.last + } + + private var loadScore: Double { + SystemLoadFormat.pressureScore(for: latestSample) + } + + private var loadLabel: String { + SystemLoadFormat.pressureLabel(for: loadScore) + } + + private var loadTint: Color { + SystemLoadFormat.pressureTint(for: loadScore) + } +} + +private struct CompactLiveMetric: View { + let title: String + let value: String + let color: Color + + var body: some View { + HStack(spacing: 4) { + Circle() + .fill(color) + .frame(width: 5, height: 5) + Text("\(title) \(value)") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } +} + +private enum DashboardMetricGraphDensity { + case standard + case condensed +} + +private struct DashboardMetricGraph: View { + @Environment(\.accessibilityReduceMotion) private var reduceMotion + let samples: [DevProcessMetricSample] + var density: DashboardMetricGraphDensity = .standard + var showsLegend = true + var showsInspectButton = true + var showsSummary = true + var fillsAvailableHeight = false + let action: () -> Void + @State private var hoverLocation: CGPoint? + + private func selectedSample(width: CGFloat? = nil) -> DevProcessMetricSample? { + guard let hoverLocation, samples.count > 1 else { + return samples.last + } + + let graphWidth = max(1, width ?? 1) + let progress = min(max(hoverLocation.x / graphWidth, 0), 1) + let index = min( + samples.count - 1, max(0, Int((progress * CGFloat(samples.count - 1)).rounded()))) + return samples[index] + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + if density == .standard, showsLegend { + HStack(spacing: 12) { + GraphLegendItem(title: "CPU cap", color: .orange) + GraphLegendItem(title: "GPU", color: .green) + GraphLegendItem(title: "Memory", color: .cyan) + Text("Running activity") + .font(.caption2.weight(.medium)) + .foregroundStyle(.tertiary) + if showsInspectButton { + Button(action: action) { + Label("Inspect", systemImage: "arrow.up.left.and.arrow.down.right") + .font(.caption2.weight(.semibold)) + .labelStyle(.titleAndIcon) + } + .buttonStyle(.bordered) + .controlSize(.small) + .accessibilityLabel("Open system statistics") + .accessibilityHint("Opens the detailed system statistics sheet.") + .help("Open system statistics") + } + Spacer() + Text(windowText) + .font(.caption2.monospacedDigit()) + .foregroundStyle(.tertiary) + } + } + + Button(action: action) { + Canvas { context, size in + drawGrid(context: &context, size: size) + drawLine( + values: samples.map { SystemLoadFormat.cpuLoadPercent($0.cpuPercent) }, + maxValue: 100, + color: .orange, + context: &context, + size: size + ) + drawLine( + values: samples.map { $0.gpuPercent ?? 0 }, + maxValue: 100, + color: .green, + context: &context, + size: size + ) + drawLine( + values: samples.map { Double($0.residentMemoryBytes) }, + maxValue: max(1, Double(samples.map(\.residentMemoryBytes).max() ?? 1)), + color: .cyan, + context: &context, + size: size + ) + drawHover(context: &context, size: size) + } + .frame( + minHeight: graphMinimumHeight, + maxHeight: fillsAvailableHeight ? .infinity : graphMinimumHeight + ) + .background( + .black.opacity(0.13), in: RoundedRectangle(cornerRadius: 10, style: .continuous) + ) + .overlay(alignment: .topTrailing) { + if showsSummary { + Text(summaryText) + .font(.caption2.monospacedDigit()) + .foregroundStyle(.secondary) + .padding(.horizontal, 8) + .padding(.vertical, 5) + } + } + .overlay(alignment: .topLeading) { + GeometryReader { proxy in + if hoverLocation != nil, let sample = selectedSample(width: proxy.size.width) { + GraphHoverTooltip(sample: sample) + .padding(8) + .transition(reduceMotion ? .identity : .opacity.combined(with: .scale(scale: 0.98))) + } + } + } + .onContinuousHover { phase in + switch phase { + case .active(let location): + hoverLocation = location + case .ended: + hoverLocation = nil + } + } + } + .buttonStyle(.plain) + .accessibilityLabel("Open system statistics") + .accessibilityHint(summaryText) + .help("Open system statistics") + } + .frame( + maxHeight: fillsAvailableHeight ? .infinity : nil, + alignment: .topLeading + ) + } + + private var graphMinimumHeight: CGFloat { + density == .condensed ? 40 : 52 + } + + private var summaryText: String { + guard let sample = selectedSample() else { + return "waiting" + } + + let gpu = ProcessMetricFormat.gpu(sample.gpuPercent) + return + "CPU \(SystemLoadFormat.cpuCapacityPercent(sample.cpuPercent)) GPU \(gpu) MEM \(ProcessMetricFormat.memory(sample.residentMemoryBytes))" + } + + private var windowText: String { + guard let first = samples.first?.timestamp, + let last = samples.last?.timestamp, + last > first + else { + return "collecting" + } + return "\(Int(last.timeIntervalSince(first)))s" + } + + private func drawGrid(context: inout GraphicsContext, size: CGSize) { + var path = Path() + for fraction in [0.25, 0.5, 0.75] { + let y = size.height * fraction + path.move(to: CGPoint(x: 0, y: y)) + path.addLine(to: CGPoint(x: size.width, y: y)) + } + context.stroke(path, with: .color(.white.opacity(0.08)), lineWidth: 1) + } + + private func drawHover(context: inout GraphicsContext, size: CGSize) { + guard let hoverLocation, samples.count > 1 else { + return + } + + let x = min(max(hoverLocation.x, 0), size.width) + var path = Path() + path.move(to: CGPoint(x: x, y: 0)) + path.addLine(to: CGPoint(x: x, y: size.height)) + context.stroke( + path, with: .color(.white.opacity(0.32)), style: StrokeStyle(lineWidth: 1, dash: [3, 4])) + } + + private func drawLine( + values: [Double], + maxValue: Double, + color: Color, + context: inout GraphicsContext, + size: CGSize + ) { + guard values.count > 1, maxValue > 0 else { + return + } + + var path = Path() + for index in values.indices { + let progress = Double(index) / Double(values.count - 1) + let normalizedValue = min(max(values[index] / maxValue, 0), 1) + let point = CGPoint( + x: progress * size.width, + y: size.height - normalizedValue * size.height + ) + if index == values.startIndex { + path.move(to: point) + } else { + path.addLine(to: point) + } + } + + context.stroke(path, with: .color(color.opacity(0.88)), lineWidth: 1.7) + } +} + +private struct RuntimeMixTile: View { + let runtimeCounts: [DevRuntimeKind: Int] + + var body: some View { + VStack(alignment: .leading, spacing: 7) { + Label("Activity Mix", systemImage: "square.grid.2x2") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + + RuntimeMixBar(runtimeCounts: runtimeCounts, height: 8) + + HStack(spacing: 8) { + ForEach(topRuntimes.prefix(3), id: \.key) { kind, count in + HStack(spacing: 4) { + Circle() + .fill(DevScopePalette.color(for: kind)) + .frame(width: 6, height: 6) + Text(kind.rawValue) + .lineLimit(1) + Text("\(count)") + .monospacedDigit() + .foregroundStyle(.tertiary) + } + .font(.caption2) + } + } + } + .frame(width: 168, alignment: .leading) + .padding(10) + .background(.white.opacity(0.045), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(.white.opacity(0.10), lineWidth: 1) + } + } + + private var topRuntimes: [(key: DevRuntimeKind, value: Int)] { + runtimeCounts.sorted { lhs, rhs in + if lhs.value != rhs.value { + return lhs.value > rhs.value + } + return lhs.key.rawValue < rhs.key.rawValue + } + } +} + +struct RuntimeMixBar: View { + let runtimeCounts: [DevRuntimeKind: Int] + let height: CGFloat + + var body: some View { + GeometryReader { proxy in + HStack(spacing: 2) { + ForEach(sortedRuntimes, id: \.key) { kind, count in + RoundedRectangle(cornerRadius: height / 2, style: .continuous) + .fill(DevScopePalette.color(for: kind)) + .frame(width: max(4, proxy.size.width * CGFloat(count) / CGFloat(max(1, totalCount)))) + } + } + } + .frame(height: height) + .accessibilityLabel("Activity mix") + } + + private var sortedRuntimes: [(key: DevRuntimeKind, value: Int)] { + runtimeCounts.sorted { lhs, rhs in + if lhs.value != rhs.value { + return lhs.value > rhs.value + } + return lhs.key.rawValue < rhs.key.rawValue + } + } + + private var totalCount: Int { + runtimeCounts.values.reduce(0, +) + } +} + +private struct GraphHoverTooltip: View { + let sample: DevProcessMetricSample + + var body: some View { + VStack(alignment: .leading, spacing: 3) { + Text(sample.timestamp, style: .time) + .font(.caption2.weight(.semibold)) + Text("CPU \(SystemLoadFormat.cpuCapacityPercent(sample.cpuPercent))") + Text("GPU \(ProcessMetricFormat.gpu(sample.gpuPercent))") + Text("Memory \(ProcessMetricFormat.memory(sample.residentMemoryBytes))") + } + .font(.caption2.monospacedDigit()) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(.white.opacity(0.14), lineWidth: 1) + } + } +} + +private struct SystemStatsSheet: View { + let samples: [DevProcessMetricSample] + let runtimeCounts: [DevRuntimeKind: Int] + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + VStack(alignment: .leading, spacing: 3) { + Text("System Statistics") + .font(.title2.weight(.semibold)) + Text("Aggregate live samples across running items plus system GPU utilization.") + .font(.callout) + .foregroundStyle(.secondary) + } + + Spacer() + + Button("Done") { + dismiss() + } + .keyboardShortcut(.defaultAction) + } + + HStack(spacing: 12) { + SummaryPill( + title: "CPU Avg", value: SystemLoadFormat.cpuCapacityPercent(averageCPU), tint: .orange) + SummaryPill(title: "GPU Avg", value: ProcessMetricFormat.gpu(averageGPU), tint: .green) + SummaryPill( + title: "Memory Peak", value: ProcessMetricFormat.memory(peakMemory), tint: .cyan) + SummaryPill(title: "Samples", value: "\(samples.count)", tint: DevScopePalette.accent) + } + + DashboardMetricGraph(samples: samples, showsInspectButton: false) {} + .frame(minHeight: 92) + + VStack(alignment: .leading, spacing: 10) { + Text("Activity Mix") + .font(.headline) + RuntimeMixBar(runtimeCounts: runtimeCounts, height: 10) + RuntimeBreakdown(runtimeCounts: runtimeCounts) + } + + Spacer(minLength: 0) + } + .padding(20) + .frame(width: 720, height: 430) + .background(DevScopeBackground()) + } + + private var averageCPU: Double? { + guard !samples.isEmpty else { + return nil + } + return samples.reduce(0) { $0 + $1.cpuPercent } / Double(samples.count) + } + + private var averageGPU: Double? { + let values = samples.compactMap(\.gpuPercent) + guard !values.isEmpty else { + return nil + } + return values.reduce(0, +) / Double(values.count) + } + + private var peakMemory: Int64? { + samples.map(\.residentMemoryBytes).max() + } +} + +private struct SummaryPill: View { + let title: String + let value: String + let tint: Color + + var body: some View { + VStack(alignment: .leading, spacing: 5) { + Text(title) + .font(.caption) + .foregroundStyle(.secondary) + Text(value) + .font(.callout.monospacedDigit().weight(.semibold)) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(tint.opacity(0.08), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(tint.opacity(0.18), lineWidth: 1) + } + } +} + +private struct RuntimeBreakdown: View { + let runtimeCounts: [DevRuntimeKind: Int] + + var body: some View { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 130), spacing: 8)], spacing: 8) { + ForEach(sortedRuntimes, id: \.key) { kind, count in + HStack(spacing: 7) { + Circle() + .fill(DevScopePalette.color(for: kind)) + .frame(width: 8, height: 8) + Text(kind.rawValue) + .lineLimit(1) + Spacer() + Text("\(count)") + .monospacedDigit() + .foregroundStyle(.secondary) + } + .font(.caption) + .padding(.horizontal, 9) + .padding(.vertical, 7) + .background(.white.opacity(0.04), in: RoundedRectangle(cornerRadius: 9, style: .continuous)) + } + } + } + + private var sortedRuntimes: [(key: DevRuntimeKind, value: Int)] { + runtimeCounts.sorted { lhs, rhs in + if lhs.value != rhs.value { + return lhs.value > rhs.value + } + return lhs.key.rawValue < rhs.key.rawValue + } + } +} + +private struct GraphLegendItem: View { + let title: String + let color: Color + + var body: some View { + HStack(spacing: 5) { + Circle() + .fill(color) + .frame(width: 7, height: 7) + Text(title) + .font(.caption2.weight(.medium)) + .foregroundStyle(.secondary) + } + } +} + +private struct RuntimeStrip: View { + let runtimeCounts: [DevRuntimeKind: Int] + + var body: some View { + ScrollView(.horizontal) { + HStack(spacing: 8) { + ForEach(runtimeCounts.sorted(by: sortRuntime), id: \.key) { kind, count in + HStack(spacing: 5) { + Circle() + .fill(DevScopePalette.color(for: kind)) + .frame(width: 7, height: 7) + Text(kind.rawValue) + .fixedSize(horizontal: true, vertical: false) + Text("\(count)") + .monospacedDigit() + .foregroundStyle(.tertiary) + } + .font(.caption) + } + } + } + .scrollIndicators(.hidden) + .frame(maxWidth: 360, alignment: .trailing) + } + + private func sortRuntime( + _ lhs: (key: DevRuntimeKind, value: Int), + _ rhs: (key: DevRuntimeKind, value: Int) + ) -> Bool { + if lhs.value != rhs.value { + return lhs.value > rhs.value + } + return lhs.key.rawValue < rhs.key.rawValue + } +} diff --git a/Sources/DevScope/Views/ProcessTableView.swift b/Sources/DevScope/Views/ProcessTableView.swift new file mode 100644 index 0000000..529a5e0 --- /dev/null +++ b/Sources/DevScope/Views/ProcessTableView.swift @@ -0,0 +1,414 @@ +import AppKit +import DevScopeCore +import SwiftUI + +struct ProcessTableRow: Identifiable, Equatable, Sendable { + let item: ClassifiedDevProcess + let title: String + let isFavorite: Bool + let isWatched: Bool + let automationBadges: [AutomationProcessBadge] + let snapshotOrder: Int + + var id: Int32 { item.process.pid } + var processName: String { title } + var pid: Int32 { item.process.pid } + var cpuPercent: Double { item.process.resourceUsage?.cpuPercent ?? 0 } + var memoryBytes: Int64 { item.process.resourceUsage?.residentMemoryBytes ?? 0 } + var elapsedTime: String { item.process.resourceUsage?.elapsedTime ?? "-" } + var elapsedSeconds: Int64 { ProcessPresentation.elapsedSeconds(elapsedTime) } + var command: String { item.process.command } + var runtime: String { item.classification.kind.rawValue } + var stableOrderKey: String { + [ + String(format: "%08d", snapshotOrder), + ProcessPresentation.projectName(for: item) ?? "", + title, + item.process.executableName, + "\(item.process.pid)" + ].joined(separator: "\u{1F}") + } +} + +struct ProcessTableView: View { + let rows: [ProcessTableRow] + let emptyTitle: String + let emptyDescription: String + @Binding var selection: Int32? + @Binding var sort: ProcessTableSortState + let copyCommand: (ClassifiedDevProcess) -> Void + @Environment(\.controlActiveState) private var controlActiveState + @State private var hoveredRowID: Int32? + @State private var verticalScrollTargetID: Int32? + @FocusState private var isTableFocused: Bool + + init( + rows: [ProcessTableRow], + emptyTitle: String = "No matching running items", + emptyDescription: String = "Refresh after opening an app, starting a service, running a script, or launching a build watcher.", + selection: Binding, + sort: Binding, + copyCommand: @escaping (ClassifiedDevProcess) -> Void + ) { + self.rows = rows + self.emptyTitle = emptyTitle + self.emptyDescription = emptyDescription + _selection = selection + _sort = sort + self.copyCommand = copyCommand + } + + var body: some View { + ZStack { + ScrollView(.horizontal) { + VStack(spacing: 0) { + header + + ScrollView(.vertical) { + LazyVStack(spacing: 0) { + ForEach(rows) { row in + dataRow(for: row) + .id(row.id) + } + } + .scrollTargetLayout() + } + .scrollPosition(id: $verticalScrollTargetID, anchor: .center) + } + .frame(minWidth: 920, maxWidth: .infinity, alignment: .topLeading) + } + + if rows.isEmpty { + ContentUnavailableView( + emptyTitle, + systemImage: emptySystemImage, + description: Text(emptyDescription) + ) + } + } + .focusable() + .focused($isTableFocused) + .focusEffectDisabled() + .onKeyPress(.upArrow) { + moveSelection(.previous) + return .handled + } + .onKeyPress(.downArrow) { + moveSelection(.next) + return .handled + } + } + + private var header: some View { + HStack(spacing: 0) { + sortableHeader("Process", column: .process) + .frame(width: 230, alignment: .leading) + sortableHeader("PID", column: .pid) + .frame(width: 76, alignment: .leading) + sortableHeader("CPU", column: .cpu) + .frame(width: 76, alignment: .leading) + sortableHeader("Memory", column: .memory) + .frame(width: 96, alignment: .leading) + sortableHeader("Time", column: .time) + .frame(width: 116, alignment: .leading) + sortableHeader("Command", column: .command) + .frame(width: 326, alignment: .leading) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(.ultraThinMaterial) + .overlay(alignment: .bottom) { + Divider() + } + } + + private func dataRow(for row: ProcessTableRow) -> some View { + Button { + selection = row.id + isTableFocused = true + } label: { + HStack(spacing: 0) { + ProcessCell(row: row) + .frame(width: 230, alignment: .leading) + MonospaceValue("\(row.pid)") + .frame(width: 76, alignment: .leading) + MetricValue(ProcessMetricFormat.cpu(row.cpuPercent)) + .frame(width: 76, alignment: .leading) + MetricValue(ProcessMetricFormat.memory(row.memoryBytes)) + .frame(width: 96, alignment: .leading) + MonospaceValue(row.elapsedTime) + .frame(width: 116, alignment: .leading) + Text(row.command) + .font(.callout) + .lineLimit(1) + .truncationMode(.middle) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .frame(width: 326, alignment: .leading) + } + .padding(.horizontal, 12) + .padding(.vertical, 4) + .contentShape(Rectangle()) + .background(rowBackground(for: row)) + .overlay(alignment: .leading) { + if row.id == selection { + Rectangle() + .fill(DevScopePalette.accent) + .frame(width: 3) + } + } + .foregroundStyle(.primary) + } + .buttonStyle(.plain) + .onHover { hovering in + hoveredRowID = hovering ? row.id : (hoveredRowID == row.id ? nil : hoveredRowID) + } + .contextMenu { + processContextMenu(for: row.item) + } + .accessibilityElement(children: .combine) + .accessibilityLabel("\(row.processName), \(row.runtime), PID \(row.pid)") + .accessibilityValue("\(ProcessMetricFormat.cpu(row.cpuPercent)), \(ProcessMetricFormat.memory(row.memoryBytes))") + .accessibilityAddTraits(row.id == selection ? .isSelected : []) + } + + private func rowBackground(for row: ProcessTableRow) -> Color { + if row.id == selection { + return DevScopePalette.accent.opacity(controlActiveState == .inactive ? 0.10 : 0.20) + } + if row.id == hoveredRowID { + return Color.primary.opacity(0.055) + } + return row.snapshotOrder.isMultiple(of: 2) ? Color.white.opacity(0.025) : Color.clear + } + + private func moveSelection(_ direction: InterfaceSelectionDirection) { + let nextSelection = InterfacePresentation.movedSelection( + in: rows.map(\.id), + current: selection, + direction: direction + ) + guard nextSelection != selection else { + return + } + selection = nextSelection + if let nextSelection { + verticalScrollTargetID = nextSelection + } + } + + private func sortableHeader(_ title: String, column: ProcessTableSortColumn) -> some View { + SortableTableHeader( + title: title, + column: column, + activeColumn: sort.column, + direction: sort.direction, + activate: { sort.activate($0) } + ) + } + + private var emptySystemImage: String { + emptyTitle == "Process scanning blocked" ? "lock.shield" : "checkmark.seal" + } + + @ViewBuilder + private func processContextMenu(for item: ClassifiedDevProcess) -> some View { + Button("Copy Command") { + copyCommand(item) + } + + Button("Copy PID") { + copy("\(item.process.pid)") + } + + if let currentDirectory = item.process.currentDirectory { + Button("Open Folder") { + NSWorkspace.shared.open(URL(fileURLWithPath: currentDirectory, isDirectory: true)) + } + } + } + + private func copy(_ value: String) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(value, forType: .string) + } +} + +private struct ProcessCell: View { + let row: ProcessTableRow + + private var item: ClassifiedDevProcess { row.item } + + var body: some View { + HStack(spacing: 10) { + ZStack { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(DevScopePalette.color(for: item.classification.kind).opacity(0.16)) + Image(systemName: item.classification.kind.symbolName) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(DevScopePalette.color(for: item.classification.kind)) + } + .frame(width: 28, height: 28) + + VStack(alignment: .leading, spacing: 2) { + Text(row.title) + .font(.callout.weight(.semibold)) + .lineLimit(1) + .truncationMode(.tail) + + HStack(spacing: 5) { + if row.isFavorite { + Image(systemName: "star.fill") + .foregroundStyle(.yellow) + .help("Favorite") + } + + if row.isWatched { + Image(systemName: "eye.fill") + .foregroundStyle(.cyan) + .help("Watched") + } + + Text(ProcessPresentation.contextLabel(for: item)) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + + ForEach(item.classification.tags.prefix(2)) { tag in + ProcessTagBadge(tag: tag) + } + } + + if !row.automationBadges.isEmpty { + HStack(spacing: 5) { + ForEach(Array(row.automationBadges.enumerated()), id: \.offset) { _, badge in + AutomationProcessBadgeView(badge: badge) + } + } + } + } + } + .padding(.vertical, 5) + } +} + +private struct AutomationProcessBadgeView: View { + let badge: AutomationProcessBadge + + var body: some View { + Label(title, systemImage: symbolName) + .font(.caption2.weight(.semibold)) + .lineLimit(1) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(tint.opacity(0.14), in: Capsule()) + .foregroundStyle(tint) + .accessibilityLabel(accessibilityLabel) + .help(accessibilityLabel) + } + + private var title: String { + switch badge { + case .automated: "Automated" + case .longRunning(let duration): "Long Running · \(duration)" + } + } + + private var symbolName: String { + switch badge { + case .automated: "gearshape.2" + case .longRunning: "clock.badge.exclamationmark" + } + } + + private var tint: Color { + switch badge { + case .automated: DevScopePalette.accent + case .longRunning: .orange + } + } + + private var accessibilityLabel: String { + switch badge { + case .automated: "Automated: linked to a verified automation definition" + case .longRunning(let duration): "Long Running: crossed the configured threshold, running for \(duration)" + } + } +} + +struct ProcessTagBadge: View { + let tag: DevProcessTag + + var body: some View { + Label(tag.title, systemImage: tag.symbolName) + .font(.caption2.weight(.semibold)) + .labelStyle(.titleAndIcon) + .lineLimit(1) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(DevScopePalette.accent.opacity(0.12), in: Capsule()) + .foregroundStyle(.secondary) + } +} + +private struct MetricValue: View { + let value: String + + init(_ value: String) { + self.value = value + } + + var body: some View { + Text(value) + .font(.system(.callout, design: .monospaced)) + .foregroundStyle(.secondary) + .contentTransition(.numericText()) + .lineLimit(1) + } +} + +private struct MonospaceValue: View { + let value: String + + init(_ value: String) { + self.value = value + } + + var body: some View { + Text(value) + .font(.system(.callout, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } +} + +enum ProcessMetricFormat { + static func cpu(_ value: Double?) -> String { + guard let value else { + return "-" + } + + return String(format: "%.1f%%", value) + } + + static func gpu(_ value: Double?) -> String { + guard let value else { + return "Unavailable" + } + + return String(format: "%.1f%%", value) + } + + static func memory(_ bytes: Int64?) -> String { + guard let bytes else { + return "-" + } + + let megabytes = Double(bytes) / 1_048_576 + if megabytes < 1024 { + return String(format: "%.0f MB", megabytes) + } + + return String(format: "%.1f GB", megabytes / 1024) + } +} diff --git a/Sources/DevScope/Views/SettingsView.swift b/Sources/DevScope/Views/SettingsView.swift new file mode 100644 index 0000000..409294e --- /dev/null +++ b/Sources/DevScope/Views/SettingsView.swift @@ -0,0 +1,788 @@ +import AppKit +import DevScopeCore +import SwiftUI + +enum DevScopeSettingsKey { + static let useAppleNaming = "useAppleNaming" + static let showProcessGraphs = "showProcessGraphs" + static let showLiveNotch = "showLiveNotch" + static let selectedCategoryID = "selectedCategoryID" + static let activityScope = "activityScope" + static let isRailCollapsed = "isRailCollapsed" + static let isLiveActivityExpanded = "isLiveActivityExpanded" + static let liveActivityPreferredHeight = "liveActivityPreferredHeight" + static let longRunningThresholdSeconds = AutomationPresentationSettings.longRunningThresholdSecondsKey + static let includeAppleSystemServices = AutomationPresentationSettings.includeAppleSystemServicesKey + static let selectedWorkspaceMode = AutomationPresentationSettings.selectedWorkspaceModeKey + static let notifyLongRunningAutomation = AutomationPresentationSettings.notifyLongRunningAutomationKey + static let notifyUnexpectedAutomationExit = AutomationPresentationSettings.notifyUnexpectedAutomationExitKey + static let notifyRepeatedAutomationFailure = AutomationPresentationSettings.notifyRepeatedAutomationFailureKey +} + +struct SettingsView: View { + @ObservedObject var automationNotifier: AutomationNotifier + @AppStorage(DevScopeSettingsKey.useAppleNaming) private var useAppleNaming = true + @AppStorage(DevScopeSettingsKey.showProcessGraphs) private var showProcessGraphs = true + @AppStorage(DevScopeSettingsKey.showLiveNotch) private var showLiveNotch = true + @AppStorage(DevScopeSettingsKey.longRunningThresholdSeconds) private var longRunningThresholdSeconds = 14_400.0 + @AppStorage(DevScopeSettingsKey.includeAppleSystemServices) private var includeAppleSystemServices = false + @AppStorage(DevScopeSettingsKey.notifyLongRunningAutomation) private var notifyLongRunningAutomation = false + @AppStorage(DevScopeSettingsKey.notifyUnexpectedAutomationExit) private var notifyUnexpectedAutomationExit = false + @AppStorage(DevScopeSettingsKey.notifyRepeatedAutomationFailure) private var notifyRepeatedAutomationFailure = false + @State private var didCopyAccessDiagnostics = false + @State private var didCopySupportURL = false + @State private var finderRevealFeedback: FinderRevealFeedback? + @State private var accessAssessment = ProcessAccessAssessment.assess( + isSandboxed: ProcessAccessStatus.isSandboxed, + processes: nil, + errorDescription: nil + ) + @State private var isCheckingAccess = false + + var body: some View { + TabView { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + SettingsPanel(title: "Live Monitoring") { + Toggle("Show process graphs", isOn: $showProcessGraphs) + Toggle("Show live notch", isOn: $showLiveNotch) + Text("Reveal DevScope Live by hovering the top-center screen notch area.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + SettingsValueRow(label: "Metric cadence", value: "2 seconds") + SettingsValueRow(label: "History window", value: "2 minutes") + } + + SettingsPanel(title: "Apple Intelligence") { + Toggle("Use Apple naming when available", isOn: $useAppleNaming) + Text( + "Deterministic labels and workflow grouping stay in control. Apple Intelligence can add concise process names and workflow notes when available." + ) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer() + } + .padding(20) + } + .tabItem { + Label("General", systemImage: "gearshape") + } + + ScrollView { + VStack(alignment: .leading, spacing: 18) { + SettingsPanel(title: "Automation Detection") { + Stepper(value: thresholdHoursBinding, in: 1...168, step: 1) { + LabeledContent("Long-running threshold", value: thresholdDescription) + } + Text("A process becomes Long Running at or after this duration. This remains independent from the Automated badge.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + Toggle("Include Apple System Services", isOn: $includeAppleSystemServices) + Text("Off by default. User and third-party automations, including organization-managed third-party definitions, remain visible.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + SettingsPanel(title: "Automation Notifications") { + Toggle("Long-running automation", isOn: $notifyLongRunningAutomation) + Toggle("Unexpected automation exit", isOn: $notifyUnexpectedAutomationExit) + Toggle("Repeated automation failure", isOn: $notifyRepeatedAutomationFailure) + + notificationDeliveryStatus + + if automationNotifier.deliveryState == .denied { + Button("Open Notification Settings") { openNotificationSettings() } + .accessibilityHint("Opens macOS notification settings so DevScope delivery can be allowed.") + } + } + Spacer() + } + .padding(20) + } + .tabItem { + Label("Automations", systemImage: "gearshape.2") + } + + ScrollView { + VStack(alignment: .leading, spacing: 18) { + SettingsPanel(title: "Process Access") { + HStack(alignment: .top, spacing: 12) { + Image(systemName: accessSummary.symbolName) + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(accessSummary.tint) + .frame(width: 34, height: 34) + + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text(accessSummary.title) + .font(.headline) + Text(accessSummary.badge) + .font(.caption.weight(.semibold)) + .foregroundStyle(accessSummary.tint) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(accessSummary.tint.opacity(0.12), in: Capsule()) + } + + Text(accessSummary.message) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + Divider() + + ForEach(accessAssessment.requirements, id: \.kind) { requirement in + AccessRequirementRow(requirement: requirement) { action in + performAccessAction(action) + } + } + + Divider() + + if shouldShowFullDiskAccessGuidance { + VStack(alignment: .leading, spacing: 8) { + Text(fullDiskAccessGuidance.title) + .font(.callout.weight(.semibold)) + + Text(fullDiskAccessGuidance.detail) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + VStack(alignment: .leading, spacing: 3) { + Text("Current app") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + Text(fullDiskAccessGuidance.appPath) + .font(.caption.monospaced()) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } + + if !fullDiskAccessGuidance.isSandboxed + && neededAccessActions.contains(.fullDiskAccess) + { + VStack(alignment: .leading, spacing: 6) { + ForEach( + Array(fullDiskAccessGuidance.steps.enumerated()), id: \.offset + ) { index, step in + HStack(alignment: .firstTextBaseline, spacing: 7) { + Text("\(index + 1).") + .font(.caption.weight(.semibold)) + .frame(width: 18, alignment: .trailing) + Text(step) + .font(.caption) + .fixedSize(horizontal: false, vertical: true) + } + } + } + .padding(.vertical, 4) + + HStack { + Button { + openFullDiskAccess() + } label: { + Label( + "Open Full Disk Access", systemImage: "externaldrive.badge.timemachine") + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .accessibilityLabel("Open Full Disk Access") + .accessibilityHint( + "Opens Full Disk Access in macOS System Settings.") + + Button { + revealDevScopeInFinder() + } label: { + Label("Reveal DevScope in Finder", systemImage: "finder") + } + .controlSize(.small) + .accessibilityHint("Selects the exact running DevScope app in Finder.") + } + + if let finderRevealFeedback { + Label( + finderRevealFeedback.message, systemImage: finderRevealFeedback.symbolName + ) + .font(.caption) + .foregroundStyle(finderRevealFeedback.tint) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + Divider() + } + + VStack(alignment: .leading, spacing: 8) { + Text("General privacy settings") + .font(.caption.weight(.semibold)) + Text( + fullDiskAccessGuidance.isSandboxed + ? "You may inspect general privacy settings, but no permission toggle can remove this build's App Sandbox restriction." + : "Open the main Privacy & Security pane for other macOS privacy controls." + ) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + Button { + openPrivacyAndSecurity() + } label: { + Label("Open Privacy & Security", systemImage: "gearshape.2") + } + .controlSize(.small) + .accessibilityHint("Opens the general Privacy & Security pane in System Settings.") + } + + HStack { + Button { + refreshAccessAssessment() + } label: { + Label( + isCheckingAccess ? "Checking" : "Check Access", systemImage: "arrow.clockwise") + } + .disabled(isCheckingAccess) + .accessibilityLabel("Check process access") + .accessibilityHint("Checks process metadata and folder-context access again.") + + Spacer() + + Button { + copyAccessDiagnostics() + } label: { + Label( + didCopyAccessDiagnostics ? "Copied" : "Copy Diagnostics", + systemImage: didCopyAccessDiagnostics ? "checkmark" : "doc.on.doc") + } + .accessibilityLabel("Copy access diagnostics") + .accessibilityHint("Copies DevScope access and sandbox diagnostics to the clipboard.") + } + } + + if accessAssessment.isSandboxed { + SettingsPanel(title: "Distribution Guidance") { + Text( + "This build is sandboxed. If process metadata is blocked, there is no extra macOS privacy permission to request; ship a reduced App Store mode or a notarized Developer ID build for full DevScope process control." + ) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + Spacer() + } + .padding(20) + } + .task { + refreshAccessAssessment() + } + .tabItem { + Label("Access", systemImage: "lock.shield") + } + + ScrollView { + VStack(alignment: .leading, spacing: 18) { + SettingsPanel(title: "Support DevScope") { + HStack(spacing: 10) { + Image(systemName: "cup.and.saucer.fill") + .font(.system(size: 22, weight: .semibold)) + .foregroundStyle(.pink) + .frame(width: 32, height: 32) + + VStack(alignment: .leading, spacing: 2) { + Text("Buy Me a Coffee") + .font(.headline) + Text("Support open-source maintenance and faster DevScope releases.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + SettingsValueRow( + label: "Destination", + value: DevScopeSupport.buyMeACoffeeURL.host() ?? "buymeacoffee.com") + Text(DevScopeSupport.buyMeACoffeeURLString) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + + HStack { + Button { + copySupportURL() + } label: { + Label( + didCopySupportURL ? "Copied" : "Copy Link", + systemImage: didCopySupportURL ? "checkmark" : "doc.on.doc") + } + .accessibilityLabel("Copy Buy Me a Coffee link") + .accessibilityHint("Copies the DevScope support link.") + + Spacer() + + Button { + openSponsorURL() + } label: { + Label("Open Buy Me a Coffee", systemImage: "safari") + } + .buttonStyle(.borderedProminent) + .accessibilityLabel("Open Buy Me a Coffee") + .accessibilityHint("Opens the DevScope Buy Me a Coffee support page.") + } + + Text( + "This support destination is fixed for the public DevScope build so users see the same trusted link everywhere." + ) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer() + } + .padding(20) + } + .tabItem { + Label("Support", systemImage: "heart") + } + } + .frame(width: 640, height: 520) + .task { + normalizeThreshold() + await automationNotifier.synchronize(persistedNotificationPreferences) + } + .onChange(of: longRunningThresholdSeconds) { _, _ in normalizeThreshold() } + .onChange(of: notifyLongRunningAutomation) { _, enabled in + Task { await automationNotifier.setPreference(.crossedLongRunningThreshold, isEnabled: enabled) } + } + .onChange(of: notifyUnexpectedAutomationExit) { _, enabled in + Task { await automationNotifier.setPreference(.unexpectedExit, isEnabled: enabled) } + } + .onChange(of: notifyRepeatedAutomationFailure) { _, enabled in + Task { await automationNotifier.setPreference(.repeatedFailure, isEnabled: enabled) } + } + } + + private var thresholdHoursBinding: Binding { + Binding( + get: { AutomationPresentationSettings.normalizedThreshold(longRunningThresholdSeconds) / 3_600 }, + set: { longRunningThresholdSeconds = $0 * 3_600 } + ) + } + + private var thresholdDescription: String { + let hours = Int(AutomationPresentationSettings.normalizedThreshold(longRunningThresholdSeconds) / 3_600) + if hours.isMultiple(of: 24) { return "\(hours / 24) day\(hours == 24 ? "" : "s")" } + return "\(hours) hour\(hours == 1 ? "" : "s")" + } + + private var persistedNotificationPreferences: AutomationNotificationPreferences { + AutomationNotificationPreferences( + crossedLongRunningThreshold: notifyLongRunningAutomation, + unexpectedExit: notifyUnexpectedAutomationExit, + repeatedFailure: notifyRepeatedAutomationFailure + ) + } + + @ViewBuilder private var notificationDeliveryStatus: some View { + switch automationNotifier.deliveryState { + case .notRequested: + Text("Notifications are off until you opt in. macOS may ask for permission.") + case .requesting: + Label("Waiting for macOS notification permission", systemImage: "clock") + case .enabled: + Label("Notification delivery is allowed", systemImage: "checkmark.circle") + .foregroundStyle(.green) + case .denied: + Label("Preferences are saved, but macOS denied notification delivery.", systemImage: "exclamationmark.triangle") + .foregroundStyle(.orange) + case .authorizationFailed: + Label("Preferences are saved, but DevScope could not verify notification permission. Turn a notification off and on to retry.", systemImage: "exclamationmark.triangle") + .foregroundStyle(.orange) + case .deliveryFailed: + Label("macOS did not accept the last notification after one retry. Review notification permission before relying on alerts.", systemImage: "exclamationmark.triangle") + .foregroundStyle(.orange) + } + } + + private func normalizeThreshold() { + let normalized = AutomationPresentationSettings.normalizedThreshold(longRunningThresholdSeconds) + if normalized != longRunningThresholdSeconds { longRunningThresholdSeconds = normalized } + } + + private func openNotificationSettings() { + guard let url = URL(string: "x-apple.systempreferences:com.apple.Notifications-Settings.extension") else { return } + NSWorkspace.shared.open(url) + } + + private func openSponsorURL() { + NSWorkspace.shared.open(DevScopeSupport.buyMeACoffeeURL) + } + + private func copySupportURL() { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(DevScopeSupport.buyMeACoffeeURLString, forType: .string) + didCopySupportURL = true + } + + private var accessSummary: ProcessAccessSummary { + ProcessAccessSummary(assessment: accessAssessment) + } + + private var neededAccessActions: [ProcessAccessAction] { + accessAssessment.requirements.reduce(into: []) { actions, requirement in + guard requirement.state == .needed, + let action = requirement.action, + !actions.contains(action) + else { + return + } + actions.append(action) + } + } + + private var fullDiskAccessGuidance: FullDiskAccessGuidance { + FullDiskAccessGuidance( + appPath: Bundle.main.bundleURL.path, + isSandboxed: accessAssessment.isSandboxed + ) + } + + private var shouldShowFullDiskAccessGuidance: Bool { + ProcessAccessStatus.isSandboxed || neededAccessActions.contains(.fullDiskAccess) + } + + private func refreshAccessAssessment() { + guard !isCheckingAccess else { + return + } + + let isSandboxed = ProcessAccessStatus.isSandboxed + isCheckingAccess = true + Task { @MainActor in + accessAssessment = await Task.detached(priority: .userInitiated) { + do { + let processes = try SystemProcessScanner().snapshot(includeCurrentDirectories: true) + return ProcessAccessAssessment.assess( + isSandboxed: isSandboxed, + processes: processes, + errorDescription: nil + ) + } catch { + return ProcessAccessAssessment.assess( + isSandboxed: isSandboxed, + processes: nil, + errorDescription: error.localizedDescription + ) + } + }.value + isCheckingAccess = false + } + } + + private func performAccessAction(_ action: ProcessAccessAction) { + switch action { + case .privacySecurity: + openPrivacyAndSecurity() + case .fullDiskAccess: + openFullDiskAccess() + } + } + + private func accessActionTitle(for action: ProcessAccessAction) -> String { + switch action { + case .privacySecurity: + "Open Privacy & Security" + case .fullDiskAccess: + "Open Full Disk Access" + } + } + + private func accessActionDestination(for action: ProcessAccessAction) -> String { + switch action { + case .privacySecurity: + "Privacy & Security" + case .fullDiskAccess: + "Full Disk Access" + } + } + + private func accessActionSymbol(for action: ProcessAccessAction) -> String { + switch action { + case .privacySecurity: + "gearshape.2" + case .fullDiskAccess: + "externaldrive.badge.timemachine" + } + } + + private func openPrivacyAndSecurity() { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security") { + NSWorkspace.shared.open(url) + } else { + openSystemSettingsApp() + } + } + + private func openFullDiskAccess() { + FullDiskAccessSettingsRoute.open( + using: NSWorkspace.shared.open, + fallback: openPrivacyAndSecurity + ) + } + + private func revealDevScopeInFinder() { + let appURL = Bundle.main.bundleURL + guard FileManager.default.fileExists(atPath: appURL.path) else { + finderRevealFeedback = .failure( + "The current DevScope app could not be found at the displayed path.") + return + } + + NSWorkspace.shared.activateFileViewerSelecting([Bundle.main.bundleURL]) + finderRevealFeedback = .success("DevScope is selected in Finder.") + } + + private func openSystemSettingsApp() { + for path in [ + "/System/Applications/System Settings.app", + "/System/Applications/System Preferences.app", + ] { + let url = URL(fileURLWithPath: path, isDirectory: true) + if FileManager.default.fileExists(atPath: url.path) { + NSWorkspace.shared.open(url) + return + } + } + } + + private func copyAccessDiagnostics() { + let bundle = Bundle.main + let diagnostics = """ + DevScope Access Diagnostics + Bundle ID: \(bundle.bundleIdentifier ?? "unknown") + Version: \(bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown") (\(bundle.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "unknown")) + App path: \(bundle.bundlePath) + Sandbox detected: \(ProcessAccessStatus.isSandboxed ? "yes" : "no") + Process count: \(accessAssessment.processCount) + Working directory count: \(accessAssessment.currentDirectoryCount) + Access rows: \(accessAssessment.requirements.map { "\($0.title)=\($0.state.rawValue)" }.joined(separator: ", ")) + Scan error: \(accessAssessment.scanErrorDescription ?? "none") + Scanner: user-level ps/lsof with native libproc fallback + Guidance: DevScope only requests actionable permissions. App Sandbox process blocking cannot be fixed from Privacy & Security. + """ + + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(diagnostics, forType: .string) + didCopyAccessDiagnostics = true + } +} + +private struct FinderRevealFeedback { + let message: String + let symbolName: String + let tint: Color + + static func success(_ message: String) -> FinderRevealFeedback { + FinderRevealFeedback(message: message, symbolName: "checkmark.circle.fill", tint: .green) + } + + static func failure(_ message: String) -> FinderRevealFeedback { + FinderRevealFeedback(message: message, symbolName: "exclamationmark.triangle.fill", tint: .red) + } +} + +private struct ProcessAccessSummary { + let title: String + let badge: String + let message: String + let symbolName: String + let tint: Color + + init(assessment: ProcessAccessAssessment) { + if assessment.hasBlockedRequirement { + title = "Process access blocked" + badge = "Blocked" + message = + assessment.isSandboxed + ? "The current build cannot inspect local process metadata. No Privacy & Security permission can override App Sandbox for unrestricted process inspection." + : "DevScope could not inspect local process metadata. This failure is not mapped to a known macOS permission toggle." + symbolName = "lock.shield" + tint = .red + } else if assessment.hasNeededAction { + title = "Permission needed" + badge = "Needed" + message = + "DevScope can see processes, but one optional capability needs a macOS privacy setting." + symbolName = "exclamationmark.shield" + tint = .orange + } else { + title = "Process access ready" + badge = "Ready" + message = "DevScope can inspect local running activity with current-user permissions." + symbolName = "checkmark.shield" + tint = .green + } + } +} + +private enum ProcessAccessStatus { + static var isSandboxed: Bool { + ProcessInfo.processInfo.environment["APP_SANDBOX_CONTAINER_ID"] != nil + } +} + +private struct AccessRequirementRow: View { + let requirement: ProcessAccessRequirement + let performAction: (ProcessAccessAction) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .top, spacing: 10) { + Image(systemName: symbolName) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(tint) + .frame(width: 22, height: 22) + + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 8) { + Text(requirement.title) + .font(.callout.weight(.medium)) + Text(badge) + .font(.caption2.weight(.bold)) + .foregroundStyle(tint) + .padding(.horizontal, 7) + .padding(.vertical, 2) + .background(tint.opacity(0.12), in: Capsule()) + } + + Text(requirement.detail) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer(minLength: 12) + } + + if let action = requirement.action, requirement.state == .needed { + Button { + performAction(action) + } label: { + Label(actionTitle(for: action), systemImage: actionSymbol(for: action)) + } + .controlSize(.small) + .accessibilityLabel(actionTitle(for: action)) + .accessibilityHint("Opens \(actionDestination(for: action)) in macOS System Settings.") + } else if requirement.state == .blocked { + Text("No System Settings permission can grant this access for the current build.") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 2) + } + + private var symbolName: String { + switch requirement.state { + case .ready: + "checkmark.circle.fill" + case .needed: + "exclamationmark.triangle.fill" + case .blocked: + "lock.fill" + } + } + + private var tint: Color { + switch requirement.state { + case .ready: + .green + case .needed: + .orange + case .blocked: + .red + } + } + + private var badge: String { + switch requirement.state { + case .ready: + "Ready" + case .needed: + "Needed" + case .blocked: + "Blocked" + } + } + + private func actionTitle(for action: ProcessAccessAction) -> String { + switch action { + case .privacySecurity: + "Open Privacy & Security" + case .fullDiskAccess: + "Open Full Disk Access" + } + } + + private func actionSymbol(for action: ProcessAccessAction) -> String { + switch action { + case .privacySecurity: + "gearshape.2" + case .fullDiskAccess: + "externaldrive.badge.timemachine" + } + } + + private func actionDestination(for action: ProcessAccessAction) -> String { + switch action { + case .privacySecurity: + "Privacy & Security" + case .fullDiskAccess: + "Full Disk Access" + } + } +} + +private struct SettingsPanel: View { + let title: String + @ViewBuilder let content: Content + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text(title) + .font(.headline) + VStack(alignment: .leading, spacing: 10) { + content + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .softPanel(cornerRadius: 12) + } + } +} + +private struct SettingsValueRow: View { + let label: String + let value: String + + var body: some View { + HStack { + Text(label) + .foregroundStyle(.secondary) + Spacer() + Text(value) + .monospacedDigit() + } + .font(.callout) + } +} diff --git a/Sources/DevScope/Views/SortableTableHeader.swift b/Sources/DevScope/Views/SortableTableHeader.swift new file mode 100644 index 0000000..531040d --- /dev/null +++ b/Sources/DevScope/Views/SortableTableHeader.swift @@ -0,0 +1,44 @@ +import SwiftUI + +struct SortableTableHeader: View { + let title: String + let column: Column + let activeColumn: Column? + let direction: TableSortDirection + let contentAlignment: Alignment + let activate: (Column) -> Void + + init( + title: String, + column: Column, + activeColumn: Column?, + direction: TableSortDirection, + contentAlignment: Alignment = .leading, + activate: @escaping (Column) -> Void + ) { + self.title = title + self.column = column + self.activeColumn = activeColumn + self.direction = direction + self.contentAlignment = contentAlignment + self.activate = activate + } + + var body: some View { + Button { activate(column) } label: { + HStack(spacing: 4) { + Text(title).font(.caption.weight(.semibold)) + if activeColumn == column { + Image(systemName: direction.symbolName).font(.caption2.weight(.bold)) + } + } + .frame(maxWidth: .infinity, alignment: contentAlignment) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityValue(activeColumn == column ? "Sorted \(direction.accessibilityTitle)" : "Not sorted") + .help(activeColumn == column + ? "Sort by \(title) \(direction == .ascending ? "descending" : "ascending")" + : "Sort by \(title)") + } +} diff --git a/Sources/DevScope/Views/SponsorSheetView.swift b/Sources/DevScope/Views/SponsorSheetView.swift new file mode 100644 index 0000000..2404966 --- /dev/null +++ b/Sources/DevScope/Views/SponsorSheetView.swift @@ -0,0 +1,101 @@ +import AppKit +import SwiftUI + +struct SponsorSheetView: View { + @Environment(\.dismiss) private var dismiss + @State private var didCopySupportURL = false + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + HStack(alignment: .top, spacing: 14) { + ZStack { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(.pink.opacity(0.16)) + Image(systemName: "cup.and.saucer.fill") + .font(.system(size: 28, weight: .semibold)) + .foregroundStyle(.pink) + } + .frame(width: 62, height: 62) + .devScopeGlass(cornerRadius: 16) + + VStack(alignment: .leading, spacing: 5) { + Text("Buy Me a Coffee") + .font(.title2.weight(.semibold)) + Text("Support DevScope development, open-source maintenance, and faster macOS polish.") + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + VStack(alignment: .leading, spacing: 10) { + SponsorPoint(symbolName: "bolt.fill", title: "Realtime polish", detail: "Support work on lower-latency process metrics and smoother charts.") + SponsorPoint(symbolName: "brain.head.profile", title: "Apple Intelligence", detail: "Fund safer local naming and process insight features.") + SponsorPoint(symbolName: "checkmark.seal", title: "Open source quality", detail: "Keep tests, docs, and release hardening part of every feature.") + } + + HStack { + Button("Close") { + dismiss() + } + .keyboardShortcut(.cancelAction) + + Spacer() + + Button { + copySupportURL() + } label: { + Label(didCopySupportURL ? "Copied" : "Copy Link", systemImage: didCopySupportURL ? "checkmark" : "doc.on.doc") + } + .accessibilityLabel("Copy Buy Me a Coffee link") + .accessibilityHint("Copies the DevScope support link.") + + Button { + openSponsorURL() + } label: { + Label("Open Buy Me a Coffee", systemImage: "safari") + } + .buttonStyle(.borderedProminent) + .accessibilityLabel("Open Buy Me a Coffee") + .accessibilityHint("Opens the DevScope Buy Me a Coffee support page.") + .keyboardShortcut(.defaultAction) + } + } + .padding(22) + .frame(width: 480) + } + + private func openSponsorURL() { + NSWorkspace.shared.open(DevScopeSupport.buyMeACoffeeURL) + } + + private func copySupportURL() { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(DevScopeSupport.buyMeACoffeeURLString, forType: .string) + didCopySupportURL = true + } +} + +private struct SponsorPoint: View { + let symbolName: String + let title: String + let detail: String + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: symbolName) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(DevScopePalette.accent) + .frame(width: 18) + + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.callout.weight(.semibold)) + Text(detail) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } +} diff --git a/Sources/DevScope/Views/TableSortState.swift b/Sources/DevScope/Views/TableSortState.swift new file mode 100644 index 0000000..9d44c54 --- /dev/null +++ b/Sources/DevScope/Views/TableSortState.swift @@ -0,0 +1,156 @@ +import DevScopeCore +import Foundation + +enum TableSortDirection: Equatable, Sendable { + case ascending + case descending + + var sortOrder: SortOrder { self == .ascending ? .forward : .reverse } + var symbolName: String { self == .ascending ? "chevron.up" : "chevron.down" } + var accessibilityTitle: String { self == .ascending ? "ascending" : "descending" } +} + +enum ProcessTableSortColumn: CaseIterable, Equatable, Hashable, Sendable { + case process, pid, cpu, memory, time, command + + var defaultDirection: TableSortDirection { + switch self { + case .cpu, .memory, .time: .descending + case .process, .pid, .command: .ascending + } + } +} + +struct ProcessTableSortState: Equatable, Sendable { + var column: ProcessTableSortColumn? + var direction: TableSortDirection + + init(column: ProcessTableSortColumn? = nil, direction: TableSortDirection = .ascending) { + self.column = column + self.direction = direction + } + + mutating func activate(_ selected: ProcessTableSortColumn) { + if column == selected { + direction = direction == .ascending ? .descending : .ascending + } else { + column = selected + direction = selected.defaultDirection + } + } + + var comparators: [KeyPathComparator] { + guard let column else { + return [KeyPathComparator(\ProcessTableRow.snapshotOrder, order: .forward)] + } + let order = direction.sortOrder + switch column { + case .process: return [KeyPathComparator(\ProcessTableRow.processName, order: order)] + case .pid: return [KeyPathComparator(\ProcessTableRow.pid, order: order)] + case .cpu: return [KeyPathComparator(\ProcessTableRow.cpuPercent, order: order)] + case .memory: return [KeyPathComparator(\ProcessTableRow.memoryBytes, order: order)] + case .time: return [KeyPathComparator(\ProcessTableRow.elapsedSeconds, order: order)] + case .command: return [KeyPathComparator(\ProcessTableRow.command, order: order)] + } + } +} + +enum AutomationTableSortColumn: CaseIterable, Equatable, Hashable, Sendable { + case automation, kind, trigger, state, owner, runs + + var defaultDirection: TableSortDirection { + self == .runs ? .descending : .ascending + } +} + +struct AutomationTableSortState: Equatable, Sendable { + var column: AutomationTableSortColumn? = nil + var direction: TableSortDirection = .ascending + + mutating func activate(_ selected: AutomationTableSortColumn) { + if column == selected { + direction = direction == .ascending ? .descending : .ascending + } else { + column = selected + direction = selected.defaultDirection + } + } + + func sorted(_ rows: [AutomationTableRow]) -> [AutomationTableRow] { + guard let column else { return rows } + return rows.sorted { lhs, rhs in + let comparison = compare(lhs, rhs, column: column) + if comparison == .orderedSame { + return lhs.id.rawValue < rhs.id.rawValue + } + return direction == .ascending + ? comparison == .orderedAscending + : comparison == .orderedDescending + } + } + + private func compare( + _ lhs: AutomationTableRow, + _ rhs: AutomationTableRow, + column: AutomationTableSortColumn + ) -> ComparisonResult { + switch column { + case .automation: + return compareText( + "\(lhs.record.displayName)\u{1F}\(lhs.record.label)", + "\(rhs.record.displayName)\u{1F}\(rhs.record.label)" + ) + case .trigger: + return compareText(lhs.record.schedule.summary, rhs.record.schedule.summary) + case .kind: + return compareInteger(kindRank(lhs.record.kind), kindRank(rhs.record.kind)) + case .state: + return compareInteger(stateRank(lhs.record.state), stateRank(rhs.record.state)) + case .owner: + return compareInteger( + ownershipRank(lhs.record.ownership), + ownershipRank(rhs.record.ownership) + ) + case .runs: + return compareInteger(lhs.linkedProcessCount, rhs.linkedProcessCount) + } + } + + private func compareText(_ lhs: String, _ rhs: String) -> ComparisonResult { + lhs.localizedStandardCompare(rhs) + } + + private func compareInteger(_ lhs: Int, _ rhs: Int) -> ComparisonResult { + lhs == rhs ? .orderedSame : (lhs < rhs ? .orderedAscending : .orderedDescending) + } + + private func kindRank(_ kind: AutomationKind) -> Int { + switch kind { + case .launchAgent: 0 + case .launchDaemon: 1 + case .loginItem: 2 + case .backgroundItem: 3 + case .cron: 4 + } + } + + private func stateRank(_ state: AutomationState) -> Int { + switch state { + case .running: 0 + case .idle: 1 + case .needsApproval: 2 + case .disabled: 3 + case .invalid: 4 + case .unresolved: 5 + } + } + + private func ownershipRank(_ ownership: AutomationOwnership) -> Int { + switch ownership { + case .user: 0 + case .thirdPartySystem: 1 + case .managed: 2 + case .appleSystem: 3 + } + } +} diff --git a/Sources/DevScopeCore/AutomationCapabilityPolicy.swift b/Sources/DevScopeCore/AutomationCapabilityPolicy.swift new file mode 100644 index 0000000..ea0854b --- /dev/null +++ b/Sources/DevScopeCore/AutomationCapabilityPolicy.swift @@ -0,0 +1,166 @@ +import Darwin +import Foundation + +public enum AutomationPathAuthorization { + public static func isApprovedDestination( + _ destination: URL, + approvedRoot: URL, + destinationExists: Bool, + verifiedMetadataURL: URL, + metadataIsSymbolicLink: Bool + ) -> Bool { + let destination = destination.standardizedFileURL + let root = approvedRoot.standardizedFileURL + let verifiedMetadataURL = verifiedMetadataURL.standardizedFileURL + let expectedMetadataURL = destinationExists + ? destination + : destination.deletingLastPathComponent().standardizedFileURL + return !metadataIsSymbolicLink + && destination.path.hasPrefix(root.path + "/") + && verifiedMetadataURL.path == expectedMetadataURL.path + } +} + +public struct AutomationCapabilityDecision: Equatable, Sendable { + public let capabilities: Set + public let reason: String? + + public init(capabilities: Set, reason: String?) { + self.capabilities = capabilities + self.reason = reason + } +} + +public struct AutomationCapabilityContext: Equatable, Sendable { + public let currentUID: uid_t + public let canonicalPathIsApproved: Bool + public let sourceOwnerUID: uid_t? + public let isSymlink: Bool + public let isManaged: Bool + public let implementedCapabilities: Set + + public init( + currentUID: uid_t, + canonicalPathIsApproved: Bool, + sourceOwnerUID: uid_t?, + isSymlink: Bool, + isManaged: Bool, + implementedCapabilities: Set = [] + ) { + self.currentUID = currentUID + self.canonicalPathIsApproved = canonicalPathIsApproved + self.sourceOwnerUID = sourceOwnerUID + self.isSymlink = isSymlink + self.isManaged = isManaged + self.implementedCapabilities = implementedCapabilities + } +} + +public enum AutomationCapabilityPolicy { + private static let inspectionCapabilities: Set = [.exportRecord] + private static let mutableSourceKinds: Set = [ + .launchAgent, + .legacyLoginItem, + .crontab, + ] + + public static func decision( + for record: AutomationRecord, + context: AutomationCapabilityContext + ) -> AutomationCapabilityDecision { + if context.isManaged || record.ownership == .managed { + return denied("This automation is managed by your organization.", context: context) + } + if record.ownership == .appleSystem { + return denied("Apple system services are inspection only.", context: context) + } + if record.ownership == .thirdPartySystem || record.kind == .launchDaemon { + return denied( + "System-wide automations require administrator management outside DevScope.", + context: context + ) + } + if record.sourceKind == .serviceManagement || record.kind == .backgroundItem { + return denied( + "Modern background items must be managed by their owning app or System Settings.", + context: context + ) + } + guard record.ownership == .user else { + return denied("Only automations owned by the current user can be managed.", context: context) + } + guard mutableSourceKinds.contains(record.sourceKind) else { + return denied("This automation source is inspection only.", context: context) + } + guard context.canonicalPathIsApproved else { + return denied(canonicalPathReason(for: record.sourceKind), context: context) + } + guard !context.isSymlink else { + return denied("Symbolic-link automation sources are inspection only.", context: context) + } + guard let ownerUID = context.sourceOwnerUID else { + return denied("DevScope could not verify who owns this automation source.", context: context) + } + guard ownerUID == context.currentUID, record.ownerUID == context.currentUID else { + return denied("The automation source is not owned by the current user.", context: context) + } + if record.sourceKind == .launchAgent, + record.enabledState == .unknown || record.loadState == .unknown + { + return denied( + "DevScope could not verify this LaunchAgent's enabled and loaded state, so management is unavailable.", + context: context + ) + } + + let capabilities = Set(AutomationCapability.allCases) + .intersection(context.implementedCapabilities) + let reason: String? + if capabilities == Set(AutomationCapability.allCases) { + reason = nil + } else { + reason = unavailableReason(for: record.sourceKind) + } + return AutomationCapabilityDecision(capabilities: capabilities, reason: reason) + } + + private static func denied( + _ reason: String, + context: AutomationCapabilityContext + ) -> AutomationCapabilityDecision { + AutomationCapabilityDecision( + capabilities: inspectionCapabilities.intersection(context.implementedCapabilities), + reason: reason + ) + } + + private static func unavailableReason(for sourceKind: AutomationSourceKind) -> String { + switch sourceKind { + case .crontab: + "Current-user crontab management is unavailable until recoverable source access is installed." + case .legacyLoginItem: + "Legacy login-item management is unavailable until recoverable source access is installed." + case .launchAgent: + "Some LaunchAgent management operations are not installed." + case .launchDaemon: + "System-wide launchd management is not installed." + case .serviceManagement: + "Modern background-item management is not installed." + } + } + + private static func canonicalPathReason(for sourceKind: AutomationSourceKind) -> String { + switch sourceKind { + case .launchAgent: + "The source resolves outside your LaunchAgents folder." + case .crontab: + "The recoverable source does not represent your current-user crontab." + case .legacyLoginItem: + "The recoverable source does not represent your current-user login items." + case .launchDaemon: + "The source resolves outside the approved launchd domain." + case .serviceManagement: + "The source resolves outside the approved background-item domain." + } + } +} diff --git a/Sources/DevScopeCore/AutomationEventDetector.swift b/Sources/DevScopeCore/AutomationEventDetector.swift new file mode 100644 index 0000000..5102088 --- /dev/null +++ b/Sources/DevScopeCore/AutomationEventDetector.swift @@ -0,0 +1,394 @@ +import Foundation + +public enum AutomationEvent: Equatable, Sendable { + case crossedLongRunningThreshold( + process: ProcessIdentity, + recordID: AutomationRecord.ID? + ) + case unexpectedExit(recordID: AutomationRecord.ID, process: ProcessIdentity) + case repeatedFailure(recordID: AutomationRecord.ID, observedExitCount: Int) +} + +public enum AutomationNotificationPreference: CaseIterable, Hashable, Sendable { + case crossedLongRunningThreshold + case unexpectedExit + case repeatedFailure +} + +public struct AutomationNotificationPreferences: Equatable, Sendable { + public var crossedLongRunningThreshold: Bool + public var unexpectedExit: Bool + public var repeatedFailure: Bool + + public init( + crossedLongRunningThreshold: Bool = false, + unexpectedExit: Bool = false, + repeatedFailure: Bool = false + ) { + self.crossedLongRunningThreshold = crossedLongRunningThreshold + self.unexpectedExit = unexpectedExit + self.repeatedFailure = repeatedFailure + } + + public subscript(preference: AutomationNotificationPreference) -> Bool { + get { + switch preference { + case .crossedLongRunningThreshold: crossedLongRunningThreshold + case .unexpectedExit: unexpectedExit + case .repeatedFailure: repeatedFailure + } + } + set { + switch preference { + case .crossedLongRunningThreshold: crossedLongRunningThreshold = newValue + case .unexpectedExit: unexpectedExit = newValue + case .repeatedFailure: repeatedFailure = newValue + } + } + } +} + +public enum AutomationNotificationAuthorizationAction: Equatable, Sendable { + case none + case requestAuthorization +} + +public struct AutomationNotificationContent: Equatable, Sendable { + public let title: String + public let body: String + + public init(title: String, body: String) { + self.title = title + self.body = body + } +} + +public struct AutomationNotificationPolicy: Sendable { + private enum AuthorizationState: Sendable { + case unknown + case requesting + case granted + case denied + } + + private enum EventIdentity: Hashable, Sendable { + case crossedThreshold(ProcessIdentity, AutomationRecord.ID?) + case unexpectedExit(AutomationRecord.ID, ProcessIdentity) + case repeatedFailure(AutomationRecord.ID, Int) + } + + public private(set) var preferences = AutomationNotificationPreferences() + private let maximumRetainedEventIdentities: Int + private var authorizationState = AuthorizationState.unknown + private var retainedEventIdentities: Set = [] + private var retainedEventOrder: [EventIdentity] = [] + + public init(maximumRetainedEventIdentities: Int = 256) { + self.maximumRetainedEventIdentities = max(1, maximumRetainedEventIdentities) + } + + public mutating func setPreference( + _ preference: AutomationNotificationPreference, + isEnabled: Bool + ) -> AutomationNotificationAuthorizationAction { + let wasEnabled = preferences[preference] + preferences[preference] = isEnabled + guard !wasEnabled, isEnabled, + authorizationState == .unknown || authorizationState == .denied else { return .none } + authorizationState = .requesting + return .requestAuthorization + } + + public mutating func recordAuthorizationResult(granted: Bool) { + authorizationState = granted ? .granted : .denied + } + + public mutating func notification( + for event: AutomationEvent + ) -> AutomationNotificationContent? { + guard authorizationState == .granted, + preferences[preference(for: event)] else { return nil } + let identity = identity(for: event) + guard retainedEventIdentities.insert(identity).inserted else { return nil } + retainedEventOrder.append(identity) + while retainedEventOrder.count > maximumRetainedEventIdentities { + retainedEventIdentities.remove(retainedEventOrder.removeFirst()) + } + return safeContent(for: event) + } + + public mutating func recordDeliveryFailure(for event: AutomationEvent) { + let failedIdentity = identity(for: event) + retainedEventIdentities.remove(failedIdentity) + retainedEventOrder.removeAll { $0 == failedIdentity } + } + + private func preference( + for event: AutomationEvent + ) -> AutomationNotificationPreference { + switch event { + case .crossedLongRunningThreshold: .crossedLongRunningThreshold + case .unexpectedExit: .unexpectedExit + case .repeatedFailure: .repeatedFailure + } + } + + private func identity(for event: AutomationEvent) -> EventIdentity { + switch event { + case let .crossedLongRunningThreshold(process, recordID): + .crossedThreshold(safeIdentity(process), recordID) + case let .unexpectedExit(recordID, process): + .unexpectedExit(recordID, safeIdentity(process)) + case let .repeatedFailure(recordID, observedExitCount): + .repeatedFailure(recordID, observedExitCount) + } + } + + private func safeIdentity(_ identity: ProcessIdentity) -> ProcessIdentity { + ProcessIdentity(pid: identity.pid, birthToken: identity.birthToken) + } + + private func safeContent(for event: AutomationEvent) -> AutomationNotificationContent { + switch event { + case .crossedLongRunningThreshold: + AutomationNotificationContent( + title: "Long-running process detected", + body: "A verified process crossed your long-running threshold." + ) + case .unexpectedExit: + AutomationNotificationContent( + title: "Automation stopped unexpectedly", + body: "A verified user automation exited unexpectedly." + ) + case .repeatedFailure: + AutomationNotificationContent( + title: "Automation is repeatedly stopping", + body: "A verified user automation stopped repeatedly." + ) + } + } +} + +public struct AutomationEventDetector: Sendable { + private struct PendingExit: Sendable { + let identity: ProcessIdentity + var completeAbsenceCount: Int + } + + private var pendingExits: [AutomationRecord.ID: PendingExit] = [:] + private var verifiedExitHistory: [AutomationRecord.ID: [Date]] = [:] + private var repeatedFailureActive: Set = [] + + public init() {} + + public mutating func events( + previous: AutomationPresentationSnapshot?, + current: AutomationPresentationSnapshot, + now: Date + ) -> [AutomationEvent] { + pruneFailureHistory(now: now) + guard let previous else { + pendingExits.removeAll() + return [] + } + guard previous.isProcessSnapshotComplete, current.isProcessSnapshotComplete else { + pendingExits.removeAll() + return [] + } + + let thresholdEvents = crossedThresholdEvents(previous: previous, current: current) + let exitEvents = verifiedExitEvents(previous: previous, current: current) + var repeatedEvents: [AutomationEvent] = [] + for event in exitEvents { + guard case let .unexpectedExit(recordID, _) = event else { continue } + if let repeated = recordVerifiedExit(recordID: recordID, now: now) { + repeatedEvents.append(repeated) + } + } + return thresholdEvents + exitEvents + repeatedEvents.sorted(by: eventPrecedes) + } + + private func crossedThresholdEvents( + previous: AutomationPresentationSnapshot, + current: AutomationPresentationSnapshot + ) -> [AutomationEvent] { + current.longRunningProcessIdentities + .subtracting(previous.longRunningProcessIdentities) + .compactMap { identity -> AutomationEvent? in + guard identity.birthToken != nil else { return nil } + return .crossedLongRunningThreshold( + process: safeEventIdentity(identity), + recordID: uniqueStrongRecordID(for: identity, in: current) + ) + } + .sorted(by: eventPrecedes) + } + + private mutating func verifiedExitEvents( + previous: AutomationPresentationSnapshot, + current: AutomationPresentationSnapshot + ) -> [AutomationEvent] { + let currentRecords = Dictionary( + current.inventory.records.map { ($0.id, $0) }, + uniquingKeysWith: { first, _ in first } + ) + let currentLinksByRecord = Dictionary( + grouping: current.allLinksByProcessID.values.flatMap { $0 }, + by: \.recordID + ) + var emitted: [AutomationEvent] = [] + + for recordID in pendingExits.keys.sorted(by: idPrecedes) { + guard var pending = pendingExits[recordID], + previousSourceWasHealthy(recordID: recordID, snapshot: previous), + canConfirmExit( + recordID: recordID, + identity: pending.identity, + current: current, + currentRecords: currentRecords, + currentLinksByRecord: currentLinksByRecord + ) else { + pendingExits.removeValue(forKey: recordID) + continue + } + pending.completeAbsenceCount += 1 + if pending.completeAbsenceCount >= 2 { + emitted.append(.unexpectedExit( + recordID: recordID, + process: safeEventIdentity(pending.identity) + )) + pendingExits.removeValue(forKey: recordID) + } else { + pendingExits[recordID] = pending + } + } + + let priorLinks = previous.allLinksByProcessID.values.flatMap { $0 }.sorted { lhs, rhs in + if lhs.recordID != rhs.recordID { return idPrecedes(lhs.recordID, rhs.recordID) } + return lhs.processIdentity.pid < rhs.processIdentity.pid + } + for link in priorLinks { + guard pendingExits[link.recordID] == nil, + !emitted.contains(where: { eventRecordID($0) == link.recordID }), + link.strength == .strong, + link.processIdentity.birthToken != nil, + uniqueStrongRecordID(for: link.processIdentity, in: previous) == link.recordID, + previousSourceWasHealthy(recordID: link.recordID, snapshot: previous), + canConfirmExit( + recordID: link.recordID, + identity: link.processIdentity, + current: current, + currentRecords: currentRecords, + currentLinksByRecord: currentLinksByRecord + ) else { continue } + pendingExits[link.recordID] = PendingExit( + identity: link.processIdentity, + completeAbsenceCount: 1 + ) + } + return emitted.sorted(by: eventPrecedes) + } + + private func uniqueStrongRecordID( + for identity: ProcessIdentity, + in snapshot: AutomationPresentationSnapshot + ) -> AutomationRecord.ID? { + let recordIDs = Set( + (snapshot.allLinksByProcessID[identity.pid] ?? []) + .filter { $0.strength == .strong && $0.processIdentity == identity } + .map(\.recordID) + ) + return recordIDs.count == 1 ? recordIDs.first : nil + } + + private func previousSourceWasHealthy( + recordID: AutomationRecord.ID, + snapshot: AutomationPresentationSnapshot + ) -> Bool { + guard let record = snapshot.inventory.records.first(where: { $0.id == recordID }) else { + return false + } + return snapshot.inventory.health[record.sourceKind]?.state == .healthy + } + + private func canConfirmExit( + recordID: AutomationRecord.ID, + identity: ProcessIdentity, + current: AutomationPresentationSnapshot, + currentRecords: [AutomationRecord.ID: AutomationRecord], + currentLinksByRecord: [AutomationRecord.ID: [AutomationProcessLink]] + ) -> Bool { + guard let record = currentRecords[recordID], + record.ownership == .user, + record.enabledState == .enabled, + record.schedule.triggers.contains(.keepAlive), + current.inventory.health[record.sourceKind]?.state == .healthy, + currentLinksByRecord[recordID]?.isEmpty != false else { return false } + return current.processIdentitiesByID[identity.pid] == nil + } + + private mutating func recordVerifiedExit( + recordID: AutomationRecord.ID, + now: Date + ) -> AutomationEvent? { + var history = verifiedExitHistory[recordID] ?? [] + history.append(now) + history.sort() + verifiedExitHistory[recordID] = history + guard history.count >= 3, !repeatedFailureActive.contains(recordID) else { return nil } + repeatedFailureActive.insert(recordID) + return .repeatedFailure(recordID: recordID, observedExitCount: history.count) + } + + private mutating func pruneFailureHistory(now: Date) { + for recordID in verifiedExitHistory.keys { + let retained = (verifiedExitHistory[recordID] ?? []).filter { timestamp in + let age = now.timeIntervalSince(timestamp) + return age >= 0 && age <= 600 + } + if retained.isEmpty { + verifiedExitHistory.removeValue(forKey: recordID) + } else { + verifiedExitHistory[recordID] = retained + } + if retained.count < 3 { repeatedFailureActive.remove(recordID) } + } + } + + private func safeEventIdentity(_ identity: ProcessIdentity) -> ProcessIdentity { + ProcessIdentity(pid: identity.pid, birthToken: identity.birthToken) + } + + private func eventRecordID(_ event: AutomationEvent) -> AutomationRecord.ID? { + switch event { + case let .crossedLongRunningThreshold(_, recordID): recordID + case let .unexpectedExit(recordID, _): recordID + case let .repeatedFailure(recordID, _): recordID + } + } + + private func eventPrecedes(_ lhs: AutomationEvent, _ rhs: AutomationEvent) -> Bool { + let lhsKey = eventKey(lhs) + let rhsKey = eventKey(rhs) + if lhsKey.rank != rhsKey.rank { return lhsKey.rank < rhsKey.rank } + if lhsKey.recordID != rhsKey.recordID { + return lhsKey.recordID.utf8.lexicographicallyPrecedes(rhsKey.recordID.utf8) + } + return lhsKey.pid < rhsKey.pid + } + + private func eventKey(_ event: AutomationEvent) -> (rank: Int, recordID: String, pid: Int32) { + switch event { + case let .crossedLongRunningThreshold(process, recordID): + (0, recordID?.rawValue ?? "", process.pid) + case let .unexpectedExit(recordID, process): + (1, recordID.rawValue, process.pid) + case let .repeatedFailure(recordID, _): + (2, recordID.rawValue, 0) + } + } + + private func idPrecedes(_ lhs: AutomationRecord.ID, _ rhs: AutomationRecord.ID) -> Bool { + lhs.rawValue.utf8.lexicographicallyPrecedes(rhs.rawValue.utf8) + } +} diff --git a/Sources/DevScopeCore/AutomationExecutors.swift b/Sources/DevScopeCore/AutomationExecutors.swift new file mode 100644 index 0000000..ce245de --- /dev/null +++ b/Sources/DevScopeCore/AutomationExecutors.swift @@ -0,0 +1,1007 @@ +import CryptoKit +import Darwin +import Foundation + +public enum AutomationExecutorError: Error, Equatable, Sendable { + case unsupportedSource + case unsupportedOperation + case missingSource + case invalidSource + case commandFailed(executable: String, status: Int32) + case commandUnavailable(executable: String) + case processControlUnavailable + case postconditionNotVerified +} + +public struct AutomationTerminationVerificationPolicy: Equatable, Sendable { + public let maximumAttempts: Int + public let interval: Duration + + public init(maximumAttempts: Int = 40, interval: Duration = .milliseconds(100)) { + self.maximumAttempts = max(1, maximumAttempts) + self.interval = max(.zero, interval) + } +} + +public typealias AutomationVerificationSleep = @Sendable (Duration) async throws -> Void + +private func oldBirthExited( + _ expected: ProcessIdentity, + resolver: ProcessKiller.IdentityResolver, + policy: AutomationTerminationVerificationPolicy, + sleep: AutomationVerificationSleep +) async throws -> Bool { + for attempt in 0.. AutomationExecutorResult { + try await apply(operation, to: record, linkedProcesses: [], proposedSourceURL: nil) + } + + public func apply( + _ operation: AutomationOperation, + to record: AutomationRecord, + linkedProcesses: [ClassifiedDevProcess], + proposedSourceURL: URL? + ) async throws -> AutomationExecutorResult { + guard record.kind == .launchAgent, + record.sourceKind == .launchAgent, + record.ownership == .user, + record.ownerUID == guiUID, + isValidLaunchdLabel(record.label) + else { throw AutomationExecutorError.unsupportedSource } + + switch operation { + case .startNow: + let loaded: Bool + switch record.loadState { + case .loaded: + loaded = true + case .unloaded: + loaded = false + case .unknown: + loaded = try await verifiedLoadedState(for: record) + } + if !loaded { + guard let sourceURL = canonicalSourceURL(for: record) else { + throw AutomationExecutorError.missingSource + } + try await requireLaunchctlSuccess(["bootstrap", domainTarget, sourceURL.path]) + } + try await requireLaunchctlSuccess(["kickstart", serviceTarget(for: record)]) + let evidence = loaded + ? "launchd accepted the typed kickstart request." + : "launchd loaded the canonical source and accepted the typed kickstart request." + return AutomationExecutorResult( + postconditions: [.targetResolved, .currentRunStarted], + evidence: [evidence] + ) + case .disable: + try await requireLaunchctlSuccess(["disable", serviceTarget(for: record)]) + return AutomationExecutorResult( + postconditions: [.futureLaunchesDisabled], + evidence: ["launchd accepted the typed disable request."] + ) + case .enable: + guard let sourceURL = canonicalSourceURL(for: record) else { + throw AutomationExecutorError.missingSource + } + let loaded = try await verifiedLoadedState(for: record) + try await requireLaunchctlSuccess(["enable", serviceTarget(for: record)]) + if !loaded { + try await requireLaunchctlSuccess(["bootstrap", domainTarget, sourceURL.path]) + } + return AutomationExecutorResult( + postconditions: [.futureLaunchesEnabled, .targetResolved], + evidence: [ + loaded + ? "launchd accepted enable for the resolved service target." + : "launchd accepted enable and bootstrap for the canonical source.", + ] + ) + case .stopCurrentRun: + try await requireLaunchctlSuccess(["bootout", serviceTarget(for: record)]) + return AutomationExecutorResult( + postconditions: [.noLinkedProcess], + evidence: ["launchd unloaded the exact user service target."] + ) + case .disableAndStop: + try await requireLaunchctlSuccess(["disable", serviceTarget(for: record)]) + try await requireLaunchctlSuccess(["bootout", serviceTarget(for: record)]) + return AutomationExecutorResult( + postconditions: [.futureLaunchesDisabled, .noLinkedProcess], + evidence: ["launchd disabled and unloaded the exact user service target."] + ) + case .edit: + let proof = try sourceProof(at: proposedSourceURL) + if record.loadState == .loaded { + try await requireLaunchctlSuccess(["bootout", serviceTarget(for: record)]) + try await requireLaunchctlSuccess(["bootstrap", domainTarget, proof.url.path]) + } + return AutomationExecutorResult( + postconditions: [.sourceInstalled, .sourceChecksum(proof.checksum)], + evidence: ["The validated LaunchAgent source was installed."] + ) + case .duplicate(let payload): + let proof = try sourceProof(at: proposedSourceURL) + guard isValidLaunchdLabel(payload.label), payload.label != record.label else { + throw AutomationExecutorError.invalidSource + } + try await requireLaunchctlSuccess(["disable", "gui/\(guiUID)/\(payload.label)"]) + return AutomationExecutorResult( + postconditions: [ + .sourceInstalled, + .sourceChecksum(proof.checksum), + .futureLaunchesDisabled, + ], + evidence: ["The validated LaunchAgent copy was installed disabled and unloaded."] + ) + case .importRecord: + let proof = try sourceProof(at: proposedSourceURL) + return AutomationExecutorResult( + postconditions: [.sourceInstalled, .sourceChecksum(proof.checksum)], + evidence: ["The validated LaunchAgent source was installed without loading it."] + ) + case .restore: + let proof = try sourceProof(at: proposedSourceURL) + return AutomationExecutorResult( + postconditions: [.sourceInstalled, .sourceChecksum(proof.checksum)], + evidence: ["The validated LaunchAgent recovery source was installed."] + ) + case .remove: + let target = serviceTarget(for: record) + let loaded = try await verifiedLoadedState(for: record) + if loaded { + try await requireLaunchctlSuccess(["bootout", target]) + } else { + guard let stillExistingSourceURL = proposedSourceURL?.standardizedFileURL else { + throw AutomationExecutorError.missingSource + } + try await requireLaunchctlSuccess([ + "bootout", domainTarget, stillExistingSourceURL.path, + ]) + } + let finalResolution = try await runLaunchctlPrint(target) + guard LaunchctlServiceTargetClassifier.classify( + finalResolution, + label: record.label, + guiUID: guiUID + ) == .absent else { + throw AutomationExecutorError.postconditionNotVerified + } + return AutomationExecutorResult( + postconditions: [.sourceRemoved, .targetUnresolved], + evidence: ["The LaunchAgent source was removed and its service target no longer resolves."] + ) + default: + throw AutomationExecutorError.unsupportedOperation + } + } + + public func restorePreTransactionState( + _ state: AutomationPreTransactionState, + for record: AutomationRecord, + recovery: AutomationVerifiedRecoveryInput, + linkedProcesses: [ClassifiedDevProcess], + expectedAppliedState: AutomationExecutorAppliedState? + ) async throws -> AutomationExecutorResult { + guard record.kind == .launchAgent, + record.sourceKind == .launchAgent, + record.ownership == .user, + record.ownerUID == guiUID, + isValidLaunchdLabel(record.label), + let sourceURL = canonicalSourceURL(for: record) + else { throw AutomationExecutorError.unsupportedSource } + switch state.enabledState { + case .enabled: + try await requireLaunchctlSuccess(["enable", serviceTarget(for: record)]) + case .disabled: + try await requireLaunchctlSuccess(["disable", serviceTarget(for: record)]) + case .unknown: + throw AutomationExecutorError.postconditionNotVerified + } + let isLoaded = try await verifiedLoadedState(for: record) + switch state.loadState { + case .loaded where !isLoaded: + try await requireLaunchctlSuccess(["bootstrap", domainTarget, sourceURL.path]) + case .unloaded where isLoaded: + try await requireLaunchctlSuccess(["bootout", serviceTarget(for: record)]) + case .unknown: + throw AutomationExecutorError.postconditionNotVerified + default: + break + } + return AutomationExecutorResult( + postconditions: [.preTransactionStateRestored(state)], + evidence: ["launchd accepted the exact enabled and loaded recovery state."] + ) + } + + private func serviceTarget(for record: AutomationRecord) -> String { + "gui/\(guiUID)/\(record.label)" + } + + private func isValidLaunchdLabel(_ label: String) -> Bool { + !label.isEmpty && !label.contains("/") && !label.contains("\0") + } + + private var domainTarget: String { "gui/\(guiUID)" } + + private func canonicalSourceURL(for record: AutomationRecord) -> URL? { + guard let sourceURL = record.sourceURL, sourceURL.path.hasPrefix("/") else { return nil } + return sourceURL.standardizedFileURL + } + + private func runLaunchctl(_ arguments: [String]) async throws -> AutomationCommandResult { + do { + return try await runner.run(AutomationCommand( + executable: "/bin/launchctl", + arguments: arguments + )) + } catch { + throw AutomationExecutorError.commandUnavailable(executable: "/bin/launchctl") + } + } + + private func verifiedLoadedState(for record: AutomationRecord) async throws -> Bool { + let result = try await runLaunchctlPrint(serviceTarget(for: record)) + switch LaunchctlServiceTargetClassifier.classify( + result, + label: record.label, + guiUID: guiUID + ) { + case .loaded: + return true + case .absent: + return false + case .unknown: + throw AutomationExecutorError.commandFailed( + executable: "/bin/launchctl", + status: result.status + ) + } + } + + private func runLaunchctlPrint(_ target: String) async throws -> AutomationCommandResult { + do { + return try await runner.run(AutomationCommand( + executable: "/bin/launchctl", + arguments: ["print", target], + environment: ["LC_ALL": "C"] + )) + } catch { + throw AutomationExecutorError.commandUnavailable(executable: "/bin/launchctl") + } + } + + private func requireLaunchctlSuccess(_ arguments: [String]) async throws { + let result = try await runLaunchctl(arguments) + guard result.status == 0 else { + throw AutomationExecutorError.commandFailed( + executable: "/bin/launchctl", + status: result.status + ) + } + } + + private func sourceProof(at url: URL?) throws -> (url: URL, checksum: String) { + guard let fileSystem, let url else { throw AutomationExecutorError.missingSource } + let canonicalURL = url.standardizedFileURL + let data: Data + do { + data = try fileSystem.read(canonicalURL) + } catch { + throw AutomationExecutorError.missingSource + } + guard (try? LaunchdPlistParser.parse( + data: data, + sourceURL: canonicalURL, + ownerUID: guiUID, + ownership: .user + )) != nil else { throw AutomationExecutorError.invalidSource } + return (canonicalURL, Self.checksum(data)) + } + + private static func checksum(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } +} + +public struct CronAutomationExecutor: AutomationMutationApplying { + public typealias ProcessSnapshot = @Sendable () async throws -> [DevProcess] + private let runner: any AutomationCommandRunning + private let fileSystem: any AutomationFileSystem + private let currentUID: uid_t + private let processKiller: ProcessKiller + private let currentProcessID: Int32 + private let processIdentityResolver: ProcessKiller.IdentityResolver + private let processSnapshot: ProcessSnapshot + private let now: @Sendable () -> Date + private let terminationVerificationPolicy: AutomationTerminationVerificationPolicy + private let verificationSleep: AutomationVerificationSleep + + public init( + runner: any AutomationCommandRunning, + fileSystem: any AutomationFileSystem, + currentUID: uid_t + ) { + self.init( + runner: runner, + fileSystem: fileSystem, + currentUID: currentUID, + processKiller: ProcessKiller(), + currentProcessID: getpid(), + processIdentityResolver: ProcessKiller.resolveLiveIdentity, + processSnapshot: { throw AutomationExecutorError.processControlUnavailable }, + now: Date.init, + terminationVerificationPolicy: AutomationTerminationVerificationPolicy(), + verificationSleep: { try await Task.sleep(for: $0) } + ) + } + + public init( + runner: any AutomationCommandRunning, + fileSystem: any AutomationFileSystem, + currentUID: uid_t, + processSnapshot: @escaping ProcessSnapshot + ) { + self.init( + runner: runner, + fileSystem: fileSystem, + currentUID: currentUID, + processKiller: ProcessKiller(), + currentProcessID: getpid(), + processIdentityResolver: ProcessKiller.resolveLiveIdentity, + processSnapshot: processSnapshot, + now: Date.init, + terminationVerificationPolicy: AutomationTerminationVerificationPolicy(), + verificationSleep: { try await Task.sleep(for: $0) } + ) + } + + init( + runner: any AutomationCommandRunning, + fileSystem: any AutomationFileSystem, + currentUID: uid_t, + processKiller: ProcessKiller, + currentProcessID: Int32, + processIdentityResolver: @escaping ProcessKiller.IdentityResolver, + processSnapshot: @escaping ProcessSnapshot, + now: @escaping @Sendable () -> Date, + terminationVerificationPolicy: AutomationTerminationVerificationPolicy = .init(), + verificationSleep: @escaping AutomationVerificationSleep = { try await Task.sleep(for: $0) } + ) { + self.runner = runner + self.fileSystem = fileSystem + self.currentUID = currentUID + self.processKiller = processKiller + self.currentProcessID = currentProcessID + self.processIdentityResolver = processIdentityResolver + self.processSnapshot = processSnapshot + self.now = now + self.terminationVerificationPolicy = terminationVerificationPolicy + self.verificationSleep = verificationSleep + } + + public func apply( + _ operation: AutomationOperation, + to record: AutomationRecord + ) async throws -> AutomationExecutorResult { + try await apply(operation, to: record, linkedProcesses: [], proposedSourceURL: nil) + } + + public func apply( + _ operation: AutomationOperation, + to record: AutomationRecord, + linkedProcesses: [ClassifiedDevProcess], + proposedSourceURL: URL? + ) async throws -> AutomationExecutorResult { + guard record.kind == .cron, + record.sourceKind == .crontab, + record.ownership == .user, + record.ownerUID == currentUID + else { throw AutomationExecutorError.unsupportedSource } + + switch operation { + case .startNow: + throw AutomationExecutorError.unsupportedOperation + case .confirmedRunToCompletion(let confirmation): + guard confirmation.recordID == record.id, + let sourceChecksum = record.sourceChecksum, + confirmation.sourceChecksum == sourceChecksum, + let command = record.commandSignature, + !command.isEmpty, + confirmation.exactCommand == command, + !command.contains("\0") + else { throw AutomationExecutorError.invalidSource } + let shell = record.environment["SHELL"] ?? "/bin/sh" + guard shell.hasPrefix("/"), !shell.contains("\0") else { + throw AutomationExecutorError.invalidSource + } + let result = try await run(AutomationCommand( + executable: shell, + arguments: ["-c", command], + environment: record.environment + )) + guard result.status == 0 else { + throw AutomationExecutorError.commandFailed(executable: shell, status: result.status) + } + return AutomationExecutorResult( + postconditions: [.targetResolved, .runCompleted], + evidence: ["The confirmed cron command completed with exit status 0."] + ) + case .edit, .duplicate, .importRecord, .restore: + let checksum = try await installDocument(at: proposedSourceURL) + return AutomationExecutorResult( + postconditions: [.sourceInstalled, .sourceChecksum(checksum)], + evidence: ["The complete current-user crontab document was installed and verified."], + appliedState: .cronLiveDocument(normalizedChecksum: checksum) + ) + case .enable: + let checksum = try await installDocument(at: proposedSourceURL) + return AutomationExecutorResult( + postconditions: [ + .sourceInstalled, + .sourceChecksum(checksum), + .futureLaunchesEnabled, + ], + evidence: ["The complete current-user crontab document was installed and verified."], + appliedState: .cronLiveDocument(normalizedChecksum: checksum) + ) + case .disable: + let checksum = try await installDocument(at: proposedSourceURL) + return AutomationExecutorResult( + postconditions: [ + .sourceInstalled, + .sourceChecksum(checksum), + .futureLaunchesDisabled, + ], + evidence: ["The complete current-user crontab document was installed and verified."], + appliedState: .cronLiveDocument(normalizedChecksum: checksum) + ) + case .stopCurrentRun: + try await stopStronglyLinkedProcesses(linkedProcesses, for: record) + return AutomationExecutorResult( + postconditions: [.noLinkedProcess], + evidence: ["Every exact cron carrier birth identity exited after SIGTERM."] + ) + case .disableAndStop: + let checksum = try await installDocument(at: proposedSourceURL) + do { + try await stopStronglyLinkedProcesses(linkedProcesses, for: record) + } catch { + throw AutomationExecutorMutationFailure( + error: (error as? AutomationExecutorError) ?? .processControlUnavailable, + appliedState: .cronLiveDocument(normalizedChecksum: checksum) + ) + } + return AutomationExecutorResult( + postconditions: [ + .sourceInstalled, + .sourceChecksum(checksum), + .futureLaunchesDisabled, + .noLinkedProcess, + ], + evidence: ["The complete crontab was installed and every controlled cron carrier exited."], + appliedState: .cronLiveDocument(normalizedChecksum: checksum) + ) + case .remove: + let checksum = try await installDocument(at: proposedSourceURL) + return AutomationExecutorResult( + postconditions: [.sourceRemoved, .sourceChecksum(checksum)], + evidence: ["The complete current-user crontab document without the selected entry was installed and verified."], + appliedState: .cronLiveDocument(normalizedChecksum: checksum) + ) + default: + throw AutomationExecutorError.unsupportedOperation + } + } + + public func restorePreTransactionState( + _ state: AutomationPreTransactionState, + for record: AutomationRecord, + recovery: AutomationVerifiedRecoveryInput, + linkedProcesses: [ClassifiedDevProcess], + expectedAppliedState: AutomationExecutorAppliedState? + ) async throws -> AutomationExecutorResult { + guard record.kind == .cron, + record.sourceKind == .crontab, + record.ownership == .user, + record.ownerUID == currentUID, + state.sourceExisted, + state.loadState == .unknown + else { throw AutomationExecutorError.postconditionNotVerified } + guard case .cronLiveDocument(let ownedChecksum) = expectedAppliedState else { + throw AutomationExecutorError.postconditionNotVerified + } + guard Self.rawChecksum(recovery.data) == recovery.checksum else { + throw AutomationExecutorError.postconditionNotVerified + } + let prepared = try prepareDocument( + data: recovery.data, + nextTo: recovery.backupURL, + expectedRawChecksum: recovery.checksum + ) + let document = CronParser.parse(String(decoding: prepared.data, as: UTF8.self)) + let restoredEnabledState = document.entries.first(where: { + CronParser.recordID(for: $0, ownerUID: currentUID) == record.id + }).map { $0.isEnabled ? AutomationEnabledState.enabled : .disabled } + guard restoredEnabledState == state.enabledState else { + throw AutomationExecutorError.postconditionNotVerified + } + + let live = try await run(AutomationCommand( + executable: "/usr/bin/crontab", + arguments: ["-l"], + environment: ["LC_ALL": "C"] + )) + guard live.status == 0, + CronDocumentChecksum.checksum(live.standardOutput) == ownedChecksum + else { throw AutomationExecutorError.postconditionNotVerified } + + // crontab(1) exposes no atomic compare-and-swap. Keep this install as the immediately + // following external operation after ownership proof; an unavoidable process-launch gap remains. + let installResult = try await run(AutomationCommand( + executable: "/usr/bin/crontab", + arguments: [prepared.temporaryURL.path] + )) + guard installResult.status == 0 else { + throw AutomationExecutorError.commandFailed( + executable: "/usr/bin/crontab", + status: installResult.status + ) + } + let listed = try await run(AutomationCommand( + executable: "/usr/bin/crontab", + arguments: ["-l"], + environment: ["LC_ALL": "C"] + )) + guard listed.status == 0, + CronDocumentChecksum.checksum(listed.standardOutput) == prepared.checksum + else { + throw AutomationExecutorError.postconditionNotVerified + } + var postconditions: Set = [ + .sourceChecksum(prepared.checksum), + ] + if state.linkedProcesses.isEmpty { + postconditions.insert(.preTransactionStateRestored(state)) + } + let result = AutomationExecutorResult( + postconditions: postconditions, + evidence: [ + "The authenticated crontab backup was staged before the final ownership proof, then installed and verified; crontab provides no atomic compare-and-swap.", + ] + ) + try removeStagedFile(prepared.staged) + return result + } + + private func run(_ command: AutomationCommand) async throws -> AutomationCommandResult { + do { + return try await runner.run(command) + } catch { + throw AutomationExecutorError.commandUnavailable(executable: command.executable) + } + } + + private func installDocument(at sourceURL: URL?) async throws -> String { + let prepared = try prepareDocument(at: sourceURL) + let checksum = try await installPreparedDocument(prepared) + try removeStagedFile(prepared.staged) + return checksum + } + + private struct PreparedCronDocument { + let data: Data + let staged: AutomationStagedFile + let checksum: String + + var temporaryURL: URL { staged.url } + } + + private func prepareDocument(at sourceURL: URL?) throws -> PreparedCronDocument { + guard let sourceURL else { throw AutomationExecutorError.missingSource } + let data: Data + do { + data = try fileSystem.read(sourceURL.standardizedFileURL) + } catch { + throw AutomationExecutorError.missingSource + } + return try prepareDocument(data: data, nextTo: sourceURL, expectedRawChecksum: nil) + } + + private func prepareDocument( + data: Data, + nextTo sourceURL: URL, + expectedRawChecksum: String? + ) throws -> PreparedCronDocument { + guard expectedRawChecksum == nil || Self.rawChecksum(data) == expectedRawChecksum, + let text = String(data: data, encoding: .utf8), + CronParser.parse(text).invalidLines.isEmpty + else { throw AutomationExecutorError.invalidSource } + + let staged: AutomationStagedFile + do { + let parent = sourceURL.deletingLastPathComponent().standardizedFileURL + let parentMetadata = try fileSystem.metadata(for: parent) + guard let authorization = AutomationDirectoryAuthorization( + directoryURL: parent, + resourceIdentifier: parentMetadata.resourceIdentifier + ) else { throw AutomationExecutorError.missingSource } + staged = try fileSystem.writeStagedFile( + nextTo: sourceURL.standardizedFileURL, + data: data, + permissions: 0o600, + authorization: authorization + ) + } catch { + throw AutomationExecutorError.missingSource + } + let stagedData: Data + do { + stagedData = try fileSystem.read(staged.url) + } catch { + try removeStagedFile(staged) + throw AutomationExecutorError.missingSource + } + guard stagedData == data, + expectedRawChecksum == nil || Self.rawChecksum(stagedData) == expectedRawChecksum + else { + try removeStagedFile(staged) + throw AutomationExecutorError.postconditionNotVerified + } + guard let intendedChecksum = CronDocumentChecksum.checksum(data) else { + try removeStagedFile(staged) + throw AutomationExecutorError.invalidSource + } + return PreparedCronDocument( + data: data, + staged: staged, + checksum: intendedChecksum + ) + } + + private func removeStagedFile(_ staged: AutomationStagedFile) throws { + switch try fileSystem.removeItem(staged.authorization) { + case .committed: + return + case .unchanged: + throw AutomationFilePartialMutation( + kind: .remove, + commitState: .unknown, + observedFiles: [staged.authorization], + recoveryHandle: staged.authorization, + resultURL: staged.url + ) + } + } + + private static func rawChecksum(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + + private func installPreparedDocument(_ prepared: PreparedCronDocument) async throws -> String { + let installResult = try await run(AutomationCommand( + executable: "/usr/bin/crontab", + arguments: [prepared.temporaryURL.path] + )) + guard installResult.status == 0 else { + throw AutomationExecutorError.commandFailed( + executable: "/usr/bin/crontab", + status: installResult.status + ) + } + + let appliedState = AutomationExecutorAppliedState.cronLiveDocument( + normalizedChecksum: prepared.checksum + ) + + let listed: AutomationCommandResult + do { + listed = try await run(AutomationCommand( + executable: "/usr/bin/crontab", + arguments: ["-l"], + environment: ["LC_ALL": "C"] + )) + } catch let error as AutomationExecutorError { + throw AutomationExecutorMutationFailure(error: error, appliedState: appliedState) + } + guard listed.status == 0, + CronDocumentChecksum.checksum(listed.standardOutput) + == prepared.checksum + else { + throw AutomationExecutorMutationFailure( + error: .postconditionNotVerified, + appliedState: appliedState + ) + } + return prepared.checksum + } + + private func stopStronglyLinkedProcesses( + _ linkedProcesses: [ClassifiedDevProcess], + for record: AutomationRecord + ) async throws { + guard !linkedProcesses.isEmpty, + linkedProcesses.allSatisfy({ $0.process.birthToken != nil }) + else { throw AutomationExecutorError.processControlUnavailable } + let freshProcesses: [DevProcess] + do { + freshProcesses = try await processSnapshot() + } catch { + throw AutomationExecutorError.processControlUnavailable + } + let expected = Set(linkedProcesses.map { ProcessIdentity(process: $0.process) }) + let proven = Set(AutomationProcessCorrelator.links( + records: [record], + processes: freshProcesses, + now: now() + ).filter { $0.strength == .strong }.map(\.processIdentity)) + guard expected.isSubset(of: proven) else { + throw AutomationExecutorError.processControlUnavailable + } + for process in linkedProcesses.sorted(by: { $0.process.pid < $1.process.pid }) { + _ = try processKiller.terminate(process, currentProcessID: currentProcessID) + } + for identity in expected { + guard try await oldBirthExited( + identity, + resolver: processIdentityResolver, + policy: terminationVerificationPolicy, + sleep: verificationSleep + ) else { + throw AutomationExecutorError.postconditionNotVerified + } + } + } +} + +public struct LegacyLoginItemAutomationExecutor: AutomationMutationApplying { + private static let fixedMutationProgram = """ + on run argv + if (count of argv) is not 3 then error "invalid arguments" + set requestedAction to item 1 of argv + set targetPath to item 2 of argv + set hiddenText to item 3 of argv + if hiddenText is "true" then + set hiddenAtLogin to true + else if hiddenText is "false" then + set hiddenAtLogin to false + else + error "invalid hidden state" + end if + tell application "System Events" + if requestedAction is "add" then + if not (exists login item 1 whose path is targetPath) then + make new login item at end with properties {path:targetPath, hidden:hiddenAtLogin} + end if + else if requestedAction is "remove" then + delete every login item whose path is targetPath + else + error "invalid action" + end if + end tell + end run + """ + + private let runner: any AutomationCommandRunning + private let listing: any LegacyLoginItemListing + private let currentUID: uid_t + + public init( + runner: any AutomationCommandRunning, + listing: any LegacyLoginItemListing, + currentUID: uid_t + ) { + self.runner = runner + self.listing = listing + self.currentUID = currentUID + } + + public func apply( + _ operation: AutomationOperation, + to record: AutomationRecord + ) async throws -> AutomationExecutorResult { + try await apply(operation, to: record, linkedProcesses: [], proposedSourceURL: nil) + } + + public func apply( + _ operation: AutomationOperation, + to record: AutomationRecord, + linkedProcesses: [ClassifiedDevProcess], + proposedSourceURL: URL? + ) async throws -> AutomationExecutorResult { + guard record.kind == .loginItem, + record.sourceKind == .legacyLoginItem, + record.ownership == .user, + record.ownerUID == currentUID, + let path = targetPath(for: record) + else { throw AutomationExecutorError.unsupportedSource } + + switch operation { + case .enable: + try await mutate("add", path: path, isHidden: false) + guard try await contains(path) else { + throw AutomationExecutorError.postconditionNotVerified + } + return AutomationExecutorResult( + postconditions: [.futureLaunchesEnabled, .sourceInstalled], + evidence: ["The current-user legacy login item is present."] + ) + case .disable: + try await mutate("remove", path: path, isHidden: false) + guard try await !contains(path) else { + throw AutomationExecutorError.postconditionNotVerified + } + return AutomationExecutorResult( + postconditions: [.futureLaunchesDisabled], + evidence: ["The current-user legacy login item is absent without claiming its process stopped."] + ) + case .remove: + try await mutate("remove", path: path, isHidden: false) + guard try await !contains(path) else { + throw AutomationExecutorError.postconditionNotVerified + } + return AutomationExecutorResult( + postconditions: [.sourceRemoved], + evidence: ["The current-user legacy login item is absent."] + ) + case .startNow: + let result = try await run(AutomationCommand( + executable: "/usr/bin/open", + arguments: ["--", path] + )) + guard result.status == 0 else { + throw AutomationExecutorError.commandFailed(executable: "/usr/bin/open", status: result.status) + } + return AutomationExecutorResult( + postconditions: [.targetResolved, .currentRunStarted], + evidence: ["macOS accepted the typed open request for the login-item path."] + ) + default: + throw AutomationExecutorError.unsupportedOperation + } + } + + public func restorePreTransactionState( + _ state: AutomationPreTransactionState, + for record: AutomationRecord, + recovery: AutomationVerifiedRecoveryInput, + linkedProcesses: [ClassifiedDevProcess], + expectedAppliedState: AutomationExecutorAppliedState? + ) async throws -> AutomationExecutorResult { + guard record.kind == .loginItem, + record.sourceKind == .legacyLoginItem, + record.ownership == .user, + record.ownerUID == currentUID, + state.loadState == .unknown, + state.linkedProcesses.isEmpty, + let path = targetPath(for: record) + else { throw AutomationExecutorError.postconditionNotVerified } + guard Self.rawChecksum(recovery.data) == recovery.checksum else { + throw AutomationExecutorError.postconditionNotVerified + } + let recoveryDescriptor = try recoveryDescriptor( + recovery.data, + for: record, + path: path + ) + switch state.enabledState { + case .enabled: + try await mutate("add", path: path, isHidden: recoveryDescriptor.isHidden) + guard try await descriptor(for: path)?.isHidden == recoveryDescriptor.isHidden else { + throw AutomationExecutorError.postconditionNotVerified + } + case .disabled: + try await mutate("remove", path: path, isHidden: recoveryDescriptor.isHidden) + guard try await !contains(path) else { + throw AutomationExecutorError.postconditionNotVerified + } + case .unknown: + throw AutomationExecutorError.postconditionNotVerified + } + return AutomationExecutorResult( + postconditions: [.preTransactionStateRestored(state)], + evidence: ["The exact current-user legacy login-item presence state was restored."] + ) + } + + private func targetPath(for record: AutomationRecord) -> String? { + guard let path = record.executable, path.hasPrefix("/"), !path.contains("\0") else { + return nil + } + return URL(fileURLWithPath: path).standardizedFileURL.path + } + + private func mutate(_ action: String, path: String, isHidden: Bool) async throws { + let result = try await run(AutomationCommand( + executable: "/usr/bin/osascript", + arguments: ["-e", Self.fixedMutationProgram, "--", action, path, isHidden ? "true" : "false"] + )) + guard result.status == 0 else { + throw AutomationExecutorError.commandFailed(executable: "/usr/bin/osascript", status: result.status) + } + } + + private func contains(_ path: String) async throws -> Bool { + try await descriptor(for: path) != nil + } + + private func descriptor(for path: String) async throws -> LegacyLoginItemDescriptor? { + do { + return try await listing.currentUserLoginItems().first { + URL(fileURLWithPath: $0.path).standardizedFileURL.path == path + } + } catch { + throw AutomationExecutorError.postconditionNotVerified + } + } + + private func recoveryDescriptor( + _ data: Data, + for record: AutomationRecord, + path: String + ) throws -> LegacyLoginItemRecoveryDescriptor { + guard let descriptor = try? JSONDecoder().decode( + LegacyLoginItemRecoveryDescriptor.self, + from: data + ), descriptor.version == 1, + descriptor.recordID == record.id.rawValue, + descriptor.ownerUID == currentUID, + descriptor.name == record.label, + descriptor.path.hasPrefix("/"), + !descriptor.path.contains("\0"), + URL(fileURLWithPath: descriptor.path).standardizedFileURL.path == path, + record.id == AutomationRecord.ID( + source: .legacyLoginItem, + ownerUID: currentUID, + label: descriptor.name, + sourcePath: path + ) + else { throw AutomationExecutorError.invalidSource } + return descriptor + } + + private func run(_ command: AutomationCommand) async throws -> AutomationCommandResult { + do { + return try await runner.run(command) + } catch { + throw AutomationExecutorError.commandUnavailable(executable: command.executable) + } + } + + private static func rawChecksum(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } +} diff --git a/Sources/DevScopeCore/AutomationInventoryService.swift b/Sources/DevScopeCore/AutomationInventoryService.swift new file mode 100644 index 0000000..693ec6e --- /dev/null +++ b/Sources/DevScopeCore/AutomationInventoryService.swift @@ -0,0 +1,615 @@ +import Foundation + +public struct AutomationInventorySnapshot: Equatable, Sendable { + public let generation: UInt64 + public let records: [AutomationRecord] + public let health: [AutomationSourceKind: AutomationSourceHealth] + public let refreshedAt: Date + + public init( + generation: UInt64, + records: [AutomationRecord], + health: [AutomationSourceKind: AutomationSourceHealth], + refreshedAt: Date + ) { + self.generation = generation + self.records = records + self.health = health + self.refreshedAt = refreshedAt + } +} + +public actor AutomationInventoryService { + private struct InFlightRefresh: Sendable { + let id: UInt64 + let task: Task + } + + private struct InFlightSourceRefresh: Sendable { + let id: UInt64 + let task: Task + } + + private let sources: [any AutomationSource] + private let minimumRefreshInterval: TimeInterval + private let sourceTimeout: Duration + private let now: @Sendable () -> Date + private var cachedSnapshot: AutomationInventorySnapshot? + private var inFlightRefresh: InFlightRefresh? + private var inFlightSourceRefreshes: [Int: InFlightSourceRefresh] = [:] + private var nextRefreshID: UInt64 = 0 + private var nextSourceRefreshID: UInt64 = 0 + + public init( + sources: [any AutomationSource], + minimumRefreshInterval: TimeInterval = 60, + sourceTimeout: Duration = .seconds(10), + now: @escaping @Sendable () -> Date = Date.init + ) { + self.sources = sources + self.minimumRefreshInterval = max(0, minimumRefreshInterval) + self.sourceTimeout = max(.milliseconds(1), sourceTimeout) + self.now = now + } + + public func refresh(force: Bool = false) async -> AutomationInventorySnapshot { + let refreshDate = now() + if !force, + let cachedSnapshot, + refreshDate.timeIntervalSince(cachedSnapshot.refreshedAt) < minimumRefreshInterval + { + return cachedSnapshot + } + if let inFlightRefresh { + return await complete(inFlightRefresh) + } + + let generation = (cachedSnapshot?.generation ?? 0) + 1 + let sourceTimeout = self.sourceTimeout + let task = Task { + let sourceSnapshots = await refreshSources( + timeout: sourceTimeout, + refreshedAt: refreshDate + ) + let health = Self.aggregatedHealth(sourceSnapshots) + let records = Self.deduplicated(sourceSnapshots.flatMap(\.records)) + return AutomationInventorySnapshot( + generation: generation, + records: records, + health: health, + refreshedAt: refreshDate + ) + } + nextRefreshID &+= 1 + let inFlight = InFlightRefresh(id: nextRefreshID, task: task) + inFlightRefresh = inFlight + return await complete(inFlight) + } + + public func refreshAfterCurrent() async -> AutomationInventorySnapshot { + if let inFlightRefresh { + _ = await complete(inFlightRefresh) + } + return await refresh(force: true) + } + + private func complete( + _ refresh: InFlightRefresh + ) async -> AutomationInventorySnapshot { + let snapshot = await refresh.task.value + if inFlightRefresh?.id == refresh.id { + cachedSnapshot = snapshot + inFlightRefresh = nil + } + return snapshot + } + + private func refreshSources( + timeout: Duration, + refreshedAt: Date + ) async -> [AutomationSourceSnapshot] { + let sources = self.sources + return await withTaskGroup( + of: (Int, AutomationSourceSnapshot).self, + returning: [AutomationSourceSnapshot].self + ) { group in + for (index, source) in sources.enumerated() { + group.addTask { [weak self] in + guard let self else { + return ( + index, + .failed( + kind: source.kind, + message: "Automation inventory service became unavailable.", + refreshedAt: refreshedAt + ) + ) + } + return ( + index, + await self.refreshSource( + at: index, + source, + timeout: timeout, + refreshedAt: refreshedAt + ) + ) + } + } + + var indexed: [(Int, AutomationSourceSnapshot)] = [] + for await value in group { + indexed.append(value) + } + return indexed.sorted { $0.0 < $1.0 }.map(\.1) + } + } + + private enum SourceRefreshOutcome: Sendable { + case snapshot(AutomationSourceSnapshot) + case timedOut + case cancelled + } + + private final class SourceRefreshResolution: @unchecked Sendable { + private let lock = NSLock() + private var continuation: CheckedContinuation? + + init(_ continuation: CheckedContinuation) { + self.continuation = continuation + } + + func resolve(_ outcome: SourceRefreshOutcome) { + let continuation = lock.withLock { + defer { self.continuation = nil } + return self.continuation + } + continuation?.resume(returning: outcome) + } + } + + private func refreshSource( + at index: Int, + _ source: any AutomationSource, + timeout: Duration, + refreshedAt: Date + ) async -> AutomationSourceSnapshot { + if inFlightSourceRefreshes[index] != nil { + return .failed( + kind: source.kind, + message: "A previous timed-out automation source refresh is still completing; a new call was not started.", + refreshedAt: refreshedAt + ) + } + + nextSourceRefreshID &+= 1 + let refreshID = nextSourceRefreshID + let sourceTask = Task { await source.snapshot() } + inFlightSourceRefreshes[index] = InFlightSourceRefresh(id: refreshID, task: sourceTask) + Task { [weak self] in + _ = await sourceTask.value + await self?.sourceRefreshCompleted(at: index, id: refreshID) + } + + let first = await Self.firstOutcome(of: sourceTask, timeout: timeout) + switch first { + case let .snapshot(snapshot): + sourceRefreshCompleted(at: index, id: refreshID) + return snapshot + case .timedOut: + return .failed( + kind: source.kind, + message: "Automation source refresh exceeded its configured time limit.", + refreshedAt: refreshedAt + ) + case .cancelled: + return .failed( + kind: source.kind, + message: "Automation source refresh was cancelled.", + refreshedAt: refreshedAt + ) + } + } + + private func sourceRefreshCompleted(at index: Int, id: UInt64) { + guard inFlightSourceRefreshes[index]?.id == id else { return } + inFlightSourceRefreshes[index] = nil + } + + private static func firstOutcome( + of sourceTask: Task, + timeout: Duration + ) async -> SourceRefreshOutcome { + await withCheckedContinuation { + (continuation: CheckedContinuation) in + let resolution = SourceRefreshResolution(continuation) + Task { + resolution.resolve(.snapshot(await sourceTask.value)) + } + Task { + do { + try await Task.sleep(for: timeout) + resolution.resolve(.timedOut) + } catch is CancellationError { + resolution.resolve(.cancelled) + } catch { + resolution.resolve(.cancelled) + } + sourceTask.cancel() + } + } + } + + private static func aggregatedHealth( + _ snapshots: [AutomationSourceSnapshot] + ) -> [AutomationSourceKind: AutomationSourceHealth] { + snapshots.reduce(into: [:]) { result, snapshot in + let kind = snapshot.health.kind + guard let existing = result[kind] else { + result[kind] = snapshot.health + return + } + result[kind] = worseHealth(existing, snapshot.health) + } + } + + private static func worseHealth( + _ lhs: AutomationSourceHealth, + _ rhs: AutomationSourceHealth + ) -> AutomationSourceHealth { + let lhsRank = healthRank(lhs.state) + let rhsRank = healthRank(rhs.state) + if lhsRank != rhsRank { return lhsRank > rhsRank ? lhs : rhs } + if lhs.refreshedAt != rhs.refreshedAt { return lhs.refreshedAt > rhs.refreshedAt ? lhs : rhs } + return (lhs.message ?? "") <= (rhs.message ?? "") ? lhs : rhs + } + + private static func healthRank(_ state: AutomationSourceHealthState) -> Int { + switch state { + case .healthy: 0 + case .partial: 1 + case .permissionRequired: 2 + case .failed: 3 + } + } + + private static func deduplicated(_ records: [AutomationRecord]) -> [AutomationRecord] { + var groups: [[AutomationRecord]] = [] + + for record in records.sorted(by: recordPrecedes) { + let matchingIndexes = groups.indices.filter { groupIndex in + groups[groupIndex].contains { exactIdentityMatches($0, record) } + } + var mergedGroup = [record] + var mergedIndexes: [Int] = [] + for index in matchingIndexes where !strongIdentityConflicts(groups[index], mergedGroup) { + mergedGroup.append(contentsOf: groups[index]) + mergedIndexes.append(index) + } + guard !mergedIndexes.isEmpty else { + groups.append([record]) + continue + } + + for index in mergedIndexes.reversed() { + groups.remove(at: index) + } + groups.append(mergedGroup) + } + + return groups + .map(mergedRecord) + .sorted(by: recordPrecedes) + } + + private static func exactIdentityMatches( + _ lhs: AutomationRecord, + _ rhs: AutomationRecord + ) -> Bool { + if let lhsBundle = nonempty(lhs.providerBundleIdentifier), + let rhsBundle = nonempty(rhs.providerBundleIdentifier), + lhsBundle == rhsBundle + { + return true + } + + if lhs.label == rhs.label, + !lhs.label.isEmpty, + let lhsExecutable = canonicalPath(lhs.executable), + let rhsExecutable = canonicalPath(rhs.executable), + lhsExecutable == rhsExecutable + { + return true + } + + if let lhsSource = lhs.sourceURL?.standardizedFileURL, + let rhsSource = rhs.sourceURL?.standardizedFileURL, + lhsSource == rhsSource + { + return true + } + return false + } + + private static func strongIdentityConflicts( + _ lhs: [AutomationRecord], + _ rhs: [AutomationRecord] + ) -> Bool { + valuesConflict( + Set(lhs.compactMap { nonempty($0.providerBundleIdentifier) }), + Set(rhs.compactMap { nonempty($0.providerBundleIdentifier) }) + ) || valuesConflict( + Set(lhs.compactMap(labelExecutableIdentity)), + Set(rhs.compactMap(labelExecutableIdentity)) + ) || valuesConflict( + Set(lhs.compactMap(canonicalSourceReference)), + Set(rhs.compactMap(canonicalSourceReference)) + ) + } + + private static func valuesConflict( + _ lhs: Set, + _ rhs: Set + ) -> Bool { + !lhs.isEmpty && !rhs.isEmpty && lhs != rhs + } + + private static func labelExecutableIdentity(_ record: AutomationRecord) -> String? { + guard !record.label.isEmpty, let executable = canonicalPath(record.executable) else { + return nil + } + return "\(record.label)\u{0}\(executable)" + } + + private static func canonicalSourceReference(_ record: AutomationRecord) -> URL? { + record.sourceURL?.standardizedFileURL + } + + private static func mergedRecord(_ records: [AutomationRecord]) -> AutomationRecord { + let primary = records.sorted(by: recordPrecedes).first! + let evidence = Array(Set(records.flatMap(\.evidence))).sorted { + if $0.strength != $1.strength { return $0.strength > $1.strength } + if $0.source != $1.source { return $0.source < $1.source } + return $0.detail < $1.detail + } + + return AutomationRecord( + id: primary.id, + kind: primary.kind, + sourceKind: primary.sourceKind, + label: primary.label, + displayName: primary.displayName, + providerBundleIdentifier: primary.providerBundleIdentifier, + ownerUID: primary.ownerUID, + ownership: primary.ownership, + executable: primary.executable, + arguments: primary.arguments, + commandSignature: primary.commandSignature, + environment: primary.environment, + workingDirectory: primary.workingDirectory, + schedule: primary.schedule, + sourceURL: primary.sourceURL, + sourceChecksum: primary.sourceChecksum, + enabledState: primary.enabledState, + loadState: primary.loadState, + approvalState: primary.approvalState, + state: primary.state, + evidence: evidence, + capabilities: primary.capabilities, + validationFindings: Array(Set(records.flatMap(\.validationFindings))).sorted() + ) + } + + private static func recordPrecedes(_ lhs: AutomationRecord, _ rhs: AutomationRecord) -> Bool { + let lhsRank = recordRank(lhs) + let rhsRank = recordRank(rhs) + if lhsRank != rhsRank { return lhsRank.lexicographicallyPrecedes(rhsRank) } + let lhsCapabilities = lhs.capabilities.map(\.rawValue).sorted(by: utf8Precedes) + let rhsCapabilities = rhs.capabilities.map(\.rawValue).sorted(by: utf8Precedes) + if lhsCapabilities != rhsCapabilities { + return utf8SequencePrecedes(lhsCapabilities, rhsCapabilities) + } + return recordOrderingKey(lhs).lexicographicallyPrecedes(recordOrderingKey(rhs)) + } + + private static func utf8SequencePrecedes(_ lhs: [String], _ rhs: [String]) -> Bool { + for (lhsValue, rhsValue) in zip(lhs, rhs) where lhsValue != rhsValue { + return utf8Precedes(lhsValue, rhsValue) + } + return lhs.count < rhs.count + } + + private static func utf8Precedes(_ lhs: String, _ rhs: String) -> Bool { + lhs.utf8.lexicographicallyPrecedes(rhs.utf8) + } + + // This total key exists only for in-memory comparisons. It must never be persisted, + // logged, or copied into diagnostics because it can contain commands and local paths. + private static func recordOrderingKey(_ record: AutomationRecord) -> [UInt8] { + var key = CanonicalOrderingKey() + + // Capability choice affects the surviving primary record, so compare the exact set + // before any descriptive or identity fields once the policy rank is tied. + key.append(record.capabilities.map(\.rawValue).sorted()) + key.append(record.sourceURL?.standardizedFileURL.path) + key.append(record.sourceKind.rawValue) + key.append(record.kind.rawValue) + key.append(record.ownership.rawValue) + key.append(record.state.rawValue) + key.append(record.enabledState.rawValue) + key.append(record.loadState.rawValue) + key.append(record.approvalState.rawValue) + key.append(canonicalPath(record.executable)) + key.append(record.arguments) + key.append(record.commandSignature) + key.append(record.providerBundleIdentifier) + key.append(record.ownerUID.map(UInt64.init)) + appendSchedule(record.schedule, to: &key) + appendCanonicalEvidence(record.evidence, to: &key) + key.append(record.validationFindings.sorted(by: utf8Precedes)) + + // Remaining exact value fields make the comparison total even when canonical forms + // above intentionally normalize equivalent paths or collection ordering. + key.append(record.label) + key.append(record.id.rawValue) + key.append(record.displayName) + key.append(record.executable) + appendEnvironment(record.environment, to: &key) + key.append(canonicalPath(record.workingDirectory)) + key.append(record.workingDirectory) + key.append(record.sourceURL?.absoluteString) + key.append(record.sourceURL?.relativeString) + key.append(record.sourceURL?.baseURL?.absoluteString) + key.append(record.sourceChecksum) + appendEvidenceInOriginalOrder(record.evidence, to: &key) + key.append(record.validationFindings) + return key.bytes + } + + private static func appendEnvironment( + _ environment: [String: String], + to key: inout CanonicalOrderingKey + ) { + let entries = environment.sorted { + if $0.key != $1.key { return $0.key.utf8.lexicographicallyPrecedes($1.key.utf8) } + return $0.value.utf8.lexicographicallyPrecedes($1.value.utf8) + } + key.appendCount(entries.count) + for entry in entries { + key.append(entry.key) + key.append(entry.value) + } + } + + private static func appendSchedule( + _ schedule: AutomationSchedule, + to key: inout CanonicalOrderingKey + ) { + key.appendCount(schedule.triggers.count) + for trigger in schedule.triggers { + switch trigger { + case .atLogin: + key.append("atLogin") + case .runAtLoad: + key.append("runAtLoad") + case let .interval(seconds): + key.append("interval") + key.append(Int64(seconds)) + case let .calendar(value): + key.append("calendar") + key.append(value) + case .keepAlive: + key.append("keepAlive") + case let .cron(value): + key.append("cron") + key.append(value) + case .demand: + key.append("demand") + } + } + key.append(schedule.summary) + } + + private static func appendCanonicalEvidence( + _ evidence: [AutomationEvidence], + to key: inout CanonicalOrderingKey + ) { + let sorted = evidence.sorted { + if $0.strength != $1.strength { return $0.strength.rawValue < $1.strength.rawValue } + if $0.source != $1.source { + return $0.source.utf8.lexicographicallyPrecedes($1.source.utf8) + } + return $0.detail.utf8.lexicographicallyPrecedes($1.detail.utf8) + } + appendEvidenceInOriginalOrder(sorted, to: &key) + } + + private static func appendEvidenceInOriginalOrder( + _ evidence: [AutomationEvidence], + to key: inout CanonicalOrderingKey + ) { + key.appendCount(evidence.count) + for item in evidence { + key.append(Int64(item.strength.rawValue)) + key.append(item.source) + key.append(item.detail) + } + } + + private static func recordRank(_ record: AutomationRecord) -> [Int] { + [ + record.ownership == .user ? 0 : 1, + -record.capabilities.count, + sourceRank(record.sourceKind), + ] + } + + private static func sourceRank(_ kind: AutomationSourceKind) -> Int { + switch kind { + case .launchAgent: 0 + case .legacyLoginItem: 1 + case .crontab: 2 + case .launchDaemon: 3 + case .serviceManagement: 4 + } + } + + private static func nonempty(_ value: String?) -> String? { + guard let value, !value.isEmpty else { return nil } + return value + } + + private static func canonicalPath(_ value: String?) -> String? { + guard let value = nonempty(value) else { return nil } + return URL(fileURLWithPath: value).standardizedFileURL.path + } +} + +private struct CanonicalOrderingKey { + private(set) var bytes: [UInt8] = [] + + mutating func append(_ value: String) { + let utf8 = Array(value.utf8) + append(UInt64(utf8.count)) + bytes.append(contentsOf: utf8) + } + + mutating func append(_ value: String?) { + guard let value else { + bytes.append(0) + return + } + bytes.append(1) + append(value) + } + + mutating func append(_ values: [String]) { + appendCount(values.count) + for value in values { + append(value) + } + } + + mutating func append(_ value: UInt64?) { + guard let value else { + bytes.append(0) + return + } + bytes.append(1) + append(value) + } + + mutating func append(_ value: Int64) { + append(UInt64(bitPattern: value) ^ (1 << 63)) + } + + mutating func appendCount(_ count: Int) { + append(UInt64(count)) + } + + private mutating func append(_ value: UInt64) { + for shift in stride(from: 56, through: 0, by: -8) { + bytes.append(UInt8(truncatingIfNeeded: value >> shift)) + } + } +} diff --git a/Sources/DevScopeCore/AutomationManager.swift b/Sources/DevScopeCore/AutomationManager.swift new file mode 100644 index 0000000..69dfbc9 --- /dev/null +++ b/Sources/DevScopeCore/AutomationManager.swift @@ -0,0 +1,4006 @@ +import CryptoKit +import Darwin +import Foundation + +public enum AutomationManagerConfigurationError: Error, Equatable, Sendable { + case destinationAuthorizationUnavailable + case recoverableSourceUnavailable + case exactStateRestorationUnavailable + case runtimeProcessVerificationUnavailable +} + +public struct AutomationRecoverableSource: Equatable, Sendable { + public let transactionURL: URL + public let data: Data + public let checksum: String + public let metadata: AutomationFileMetadata + + public init( + transactionURL: URL, + data: Data, + checksum: String, + metadata: AutomationFileMetadata + ) { + self.transactionURL = transactionURL + self.data = data + self.checksum = checksum + self.metadata = metadata + } +} + +public enum AutomationOperation: Equatable, Sendable { + case startNow + case confirmedRunToCompletion(AutomationRunToCompletionConfirmation) + case stopCurrentRun + case enable + case disable + case disableAndStop + case edit(AutomationEditPayload) + case duplicate(AutomationEditPayload) + case importRecord(AutomationImportPayload) + case exportRecord(redacted: Bool) + case remove + case restore(AutomationBackup.ID) +} + +public struct AutomationRunToCompletionConfirmation: Equatable, Sendable { + public let recordID: AutomationRecord.ID + public let sourceChecksum: String? + public let exactCommand: String + + public init( + recordID: AutomationRecord.ID, + sourceChecksum: String?, + exactCommand: String + ) { + self.recordID = recordID + self.sourceChecksum = sourceChecksum + self.exactCommand = exactCommand + } +} + +public struct AutomationEditPayload: Equatable, Sendable { + public let label: String + public let executable: String + public let arguments: [String] + public let environment: [String: String] + public let workingDirectory: String? + public let schedule: AutomationSchedule + public let rawRepresentation: Data? + public let destination: URL? + public let expectedDestinationChecksum: String? + + public init( + label: String, + executable: String, + arguments: [String], + environment: [String: String], + workingDirectory: String?, + schedule: AutomationSchedule, + rawRepresentation: Data?, + destination: URL? = nil, + expectedDestinationChecksum: String? = nil + ) { + self.label = label + self.executable = executable + self.arguments = arguments + self.environment = environment + self.workingDirectory = workingDirectory + self.schedule = schedule + self.rawRepresentation = rawRepresentation + self.destination = destination + self.expectedDestinationChecksum = expectedDestinationChecksum + } +} + +public struct AutomationImportPayload: Equatable, Sendable { + public let destination: URL + public let data: Data + public let expectedKind: AutomationKind + public let expectedDestinationChecksum: String? + + public init( + destination: URL, + data: Data, + expectedKind: AutomationKind, + expectedDestinationChecksum: String? = nil + ) { + self.destination = destination + self.data = data + self.expectedKind = expectedKind + self.expectedDestinationChecksum = expectedDestinationChecksum + } +} + +public struct AutomationBackup: Identifiable, Equatable, Sendable { + public struct ID: RawRepresentable, Hashable, Sendable { + public let rawValue: UUID + + public init(rawValue: UUID) { + self.rawValue = rawValue + } + } + + public let id: ID + public let recordID: AutomationRecord.ID + public let sourceURL: URL + public let backupURL: URL + public let checksum: String + public let createdAt: Date + public let sourceExisted: Bool + public let ownerUID: uid_t + public let kind: AutomationKind + public let sourceKind: AutomationSourceKind + public let authorizedRecordIDs: Set + public let parentResourceIdentifier: String? + + public init( + id: ID, + recordID: AutomationRecord.ID, + sourceURL: URL, + backupURL: URL, + checksum: String, + createdAt: Date, + sourceExisted: Bool = true, + ownerUID: uid_t = 0, + kind: AutomationKind = .launchAgent, + sourceKind: AutomationSourceKind = .launchAgent, + authorizedRecordIDs: Set? = nil, + parentResourceIdentifier: String? = nil + ) { + self.id = id + self.recordID = recordID + self.sourceURL = sourceURL + self.backupURL = backupURL + self.checksum = checksum + self.createdAt = createdAt + self.sourceExisted = sourceExisted + self.ownerUID = ownerUID + self.kind = kind + self.sourceKind = sourceKind + self.authorizedRecordIDs = authorizedRecordIDs ?? [recordID] + self.parentResourceIdentifier = parentResourceIdentifier + } +} + +public enum AutomationRollbackOutcome: Equatable, Sendable { + case notNeeded + case restored(AutomationBackup.ID) + case failed(String) +} + +public enum AutomationOperationStatus: Equatable, Sendable { + case succeeded + case rejected(String) + case failed(String) + case partialFailure(String) +} + +public struct AutomationExportArtifact: Equatable, Sendable { + public let suggestedFilename: String + public let mediaType: String + public let format: String + public let data: Data + public let isRedacted: Bool + + public init( + suggestedFilename: String, + mediaType: String, + format: String, + data: Data, + isRedacted: Bool + ) { + self.suggestedFilename = suggestedFilename + self.mediaType = mediaType + self.format = format + self.data = data + self.isRedacted = isRedacted + } +} + +public enum AutomationRecordExporter { + public static func artifact( + for record: AutomationRecord, + verifiedSourceData: Data?, + requestedRedaction: Bool + ) throws -> AutomationExportArtifact { + let protected = record.ownership != .user + || record.kind == .launchDaemon + || record.kind == .backgroundItem + || record.sourceKind == .launchDaemon + || record.sourceKind == .serviceManagement + let redacted = requestedRedaction || protected || verifiedSourceData == nil + if !redacted, let verifiedSourceData { + let presentation: (extension: String, mediaType: String, format: String) + switch record.kind { + case .launchAgent, .launchDaemon: + presentation = ("plist", "application/x-plist", "source.plist") + case .cron: + presentation = ("crontab", "text/plain", "source.crontab") + case .loginItem: + presentation = ("json", "application/json", "source.legacy-login-item") + case .backgroundItem: + presentation = ("json", "application/json", "inspection-manifest.v1") + } + return AutomationExportArtifact( + suggestedFilename: "devscope-\(record.sourceKind.rawValue)-\(record.id.rawValue.prefix(12)).\(presentation.extension)", + mediaType: presentation.mediaType, + format: presentation.format, + data: verifiedSourceData, + isRedacted: false + ) + } + + var payload: [String: Any] = [ + "schemaVersion": 1, + "recordID": record.id.rawValue, + "kind": record.kind.rawValue, + "sourceKind": record.sourceKind.rawValue, + "ownership": record.ownership.rawValue, + "enabledState": record.enabledState.rawValue, + "loadState": record.loadState.rawValue, + "approvalState": record.approvalState.rawValue, + "state": record.state.rawValue, + "redacted": redacted, + "importable": false, + "scheduleSummary": record.schedule.summary, + ] + payload["providerBundleIdentifier"] = record.providerBundleIdentifier + payload["sourceChecksum"] = record.sourceChecksum + payload["label"] = "" + payload["displayName"] = "" + payload["argumentCount"] = record.arguments.count + payload["environmentKeys"] = record.environment.keys.sorted() + payload["validationFindingCount"] = record.validationFindings.count + payload["evidenceCount"] = record.evidence.count + let data = try JSONSerialization.data( + withJSONObject: payload, + options: [.prettyPrinted, .sortedKeys] + ) + return AutomationExportArtifact( + suggestedFilename: "devscope-\(record.sourceKind.rawValue)-\(record.id.rawValue.prefix(12)).json", + mediaType: "application/json", + format: "inspection-manifest.v1", + data: data, + isRedacted: redacted + ) + } +} + +public struct AutomationOperationResult: Equatable, Sendable { + public let operation: AutomationOperation + public let status: AutomationOperationStatus + public let appliedSteps: [String] + public let verificationEvidence: [String] + public let rollback: AutomationRollbackOutcome + public let manualRecovery: String? + public let backup: AutomationBackup? + public let exportArtifact: AutomationExportArtifact? + public let fileMutationEvidence: AutomationFilePartialMutation? + public let rollbackFileMutationEvidence: AutomationFilePartialMutation? + + public init( + operation: AutomationOperation, + status: AutomationOperationStatus, + appliedSteps: [String], + verificationEvidence: [String], + rollback: AutomationRollbackOutcome, + manualRecovery: String?, + backup: AutomationBackup? = nil, + exportArtifact: AutomationExportArtifact? = nil, + fileMutationEvidence: AutomationFilePartialMutation? = nil, + rollbackFileMutationEvidence: AutomationFilePartialMutation? = nil + ) { + self.operation = operation + self.status = status + self.appliedSteps = appliedSteps + self.verificationEvidence = verificationEvidence + self.rollback = rollback + self.manualRecovery = manualRecovery + self.backup = backup + self.exportArtifact = exportArtifact + self.fileMutationEvidence = fileMutationEvidence + self.rollbackFileMutationEvidence = rollbackFileMutationEvidence + } +} + +public enum AutomationPostcondition: Equatable, Hashable, Sendable { + case targetResolved + case targetUnresolved + case currentRunStarted + case runCompleted + case noLinkedProcess + case futureLaunchesEnabled + case futureLaunchesDisabled + case sourceInstalled + case sourceRemoved + case sourceChecksum(String) + case preTransactionStateRestored(AutomationPreTransactionState) +} + +public struct AutomationExecutorResult: Equatable, Sendable { + public let postconditions: Set + public let evidence: [String] + public let appliedState: AutomationExecutorAppliedState? + + public init( + postconditions: Set, + evidence: [String], + appliedState: AutomationExecutorAppliedState? = nil + ) { + self.postconditions = postconditions + self.evidence = evidence + self.appliedState = appliedState + } +} + +public enum AutomationExecutorAppliedState: Equatable, Hashable, Sendable { + case cronLiveDocument(normalizedChecksum: String) +} + +public struct AutomationExecutorMutationFailure: Error, Equatable, Sendable { + public let error: AutomationExecutorError + public let appliedState: AutomationExecutorAppliedState + + public init(error: AutomationExecutorError, appliedState: AutomationExecutorAppliedState) { + self.error = error + self.appliedState = appliedState + } +} + +public struct AutomationVerifiedRecoveryInput: Equatable, Sendable { + public let backupID: AutomationBackup.ID + public let backupURL: URL + public let data: Data + public let checksum: String + + public init( + backupID: AutomationBackup.ID, + backupURL: URL, + data: Data, + checksum: String + ) { + self.backupID = backupID + self.backupURL = backupURL + self.data = data + self.checksum = checksum + } +} + +public struct AutomationLinkedProcessIdentity: Equatable, Hashable, Sendable { + public let processID: Int32 + public let birthToken: ProcessBirthToken + + public init(processID: Int32, birthToken: ProcessBirthToken) { + self.processID = processID + self.birthToken = birthToken + } +} + +public struct AutomationPreTransactionState: Equatable, Hashable, Sendable { + public let sourceExisted: Bool + public let enabledState: AutomationEnabledState + public let loadState: AutomationLoadState + public let linkedProcesses: Set + + public init( + sourceExisted: Bool = true, + enabledState: AutomationEnabledState, + loadState: AutomationLoadState, + linkedProcesses: Set + ) { + self.sourceExisted = sourceExisted + self.enabledState = enabledState + self.loadState = loadState + self.linkedProcesses = linkedProcesses + } +} + +public protocol AutomationMutationApplying: Sendable { + func apply( + _ operation: AutomationOperation, + to record: AutomationRecord, + linkedProcesses: [ClassifiedDevProcess], + proposedSourceURL: URL? + ) async throws -> AutomationExecutorResult + + func restorePreTransactionState( + _ state: AutomationPreTransactionState, + for record: AutomationRecord, + recovery: AutomationVerifiedRecoveryInput, + linkedProcesses: [ClassifiedDevProcess], + expectedAppliedState: AutomationExecutorAppliedState? + ) async throws -> AutomationExecutorResult +} + +public extension AutomationMutationApplying { + func restorePreTransactionState( + _ state: AutomationPreTransactionState, + for record: AutomationRecord, + recovery: AutomationVerifiedRecoveryInput, + linkedProcesses: [ClassifiedDevProcess], + expectedAppliedState: AutomationExecutorAppliedState? + ) async throws -> AutomationExecutorResult { + throw AutomationManagerConfigurationError.exactStateRestorationUnavailable + } +} + +public actor AutomationManager { + private let fileSystem: any AutomationFileSystem + private let executor: any AutomationMutationApplying + private let capabilityContext: @Sendable (AutomationRecord) throws -> AutomationCapabilityContext + private let destinationContext: @Sendable ( + AutomationRecord, + URL + ) throws -> AutomationCapabilityContext + private let recoverableSource: @Sendable ( + AutomationRecord + ) async throws -> AutomationRecoverableSource + private let refresh: @Sendable () async -> AutomationInventorySnapshot + private let refreshProcesses: @Sendable () async throws -> [DevProcess] + private let backupDirectory: URL + private let currentUID: uid_t + private let now: @Sendable () -> Date + private let makeUUID: @Sendable () -> UUID + private let evidenceRedactor: @Sendable (String) -> String + private var backupsByID: [AutomationBackup.ID: AutomationBackup] = [:] + private var manifestURLsByID: [AutomationBackup.ID: URL] = [:] + private var backupAuthorizationsByID: [AutomationBackup.ID: AutomationFileAuthorization] = [:] + private var manifestAuthorizationsByID: [AutomationBackup.ID: AutomationFileAuthorization] = [:] + private var manifestDataByID: [AutomationBackup.ID: Data] = [:] + private var recoveryRootResourceIdentifier: String? + private var transactionIsActive = false + private var transactionWaiters: [CheckedContinuation] = [] + + public init( + fileSystem: any AutomationFileSystem, + executor: any AutomationMutationApplying, + capabilityContext: @escaping @Sendable (AutomationRecord) throws -> AutomationCapabilityContext, + destinationContext: @escaping @Sendable ( + AutomationRecord, + URL + ) throws -> AutomationCapabilityContext = { _, _ in + throw AutomationManagerConfigurationError.destinationAuthorizationUnavailable + }, + recoverableSource: @escaping @Sendable ( + AutomationRecord + ) async throws -> AutomationRecoverableSource = { _ in + throw AutomationManagerConfigurationError.recoverableSourceUnavailable + }, + refresh: @escaping @Sendable () async -> AutomationInventorySnapshot, + refreshProcesses: @escaping @Sendable () async throws -> [DevProcess] = { + throw AutomationManagerConfigurationError.runtimeProcessVerificationUnavailable + }, + backupDirectory: URL, + currentUID: uid_t = getuid(), + now: @escaping @Sendable () -> Date = Date.init, + makeUUID: @escaping @Sendable () -> UUID = UUID.init, + evidenceRedactor: @escaping @Sendable (String) -> String = { _ in + "Executor evidence redacted." + } + ) { + self.fileSystem = fileSystem + self.executor = executor + self.capabilityContext = capabilityContext + self.destinationContext = destinationContext + self.recoverableSource = recoverableSource + self.refresh = refresh + self.refreshProcesses = refreshProcesses + self.backupDirectory = backupDirectory + self.currentUID = currentUID + self.now = now + self.makeUUID = makeUUID + self.evidenceRedactor = evidenceRedactor + recoveryRootResourceIdentifier = try? fileSystem.metadata(for: backupDirectory) + .resourceIdentifier + let loaded = Self.loadManifests( + fileSystem: fileSystem, + backupDirectory: backupDirectory, + currentUID: currentUID + ) + backupsByID = loaded.backups + manifestURLsByID = loaded.manifestURLs + backupAuthorizationsByID = loaded.backupAuthorizations + manifestAuthorizationsByID = loaded.manifestAuthorizations + manifestDataByID = loaded.manifestData + } + + public func perform( + _ operation: AutomationOperation, + record: AutomationRecord, + expectedChecksum: String?, + linkedProcesses: [ClassifiedDevProcess] + ) async -> AutomationOperationResult { + await acquireTransaction() + defer { releaseTransaction() } + + if case let .exportRecord(requestedRedaction) = operation, + record.ownership != .user + || record.kind == .launchDaemon + || record.kind == .backgroundItem + || record.sourceKind == .launchDaemon + || record.sourceKind == .serviceManagement + { + guard expectedChecksum == record.sourceChecksum else { + return rejected(operation, "The automation record changed since it was inspected.") + } + let context: AutomationCapabilityContext + do { + context = try capabilityContext(record) + } catch { + return rejected(operation, "DevScope could not verify the automation record identity.") + } + let decision = AutomationCapabilityPolicy.decision(for: record, context: context) + guard decision.capabilities.contains(.exportRecord) else { + return rejected(operation, decision.reason ?? "Export is not available for this source.") + } + do { + let artifact = try AutomationRecordExporter.artifact( + for: record, + verifiedSourceData: nil, + requestedRedaction: requestedRedaction + ) + return AutomationOperationResult( + operation: operation.redactedForResult, + status: .succeeded, + appliedSteps: ["Prepared an automation export without mutating its source."], + verificationEvidence: ["The export artifact is bound to the inspected record generation."], + rollback: .notNeeded, + manualRecovery: nil, + exportArtifact: artifact + ) + } catch { + return failed(operation, "DevScope could not prepare the automation export.") + } + } + + let restorationBackup: AutomationBackup? + if case .restore(let id) = operation { + guard let backup = backupsByID[id], + backup.authorizedRecordIDs.contains(record.id), + backup.ownerUID == record.ownerUID, + backup.kind == record.kind, + backup.sourceKind == record.sourceKind + else { + return rejected(operation, "The selected recovery backup does not belong to this automation.") + } + restorationBackup = backup + } else { + restorationBackup = nil + } + + let sourceURL: URL + let sourceData: Data + let metadata: AutomationFileMetadata + let sourceExistedAtPreflight: Bool + do { + if let restorationBackup { + sourceURL = restorationBackup.sourceURL.standardizedFileURL + sourceExistedAtPreflight = fileSystem.itemExists(at: sourceURL) + if sourceExistedAtPreflight { + sourceData = try fileSystem.read(sourceURL) + metadata = try fileSystem.metadata(for: sourceURL) + } else { + sourceData = Data() + metadata = try fileSystem.metadata(for: sourceURL.deletingLastPathComponent()) + } + } else if !record.sourceKind.requiresRecoverableSource, let directURL = record.sourceURL { + sourceURL = directURL.standardizedFileURL + sourceData = try fileSystem.read(sourceURL) + metadata = try fileSystem.metadata(for: sourceURL) + sourceExistedAtPreflight = true + } else { + let captured = try await recoverableSource(record) + sourceURL = captured.transactionURL.standardizedFileURL + sourceData = captured.data + metadata = captured.metadata + guard captured.checksum == Self.sourceChecksum(captured.data, kind: record.kind), + try fileSystem.read(sourceURL) == captured.data, + try fileSystem.metadata(for: sourceURL) == captured.metadata + else { + return rejected(operation, "The recoverable source identity could not be established.") + } + sourceExistedAtPreflight = true + } + } catch let partial as AutomationFilePartialMutation { + _ = await refresh() + return AutomationOperationResult( + operation: operation.redactedForResult, + status: .partialFailure( + "HIGH SEVERITY: recoverable source materialization reached a partial filesystem state." + ), + appliedSteps: ["Recoverable source materialization reached a partial filesystem state."], + verificationEvidence: ["Refreshed automation truth before returning."], + rollback: .notNeeded, + manualRecovery: "Preserve and inspect the authorized recovery handle before retrying.", + fileMutationEvidence: partial + ) + } catch { + let reason: String + switch record.sourceKind { + case .crontab: + reason = "Current-user crontab management is unavailable until recoverable source access is installed." + case .legacyLoginItem: + reason = "Legacy login-item management is unavailable until recoverable source access is installed." + default: + reason = "DevScope could not re-read the automation source." + } + return rejected(operation, reason) + } + + let observedChecksum = Self.sourceChecksum(sourceData, kind: record.kind) + if restorationBackup != nil { + guard sourceExistedAtPreflight ? expectedChecksum == observedChecksum : expectedChecksum == nil else { + return rejected(operation, "The restore destination changed since it was inspected.") + } + } else if expectedChecksum != observedChecksum { + return rejected(operation, "The automation source changed since it was inspected.") + } + if case .confirmedRunToCompletion(let confirmation) = operation { + guard confirmation.recordID == record.id, + let confirmationChecksum = confirmation.sourceChecksum, + confirmationChecksum == record.sourceChecksum, + confirmationChecksum == observedChecksum + else { + return rejected(operation, "The confirmed cron command belongs to a stale source generation.") + } + } + + let context: AutomationCapabilityContext + do { + context = try restorationBackup == nil + ? capabilityContext(record) + : destinationContext(record, sourceURL) + } catch { + return rejected(operation, "DevScope could not verify the automation source identity.") + } + let authorizedMetadataURL = sourceExistedAtPreflight + ? sourceURL + : sourceURL.deletingLastPathComponent().standardizedFileURL + guard metadata.resourceIdentifier?.isEmpty == false, + metadata.ownerUID == context.sourceOwnerUID, + metadata.isSymbolicLink == context.isSymlink, + metadata.canonicalURL.standardizedFileURL.path == authorizedMetadataURL.path + else { + return rejected(operation, "The automation source identity changed during verification.") + } + let decision = AutomationCapabilityPolicy.decision(for: record, context: context) + guard decision.capabilities.contains(operation.requiredCapability) else { + return rejected(operation, decision.reason ?? "This operation is not available for the source.") + } + + if case let .exportRecord(requestedRedaction) = operation { + do { + let artifact = try AutomationRecordExporter.artifact( + for: record, + verifiedSourceData: sourceData, + requestedRedaction: requestedRedaction + ) + return AutomationOperationResult( + operation: operation.redactedForResult, + status: .succeeded, + appliedSteps: ["Prepared an automation export without mutating its source."], + verificationEvidence: ["The export uses the checksum-verified source generation."], + rollback: .notNeeded, + manualRecovery: nil, + exportArtifact: artifact + ) + } catch { + return failed(operation, "DevScope could not prepare the automation export.") + } + } + + let proposal: SourceProposal? + do { + proposal = try makeProposal( + for: operation, + record: record, + sourceURL: sourceURL, + selectedSourceData: sourceData + ) + } catch let error as ProposalError { + return rejected(operation, error.message) + } catch { + return rejected(operation, "The proposed automation source could not be prepared.") + } + + if let proposal, proposal.restoresSourcePresence, + !Self.isValidSource( + proposal.data, + kind: record.kind, + expectedLabel: proposal.intendedLabel, + sourceURL: proposal.destination, + ownerUID: context.currentUID + ) + { + return rejected(operation, "The proposed automation source is not valid.") + } + + let destinationSnapshot: DestinationSnapshot? + do { + destinationSnapshot = try proposal.map { + try verifyDestination( + $0, + record: record, + selectedSourceURL: sourceURL, + selectedSourceData: sourceData, + selectedMetadata: metadata + ) + } + } catch let error as ProposalError { + return rejected(operation, error.message) + } catch { + return rejected(operation, "DevScope could not authorize the intended destination.") + } + + let linkedIdentities = linkedProcesses.compactMap { classified -> AutomationLinkedProcessIdentity? in + guard let birthToken = classified.process.birthToken else { return nil } + return AutomationLinkedProcessIdentity( + processID: classified.process.pid, + birthToken: birthToken + ) + } + if operation.controlsLinkedProcesses, linkedIdentities.count != linkedProcesses.count { + return rejected(operation, "Every controlled process requires an exact birth identity.") + } + let preTransactionState = AutomationPreTransactionState( + sourceExisted: destinationSnapshot?.existed ?? sourceExistedAtPreflight, + enabledState: record.enabledState, + loadState: record.loadState, + linkedProcesses: Set(linkedIdentities) + ) + + let backup: AutomationBackup + do { + let recoveryData = destinationSnapshot?.data ?? (proposal == nil ? sourceData : Data()) + let recoveryURL = proposal?.destination ?? sourceURL + backup = try createBackup( + sourceData: recoveryData, + checksum: Self.checksum(recoveryData), + sourceURL: recoveryURL, + sourceExisted: destinationSnapshot?.existed ?? true, + record: record + ) + } catch let partial as AutomationFilePartialMutation { + _ = await refresh() + return AutomationOperationResult( + operation: operation.redactedForResult, + status: .partialFailure( + "HIGH SEVERITY: recovery evidence installation was only partially completed." + ), + appliedSteps: ["Recovery evidence installation reached a partial filesystem state."], + verificationEvidence: ["Refreshed automation truth before returning."], + rollback: .notNeeded, + manualRecovery: "Preserve and inspect the authorized recovery handle before retrying.", + fileMutationEvidence: partial + ) + } catch { + return failed( + operation, + "DevScope could not create owner-only recovery evidence.", + manualRecovery: "No changes were applied. Verify that the recovery folder is writable." + ) + } + + if restorationBackup == nil { + do { + if let revalidationFailure = try await revalidationFailure( + operation: operation, + record: record, + sourceURL: sourceURL, + expectedChecksum: observedChecksum, + initialMetadata: metadata + ) { + return failed( + operation, + revalidationFailure, + appliedSteps: ["Created owner-only recovery backup."], + manualRecovery: "No mutation was applied; inspect the source again before retrying.", + backup: backup + ) + } + } catch let partial as AutomationFilePartialMutation { + _ = await refresh() + return AutomationOperationResult( + operation: operation.redactedForResult, + status: .partialFailure( + "HIGH SEVERITY: recoverable source materialization reached a partial filesystem state." + ), + appliedSteps: [ + "Created owner-only recovery backup.", + "Recoverable source materialization reached a partial filesystem state.", + ], + verificationEvidence: ["Refreshed automation truth before returning."], + rollback: .notNeeded, + manualRecovery: "Preserve and inspect the authorized recovery handle before retrying.", + backup: backup, + fileMutationEvidence: partial + ) + } catch { + return failed( + operation, + "DevScope could not revalidate the automation source immediately before mutation.", + appliedSteps: ["Created owner-only recovery backup."], + manualRecovery: "No mutation was applied; inspect the source again before retrying.", + backup: backup + ) + } + } + if let proposal, let destinationSnapshot, + let failure = destinationRevalidationFailure( + proposal, + snapshot: destinationSnapshot, + record: record + ) + { + return failed( + operation, + failure, + appliedSteps: ["Created owner-only recovery backup."], + manualRecovery: "No mutation was applied; inspect the destination again before retrying.", + backup: backup + ) + } + + var proposedSourceURL: URL? + var sourceMutationStep: String? + var postMutationResourceIdentifier: String? + if let proposal, proposal.restoresSourcePresence { + do { + guard let destinationSnapshot else { + throw AutomationManagerConfigurationError.destinationAuthorizationUnavailable + } + let parentAuthorization = try Self.directoryAuthorization( + for: proposal.destination.deletingLastPathComponent(), + resourceIdentifier: destinationSnapshot.parentMetadata.resourceIdentifier + ) + let staged = try fileSystem.writeStagedFile( + nextTo: proposal.destination, + data: proposal.data, + permissions: 0o600, + authorization: parentAuthorization + ) + let stagedData = try fileSystem.read(staged.url) + guard Self.isValidSource( + stagedData, + kind: record.kind, + expectedLabel: proposal.intendedLabel, + sourceURL: proposal.destination, + ownerUID: context.currentUID + ), stagedData == proposal.data else { + try removeCleanupArtifact(staged.authorization) + return failed( + operation, + "The proposed automation source is not valid.", + appliedSteps: ["Created owner-only recovery backup."], + backup: backup + ) + } + if let failure = destinationRevalidationFailure( + proposal, + snapshot: destinationSnapshot, + record: record + ) { + try removeCleanupArtifact(staged.authorization) + return failed( + operation, + failure, + appliedSteps: ["Created owner-only recovery backup."], + backup: backup + ) + } + let destinationAuthorization = try Self.fileAuthorization( + for: proposal.destination, + directory: parentAuthorization, + resourceIdentifier: destinationSnapshot.metadata.resourceIdentifier, + existed: destinationSnapshot.existed + ) + let replacementReceipt = try committedReplacement( + at: destinationAuthorization, + with: staged + ) + guard replacementReceipt.bindingVerified else { + throw AutomationManagerConfigurationError.recoverableSourceUnavailable + } + sourceMutationStep = "Installed validated source atomically." + let installedMetadata = try fileSystem.metadata(for: proposal.destination) + guard let installedIdentity = installedMetadata.resourceIdentifier, + !installedIdentity.isEmpty + else { + throw AutomationManagerConfigurationError.recoverableSourceUnavailable + } + postMutationResourceIdentifier = installedIdentity + let installedAuthorization = try Self.fileAuthorization( + for: proposal.destination, + directory: parentAuthorization, + resourceIdentifier: installedIdentity, + existed: true + ) + guard try fileSystem.fileMatchesBinding( + installedAuthorization, + binding: staged.binding + ) else { + throw AutomationManagerConfigurationError.recoverableSourceUnavailable + } + proposedSourceURL = proposal.destination + } catch let partial as AutomationFilePartialMutation { + postMutationResourceIdentifier = try? fileSystem.metadata(for: proposal.destination) + .resourceIdentifier + return await failureAfterMutation( + operation: operation, + record: record, + linkedProcesses: linkedProcesses, + backup: backup, + preTransactionState: preTransactionState, + proposal: proposal, + originalSourceMetadata: metadata, + postMutationResourceIdentifier: postMutationResourceIdentifier, + sourceMutationStep: "Filesystem reported a partial source replacement.", + phase: .sourceMutationFailed, + evidence: ["Filesystem mutation state was refreshed before executor use."], + expectedAppliedState: nil, + fileMutationEvidence: partial + ) + } catch { + if sourceMutationStep != nil { + postMutationResourceIdentifier = try? fileSystem.metadata(for: proposal.destination) + .resourceIdentifier + return await failureAfterMutation( + operation: operation, + record: record, + linkedProcesses: linkedProcesses, + backup: backup, + preTransactionState: preTransactionState, + proposal: proposal, + originalSourceMetadata: metadata, + postMutationResourceIdentifier: postMutationResourceIdentifier, + sourceMutationStep: sourceMutationStep, + phase: .sourceMutationFailed, + evidence: [], + expectedAppliedState: nil + ) + } + return failed( + operation, + "DevScope could not stage the proposed automation source.", + appliedSteps: ["Created owner-only recovery backup."], + backup: backup + ) + } + } else if let proposal { + do { + if fileSystem.itemExists(at: proposal.destination) { + guard try writeTargetIsAuthorized( + record: record, + url: proposal.destination, + expectedResourceIdentifier: destinationSnapshot?.metadata.resourceIdentifier, + expectedParentResourceIdentifier: destinationSnapshot?.parentMetadata.resourceIdentifier, + requiredCapability: .restore + ) else { + throw AutomationManagerConfigurationError.destinationAuthorizationUnavailable + } + let parentAuthorization = try Self.directoryAuthorization( + for: proposal.destination.deletingLastPathComponent(), + resourceIdentifier: destinationSnapshot?.parentMetadata.resourceIdentifier + ) + let destinationAuthorization = try Self.fileAuthorization( + for: proposal.destination, + directory: parentAuthorization, + resourceIdentifier: destinationSnapshot?.metadata.resourceIdentifier, + existed: true + ) + _ = try Self.committedReceipt(fileSystem.removeItem(destinationAuthorization)) + } + sourceMutationStep = "Restored the source's prior absence." + } catch let partial as AutomationFilePartialMutation { + return await failureAfterMutation( + operation: operation, + record: record, + linkedProcesses: linkedProcesses, + backup: backup, + preTransactionState: preTransactionState, + proposal: proposal, + originalSourceMetadata: metadata, + postMutationResourceIdentifier: nil, + sourceMutationStep: "Filesystem reported a partial removal while restoring absence.", + phase: .sourceMutationFailed, + evidence: ["Filesystem mutation state was refreshed before executor use."], + expectedAppliedState: nil, + fileMutationEvidence: partial + ) + } catch { + return failed( + operation, + "DevScope could not restore the prior absent source state.", + appliedSteps: ["Created owner-only recovery backup."], + backup: backup + ) + } + } + + if case .remove = operation, !record.sourceKind.requiresRecoverableSource { + do { + guard try writeTargetIsAuthorized( + record: record, + url: sourceURL, + expectedResourceIdentifier: metadata.resourceIdentifier, + expectedParentResourceIdentifier: backup.parentResourceIdentifier, + requiredCapability: .remove + ) else { + throw AutomationManagerConfigurationError.destinationAuthorizationUnavailable + } + let parentAuthorization = try Self.directoryAuthorization( + for: sourceURL.deletingLastPathComponent(), + resourceIdentifier: backup.parentResourceIdentifier + ) + let sourceAuthorization = try Self.fileAuthorization( + for: sourceURL, + directory: parentAuthorization, + resourceIdentifier: metadata.resourceIdentifier, + existed: true + ) + let trashReceipt = try Self.committedReceipt( + fileSystem.moveToTrash(sourceAuthorization) + ) + guard let trashURL = trashReceipt.resultURL else { + throw AutomationManagerConfigurationError.recoverableSourceUnavailable + } + proposedSourceURL = trashURL + sourceMutationStep = "Moved the source to Trash." + } catch let partial as AutomationFilePartialMutation { + proposedSourceURL = partial.resultURL + return await failureAfterMutation( + operation: operation, + record: record, + linkedProcesses: linkedProcesses, + backup: backup, + preTransactionState: preTransactionState, + proposal: proposal, + originalSourceMetadata: metadata, + postMutationResourceIdentifier: nil, + sourceMutationStep: "Filesystem reported a partial Trash move.", + phase: .sourceMutationFailed, + evidence: ["Filesystem mutation state was refreshed before executor use."], + expectedAppliedState: nil, + fileMutationEvidence: partial + ) + } catch { + return failed( + operation, + "DevScope could not move the automation source to Trash.", + appliedSteps: ["Created owner-only recovery backup."], + backup: backup + ) + } + } + + let executorResult: AutomationExecutorResult + do { + executorResult = try await executor.apply( + operation, + to: record, + linkedProcesses: linkedProcesses, + proposedSourceURL: proposedSourceURL + ) + } catch let partial as AutomationFilePartialMutation { + return await failureAfterMutation( + operation: operation, + record: record, + linkedProcesses: linkedProcesses, + backup: backup, + preTransactionState: preTransactionState, + proposal: proposal, + originalSourceMetadata: metadata, + postMutationResourceIdentifier: postMutationResourceIdentifier, + sourceMutationStep: sourceMutationStep, + phase: .executorAttempted, + evidence: ["Executor filesystem cleanup reached a partial state."], + expectedAppliedState: nil, + fileMutationEvidence: partial + ) + } catch { + let expectedAppliedState = (error as? AutomationExecutorMutationFailure)?.appliedState + return await failureAfterMutation( + operation: operation, + record: record, + linkedProcesses: linkedProcesses, + backup: backup, + preTransactionState: preTransactionState, + proposal: proposal, + originalSourceMetadata: metadata, + postMutationResourceIdentifier: postMutationResourceIdentifier, + sourceMutationStep: sourceMutationStep, + phase: .executorAttempted, + evidence: [], + expectedAppliedState: expectedAppliedState + ) + } + + let refreshed = await refresh() + let refreshedProcesses: [DevProcess] + if operation.requiresFreshProcessSnapshot { + do { + refreshedProcesses = try await refreshProcesses() + } catch { + return await failureAfterMutation( + operation: operation, + record: record, + linkedProcesses: linkedProcesses, + backup: backup, + preTransactionState: preTransactionState, + proposal: proposal, + originalSourceMetadata: metadata, + postMutationResourceIdentifier: postMutationResourceIdentifier, + sourceMutationStep: sourceMutationStep, + phase: .verificationFailed, + evidence: ["Live process verification was unavailable after the operation."], + expectedAppliedState: executorResult.appliedState + ) + } + } else { + refreshedProcesses = [] + } + let verification = verify( + operation, + record: record, + executorResult: executorResult, + snapshot: refreshed, + proposal: proposal, + transactionSourceURL: sourceURL, + controlledIdentities: preTransactionState.linkedProcesses, + liveProcesses: refreshedProcesses + ) + guard verification.satisfied else { + return await failureAfterMutation( + operation: operation, + record: record, + linkedProcesses: linkedProcesses, + backup: backup, + preTransactionState: preTransactionState, + proposal: proposal, + originalSourceMetadata: metadata, + postMutationResourceIdentifier: postMutationResourceIdentifier, + sourceMutationStep: sourceMutationStep, + phase: .verificationFailed, + evidence: executorResult.evidence.map(evidenceRedactor) + verification.evidence, + expectedAppliedState: executorResult.appliedState + ) + } + + if let verifiedRecordID = verification.verifiedRecordID, + !backup.authorizedRecordIDs.contains(verifiedRecordID) { + do { + try authorizeRecoverySuccessor(verifiedRecordID, for: backup.id) + } catch let partial as AutomationFilePartialMutation { + return await failureAfterMutation( + operation: operation, + record: record, + linkedProcesses: linkedProcesses, + backup: backup, + preTransactionState: preTransactionState, + proposal: proposal, + originalSourceMetadata: metadata, + postMutationResourceIdentifier: postMutationResourceIdentifier, + sourceMutationStep: sourceMutationStep, + phase: .verificationFailed, + evidence: verification.evidence + ["Recovery identity persistence reached a partial filesystem state."], + expectedAppliedState: executorResult.appliedState, + fileMutationEvidence: partial + ) + } catch { + return await failureAfterMutation( + operation: operation, + record: record, + linkedProcesses: linkedProcesses, + backup: backup, + preTransactionState: preTransactionState, + proposal: proposal, + originalSourceMetadata: metadata, + postMutationResourceIdentifier: postMutationResourceIdentifier, + sourceMutationStep: sourceMutationStep, + phase: .verificationFailed, + evidence: verification.evidence + ["Recovery identity could not be persisted."], + expectedAppliedState: executorResult.appliedState + ) + } + } + + return AutomationOperationResult( + operation: operation.redactedForResult, + status: .succeeded, + appliedSteps: [ + "Created owner-only recovery backup.", + sourceMutationStep, + "Applied typed automation operation.", + "Refreshed automation inventory.", + "Verified operation postconditions.", + ].compactMap { $0 }, + verificationEvidence: executorResult.evidence.map(evidenceRedactor) + verification.evidence, + rollback: .notNeeded, + manualRecovery: nil, + backup: backup.redactedForResult + ) + } + + public func restorationManifests() -> [AutomationBackup] { + return backupsByID.values.sorted { lhs, rhs in + if lhs.createdAt != rhs.createdAt { return lhs.createdAt < rhs.createdAt } + return lhs.id.rawValue.uuidString < rhs.id.rawValue.uuidString + }.map(\.redactedForResult) + } + + private func acquireTransaction() async { + guard transactionIsActive else { + transactionIsActive = true + return + } + await withCheckedContinuation { continuation in + transactionWaiters.append(continuation) + } + } + + private func releaseTransaction() { + guard !transactionWaiters.isEmpty else { + transactionIsActive = false + return + } + transactionWaiters.removeFirst().resume() + } + + private func createBackup( + sourceData: Data, + checksum: String, + sourceURL: URL, + sourceExisted: Bool, + record: AutomationRecord + ) throws -> AutomationBackup { + let id = AutomationBackup.ID(rawValue: makeUUID()) + let parentMetadata = try fileSystem.metadata(for: sourceURL.deletingLastPathComponent()) + guard parentMetadata.resourceIdentifier?.isEmpty == false else { + throw AutomationManagerConfigurationError.recoverableSourceUnavailable + } + let backupURL = backupDirectory.appendingPathComponent( + "\(id.rawValue.uuidString.lowercased()).backup", + isDirectory: false + ) + guard backupsByID[id] == nil, !fileSystem.itemExists(at: backupURL) else { + throw AutomationManagerConfigurationError.recoverableSourceUnavailable + } + try fileSystem.createDirectory(backupDirectory, permissions: 0o700) + let rootMetadata = try fileSystem.metadata(for: backupDirectory) + guard rootMetadata.canonicalURL.standardizedFileURL.path == backupDirectory.standardizedFileURL.path, + rootMetadata.ownerUID == currentUID, + !rootMetadata.isSymbolicLink, + rootMetadata.resourceIdentifier?.isEmpty == false, + rootMetadata.permissions == 0o700 + else { throw AutomationManagerConfigurationError.recoverableSourceUnavailable } + if let expectedRootIdentity = recoveryRootResourceIdentifier, + rootMetadata.resourceIdentifier != expectedRootIdentity { + throw AutomationManagerConfigurationError.recoverableSourceUnavailable + } + recoveryRootResourceIdentifier = rootMetadata.resourceIdentifier + let rootAuthorization = try Self.directoryAuthorization( + for: backupDirectory, + resourceIdentifier: rootMetadata.resourceIdentifier + ) + let stagedBackup = try fileSystem.writeStagedFile( + nextTo: backupURL, + data: sourceData, + permissions: 0o600, + authorization: rootAuthorization + ) + guard recoveryRootIsAuthorized() else { + try removeCleanupArtifact(stagedBackup.authorization) + throw AutomationManagerConfigurationError.recoverableSourceUnavailable + } + let absentBackup = try Self.fileAuthorization( + for: backupURL, + directory: rootAuthorization, + resourceIdentifier: nil, + existed: false + ) + _ = try committedReplacement(at: absentBackup, with: stagedBackup) + let backupResourceIdentifier: String + guard case .existing(let stagedBackupIdentity) = stagedBackup.authorization.expectation else { + throw AutomationManagerConfigurationError.recoverableSourceUnavailable + } + backupResourceIdentifier = stagedBackupIdentity + let backupAuthorization = try Self.fileAuthorization( + for: backupURL, + directory: rootAuthorization, + resourceIdentifier: backupResourceIdentifier, + existed: true + ) + guard recoveryArtifactIsVerified( + at: backupURL, + expectedData: sourceData, + expectedChecksum: checksum, + requiresPropertyList: false + ) else { + try removeCleanupArtifact(backupAuthorization) + throw AutomationManagerConfigurationError.recoverableSourceUnavailable + } + let backup = AutomationBackup( + id: id, + recordID: record.id, + sourceURL: sourceURL, + backupURL: backupURL, + checksum: checksum, + createdAt: now(), + sourceExisted: sourceExisted, + ownerUID: record.ownerUID ?? 0, + kind: record.kind, + sourceKind: record.sourceKind, + parentResourceIdentifier: parentMetadata.resourceIdentifier + ) + let manifestURL = backupDirectory.appendingPathComponent( + "\(id.rawValue.uuidString.lowercased()).manifest.plist", + isDirectory: false + ) + var installedManifestAuthorization: AutomationFileAuthorization? + var installedManifestData: Data? + do { + let manifestData = try Self.manifestData( + for: backup, + ownerUID: record.ownerUID ?? 0 + ) + let stagedManifest = try fileSystem.writeStagedFile( + nextTo: manifestURL, + data: manifestData, + permissions: 0o600, + authorization: rootAuthorization + ) + guard recoveryRootIsAuthorized() else { + try removeCleanupArtifact(stagedManifest.authorization) + throw AutomationManagerConfigurationError.recoverableSourceUnavailable + } + let absentManifest = try Self.fileAuthorization( + for: manifestURL, + directory: rootAuthorization, + resourceIdentifier: nil, + existed: false + ) + let manifestReceipt = try committedReplacement(at: absentManifest, with: stagedManifest) + installedManifestAuthorization = manifestReceipt.primaryFile + installedManifestData = manifestData + guard recoveryArtifactIsVerified( + at: manifestURL, + expectedData: manifestData, + expectedChecksum: Self.checksum(manifestData), + requiresPropertyList: true + ) else { throw AutomationManagerConfigurationError.recoverableSourceUnavailable } + } catch { + let originalPartial = error as? AutomationFilePartialMutation + var cleanupCandidates = [installedManifestAuthorization, backupAuthorization].compactMap { + $0 + } + if let originalPartial { + for candidate in originalPartial.recoveryHandles + where candidate.directory.directoryURL.standardizedFileURL + == backupDirectory.standardizedFileURL + && !cleanupCandidates.contains(candidate) { + cleanupCandidates.append(candidate) + } + } + do { + try cleanupRecoveryArtifacts(cleanupCandidates) + } catch let cleanupPartial as AutomationFilePartialMutation { + let observedSurvivors = currentAuthorizedSurvivors( + cleanupCandidates + (originalPartial?.observedFiles ?? []) + + cleanupPartial.observedFiles + cleanupPartial.recoveryHandles + ) + let recoverySurvivors = currentAuthorizedSurvivors( + cleanupCandidates + (originalPartial?.recoveryHandles ?? []) + + cleanupPartial.recoveryHandles + ) + throw AutomationFilePartialMutation( + kind: originalPartial?.kind ?? .remove, + commitState: .unknown, + observedFiles: observedSurvivors, + recoveryHandle: recoverySurvivors.first, + recoveryHandles: recoverySurvivors, + resultURL: originalPartial?.resultURL ?? manifestURL + ) + } + if let originalPartial { + let observedSurvivors = currentAuthorizedSurvivors( + cleanupCandidates + originalPartial.observedFiles + ) + let recoverySurvivors = currentAuthorizedSurvivors( + cleanupCandidates + originalPartial.recoveryHandles + ) + throw AutomationFilePartialMutation( + kind: originalPartial.kind, + commitState: originalPartial.commitState, + observedFiles: observedSurvivors, + recoveryHandle: recoverySurvivors.first, + recoveryHandles: recoverySurvivors, + resultURL: originalPartial.resultURL + ) + } + throw error + } + guard let installedManifestAuthorization, let installedManifestData else { + throw AutomationManagerConfigurationError.recoverableSourceUnavailable + } + backupsByID[id] = backup + manifestURLsByID[id] = manifestURL + backupAuthorizationsByID[id] = backupAuthorization + manifestAuthorizationsByID[id] = installedManifestAuthorization + manifestDataByID[id] = installedManifestData + try pruneRecoveryEvidence(preserving: id) + return backup + } + + private static func manifestData( + for backup: AutomationBackup, + ownerUID: uid_t + ) throws -> Data { + try PropertyListSerialization.data( + fromPropertyList: [ + "version": 2, + "id": backup.id.rawValue.uuidString.lowercased(), + "recordID": backup.recordID.rawValue, + "sourcePath": backup.sourceURL.path, + "backupPath": backup.backupURL.path, + "checksum": backup.checksum, + "createdAt": backup.createdAt.timeIntervalSince1970, + "sourceExisted": backup.sourceExisted, + "ownerUID": Int(ownerUID), + "kind": backup.kind.rawValue, + "sourceKind": backup.sourceKind.rawValue, + "authorizedRecordIDs": backup.authorizedRecordIDs.map(\.rawValue).sorted(), + "parentResourceIdentifier": backup.parentResourceIdentifier ?? "", + ], + format: .binary, + options: 0 + ) + } + + private static func loadManifests( + fileSystem: any AutomationFileSystem, + backupDirectory: URL, + currentUID: uid_t + ) -> ( + backups: [AutomationBackup.ID: AutomationBackup], + manifestURLs: [AutomationBackup.ID: URL], + backupAuthorizations: [AutomationBackup.ID: AutomationFileAuthorization], + manifestAuthorizations: [AutomationBackup.ID: AutomationFileAuthorization], + manifestData: [AutomationBackup.ID: Data] + ) { + let recoveryRoot = backupDirectory.standardizedFileURL + guard let rootMetadata = try? fileSystem.metadata(for: recoveryRoot), + rootMetadata.canonicalURL.standardizedFileURL.path == recoveryRoot.path, + rootMetadata.ownerUID == currentUID, + !rootMetadata.isSymbolicLink, + let rootResourceIdentifier = rootMetadata.resourceIdentifier, + !rootResourceIdentifier.isEmpty, + let rootAuthorization = AutomationDirectoryAuthorization( + directoryURL: recoveryRoot, + resourceIdentifier: rootResourceIdentifier + ), + rootMetadata.permissions == 0o700, + let urls = try? fileSystem.plistURLs(in: recoveryRoot) + else { return ([:], [:], [:], [:], [:]) } + var backups: [AutomationBackup.ID: AutomationBackup] = [:] + var manifestURLs: [AutomationBackup.ID: URL] = [:] + var backupAuthorizations: [AutomationBackup.ID: AutomationFileAuthorization] = [:] + var manifestAuthorizations: [AutomationBackup.ID: AutomationFileAuthorization] = [:] + var manifestData: [AutomationBackup.ID: Data] = [:] + for manifestURL in urls where manifestURL.lastPathComponent.hasSuffix(".manifest.plist") { + guard let data = try? fileSystem.read(manifestURL), + let value = try? PropertyListSerialization.propertyList( + from: data, + options: [], + format: nil + ), + let dictionary = value as? [String: Any], + Set(dictionary.keys) == Set([ + "version", "id", "recordID", "sourcePath", "backupPath", "checksum", + "createdAt", "sourceExisted", "ownerUID", "kind", "sourceKind", + "authorizedRecordIDs", "parentResourceIdentifier", + ]), + dictionary["version"] as? Int == 2, + let rawID = dictionary["id"] as? String, + let uuid = UUID(uuidString: rawID), + manifestURL.standardizedFileURL == recoveryRoot.appendingPathComponent( + "\(uuid.uuidString.lowercased()).manifest.plist" + ).standardizedFileURL, + let recordID = dictionary["recordID"] as? String, + let sourcePath = dictionary["sourcePath"] as? String, + let backupPath = dictionary["backupPath"] as? String, + let checksum = dictionary["checksum"] as? String, + let createdAt = dictionary["createdAt"] as? TimeInterval, + let sourceExisted = dictionary["sourceExisted"] as? Bool, + let ownerNumber = dictionary["ownerUID"] as? Int, + let kindRaw = dictionary["kind"] as? String, + let kind = AutomationKind(rawValue: kindRaw), + let sourceKindRaw = dictionary["sourceKind"] as? String, + let sourceKind = AutomationSourceKind(rawValue: sourceKindRaw), + let authorizedRawIDs = dictionary["authorizedRecordIDs"] as? [String], + let parentResourceIdentifier = dictionary["parentResourceIdentifier"] as? String, + ownerNumber == Int(currentUID), + let derivedBackupURL = Optional(recoveryRoot.appendingPathComponent( + "\(uuid.uuidString.lowercased()).backup" + ).standardizedFileURL), + URL(fileURLWithPath: backupPath).standardizedFileURL == derivedBackupURL, + fileSystem.itemExists(at: derivedBackupURL), + let manifestMetadata = try? fileSystem.metadata(for: manifestURL), + let backupMetadata = try? fileSystem.metadata(for: derivedBackupURL), + manifestMetadata.canonicalURL.standardizedFileURL == manifestURL.standardizedFileURL, + backupMetadata.canonicalURL.standardizedFileURL == derivedBackupURL, + !manifestMetadata.isSymbolicLink, + !backupMetadata.isSymbolicLink, + manifestMetadata.ownerUID == currentUID, + backupMetadata.ownerUID == currentUID, + let manifestIdentity = manifestMetadata.resourceIdentifier, + !manifestIdentity.isEmpty, + let backupIdentity = backupMetadata.resourceIdentifier, + !backupIdentity.isEmpty, + manifestMetadata.permissions == 0o600, + backupMetadata.permissions == 0o600, + let backupData = try? fileSystem.read(derivedBackupURL), + checksum == Self.checksum(backupData), + let manifestAuthorization = AutomationFileAuthorization( + fileURL: manifestURL, + directory: rootAuthorization, + expectation: .existing(resourceIdentifier: manifestIdentity) + ), + let backupAuthorization = AutomationFileAuthorization( + fileURL: derivedBackupURL, + directory: rootAuthorization, + expectation: .existing(resourceIdentifier: backupIdentity) + ) + else { continue } + let id = AutomationBackup.ID(rawValue: uuid) + let backup = AutomationBackup( + id: id, + recordID: AutomationRecord.ID(rawValue: recordID), + sourceURL: URL(fileURLWithPath: sourcePath), + backupURL: derivedBackupURL, + checksum: checksum, + createdAt: Date(timeIntervalSince1970: createdAt), + sourceExisted: sourceExisted, + ownerUID: uid_t(ownerNumber), + kind: kind, + sourceKind: sourceKind, + authorizedRecordIDs: Set(authorizedRawIDs.map(AutomationRecord.ID.init(rawValue:))), + parentResourceIdentifier: parentResourceIdentifier.isEmpty ? nil : parentResourceIdentifier + ) + backups[id] = backup + manifestURLs[id] = manifestURL + manifestAuthorizations[id] = manifestAuthorization + backupAuthorizations[id] = backupAuthorization + manifestData[id] = data + } + return (backups, manifestURLs, backupAuthorizations, manifestAuthorizations, manifestData) + } + + private func authorizeRecoverySuccessor( + _ recordID: AutomationRecord.ID, + for backupID: AutomationBackup.ID + ) throws { + guard let backup = backupsByID[backupID], + let manifestURL = manifestURLsByID[backupID], + let manifestAuthorization = manifestAuthorizationsByID[backupID], + let backupAuthorization = backupAuthorizationsByID[backupID], + let previousManifestData = manifestDataByID[backupID] + else { throw AutomationManagerConfigurationError.recoverableSourceUnavailable } + let updated = AutomationBackup( + id: backup.id, + recordID: backup.recordID, + sourceURL: backup.sourceURL, + backupURL: backup.backupURL, + checksum: backup.checksum, + createdAt: backup.createdAt, + sourceExisted: backup.sourceExisted, + ownerUID: backup.ownerUID, + kind: backup.kind, + sourceKind: backup.sourceKind, + authorizedRecordIDs: backup.authorizedRecordIDs.union([recordID]), + parentResourceIdentifier: backup.parentResourceIdentifier + ) + let manifestData = try Self.manifestData(for: updated, ownerUID: backup.ownerUID) + let rootAuthorization = manifestAuthorization.directory + let artifactsRemainExact = rootAuthorization == backupAuthorization.directory + && recoveryRootIsAuthorized() + && authorizationIsCurrent(manifestAuthorization) + && authorizationIsCurrent(backupAuthorization) + && recoveryArtifactIsVerified( + at: manifestURL, + expectedData: previousManifestData, + expectedChecksum: Self.checksum(previousManifestData), + requiresPropertyList: true + ) + && ((try? fileSystem.read(backup.backupURL)).map { + recoveryArtifactIsVerified( + at: backup.backupURL, + expectedData: $0, + expectedChecksum: backup.checksum, + requiresPropertyList: false + ) + } ?? false) + guard artifactsRemainExact else { + let observed = [manifestURL, backup.backupURL].compactMap { + try? currentRecoveryAuthorization(at: $0, authorization: rootAuthorization) + } + reconcileRecoveryMapsFromRetainedAuthority() + throw AutomationFilePartialMutation( + kind: .replace, + commitState: .unknown, + observedFiles: observed, + recoveryHandle: nil, + resultURL: manifestURL + ) + } + let staged = try fileSystem.writeStagedFile( + nextTo: manifestURL, + data: manifestData, + permissions: 0o600, + authorization: rootAuthorization + ) + guard recoveryRootIsAuthorized() else { + try removeCleanupArtifact(staged.authorization) + throw AutomationManagerConfigurationError.recoverableSourceUnavailable + } + let installedAuthorization = try Self.fileAuthorization( + for: manifestURL, + directory: rootAuthorization, + resourceIdentifier: staged.binding.resourceIdentifier, + existed: true + ) + let replacementReceipt: AutomationFileMutationReceipt + do { + replacementReceipt = try committedReplacement(at: manifestAuthorization, with: staged) + } catch let partial as AutomationFilePartialMutation { + let restored: AutomationFileAuthorization + do { + guard let compensationDestination = currentAuthorizedSurvivors( + [manifestAuthorization, installedAuthorization] + ).first else { + throw AutomationManagerConfigurationError.recoverableSourceUnavailable + } + restored = try compensateRecoveryArtifact( + previousManifestData, + at: manifestURL, + authorization: rootAuthorization, + destination: compensationDestination + ) + retainRestoredManifest( + for: backupID, + authorization: restored, + data: previousManifestData + ) + try cleanupRecoveryArtifacts(partial.recoveryHandles.filter { + $0.fileURL.standardizedFileURL == staged.url.standardizedFileURL + && $0.directory == rootAuthorization + && $0.expectation == staged.authorization.expectation + }) + } catch let compensation as AutomationFilePartialMutation { + throw aggregatePartialMutations( + partial, + compensation, + at: manifestURL, + authorization: rootAuthorization + ) + } catch { + throw aggregatePartialMutations( + partial, + nil, + at: manifestURL, + authorization: rootAuthorization + ) + } + let observedSurvivors = currentAuthorizedSurvivors( + [restored] + partial.observedFiles + ) + let recoverySurvivors = currentAuthorizedSurvivors( + [restored] + partial.recoveryHandles + ) + throw AutomationFilePartialMutation( + kind: .replace, + commitState: partial.commitState, + observedFiles: observedSurvivors, + recoveryHandle: recoverySurvivors.first, + recoveryHandles: recoverySurvivors, + resultURL: manifestURL + ) + } + guard let exactInstalled = replacementReceipt.primaryFile, + exactInstalled == installedAuthorization, + authorizationIsCurrent(exactInstalled), + recoveryRootIsAuthorized(), + authorizationIsCurrent(backupAuthorization), + let finalBackupData = try? fileSystem.read(backup.backupURL), + recoveryArtifactIsVerified( + at: backup.backupURL, + expectedData: finalBackupData, + expectedChecksum: backup.checksum, + requiresPropertyList: false + ), + authorizationIsCurrent(backupAuthorization), + recoveryArtifactIsVerified( + at: manifestURL, + expectedData: manifestData, + expectedChecksum: Self.checksum(manifestData), + requiresPropertyList: true + ), + authorizationIsCurrent(exactInstalled) + else { + let knownInstalled = currentAuthorizedSurvivors([installedAuthorization]) + let committed = AutomationFilePartialMutation( + kind: .replace, + commitState: .committed, + observedFiles: knownInstalled, + recoveryHandle: knownInstalled.first, + resultURL: manifestURL + ) + let restored: AutomationFileAuthorization + do { + guard authorizationIsCurrent(installedAuthorization) else { + throw AutomationManagerConfigurationError.recoverableSourceUnavailable + } + restored = try compensateRecoveryArtifact( + previousManifestData, + at: manifestURL, + authorization: rootAuthorization, + destination: installedAuthorization + ) + retainRestoredManifest( + for: backupID, + authorization: restored, + data: previousManifestData + ) + } catch let compensation as AutomationFilePartialMutation { + let aggregate = aggregatePartialMutations( + committed, + compensation, + at: manifestURL, + authorization: rootAuthorization + ) + reconcileRecoveryMapsFromRetainedAuthority() + throw aggregate + } catch { + let aggregate = aggregatePartialMutations( + committed, + nil, + at: manifestURL, + authorization: rootAuthorization + ) + reconcileRecoveryMapsFromRetainedAuthority() + throw aggregate + } + let ambientBackup = try? currentRecoveryAuthorization( + at: backup.backupURL, + authorization: rootAuthorization + ) + reconcileRecoveryMapsFromRetainedAuthority() + throw AutomationFilePartialMutation( + kind: .replace, + commitState: .committed, + observedFiles: [restored] + [ambientBackup].compactMap { $0 }, + recoveryHandle: restored, + resultURL: manifestURL + ) + } + backupsByID[backupID] = updated + manifestAuthorizationsByID[backupID] = exactInstalled + manifestDataByID[backupID] = manifestData + } + + private func retainRestoredManifest( + for backupID: AutomationBackup.ID, + authorization: AutomationFileAuthorization, + data: Data + ) { + manifestAuthorizationsByID[backupID] = authorization + manifestDataByID[backupID] = data + } + + private func compensateRecoveryArtifact( + _ data: Data, + at url: URL, + authorization rootAuthorization: AutomationDirectoryAuthorization, + destination: AutomationFileAuthorization + ) throws -> AutomationFileAuthorization { + let staged = try fileSystem.writeStagedFile( + nextTo: url, + data: data, + permissions: 0o600, + authorization: rootAuthorization + ) + let receipt = try committedReplacement(at: destination, with: staged) + guard let installed = receipt.primaryFile, + installed.expectation == staged.authorization.expectation, + authorizationIsCurrent(installed), + recoveryArtifactIsVerified( + at: url, + expectedData: data, + expectedChecksum: Self.checksum(data), + requiresPropertyList: true + ) else { throw AutomationManagerConfigurationError.recoverableSourceUnavailable } + return installed + } + + private func currentRecoveryAuthorization( + at url: URL, + authorization rootAuthorization: AutomationDirectoryAuthorization + ) throws -> AutomationFileAuthorization { + let metadata = try fileSystem.metadata(for: url) + return try Self.fileAuthorization( + for: url, + directory: rootAuthorization, + resourceIdentifier: metadata.resourceIdentifier, + existed: true + ) + } + + private func aggregatePartialMutations( + _ primary: AutomationFilePartialMutation, + _ secondary: AutomationFilePartialMutation?, + at url: URL, + authorization rootAuthorization: AutomationDirectoryAuthorization + ) -> AutomationFilePartialMutation { + var observed: [AutomationFileAuthorization] = [] + for candidate in primary.observedFiles + (secondary?.observedFiles ?? []) + where authorizationIsCurrent(candidate) && !observed.contains(candidate) { + observed.append(candidate) + } + var handles: [AutomationFileAuthorization] = [] + for candidate in primary.recoveryHandles + (secondary?.recoveryHandles ?? []) + where authorizationIsCurrent(candidate) && !handles.contains(candidate) { + handles.append(candidate) + } + if let current = try? currentRecoveryAuthorization( + at: url, + authorization: rootAuthorization + ) { + if !observed.contains(current) { observed.append(current) } + } + return AutomationFilePartialMutation( + kind: .replace, + commitState: .unknown, + observedFiles: observed, + recoveryHandle: handles.first, + recoveryHandles: handles, + resultURL: url + ) + } + + private func recoveryArtifactIsVerified( + at url: URL, + expectedData: Data, + expectedChecksum: String, + requiresPropertyList: Bool + ) -> Bool { + let artifact = url.standardizedFileURL + let root = backupDirectory.standardizedFileURL + guard recoveryRootIsAuthorized(), + artifact.deletingLastPathComponent().standardizedFileURL.path == root.path, + let metadata = try? fileSystem.metadata(for: artifact), + metadata.canonicalURL.standardizedFileURL.path == artifact.path, + metadata.ownerUID == currentUID, + !metadata.isSymbolicLink, + metadata.resourceIdentifier?.isEmpty == false, + metadata.permissions == 0o600, + let actualData = try? fileSystem.read(artifact), + actualData == expectedData, + Self.checksum(actualData) == expectedChecksum + else { return false } + if requiresPropertyList { + return (try? PropertyListSerialization.propertyList( + from: actualData, + options: [], + format: nil + )) != nil + } + return true + } + + private func pruneRecoveryEvidence(preserving currentID: AutomationBackup.ID?) throws { + guard recoveryRootIsAuthorized() else { return } + let rootMetadata = try fileSystem.metadata(for: backupDirectory) + let rootAuthorization = try Self.directoryAuthorization( + for: backupDirectory, + resourceIdentifier: rootMetadata.resourceIdentifier + ) + let cutoff = now().addingTimeInterval(-30 * 24 * 60 * 60) + let ordered = backupsByID.values.sorted { lhs, rhs in + if lhs.createdAt != rhs.createdAt { return lhs.createdAt > rhs.createdAt } + return lhs.id.rawValue.uuidString > rhs.id.rawValue.uuidString + } + var retained = Set(ordered.prefix(20).map(\.id)) + if let currentID { retained.insert(currentID) } + for backup in ordered where backup.createdAt < cutoff || !retained.contains(backup.id) { + guard backup.id != currentID else { continue } + guard let manifestURL = manifestURLsByID[backup.id], + let manifestAuthorization = manifestAuthorizationsByID[backup.id], + let backupAuthorization = backupAuthorizationsByID[backup.id], + let manifestData = manifestDataByID[backup.id] + else { + reconcileRecoveryMapsFromRetainedAuthority() + continue + } + let backupData = try? fileSystem.read(backup.backupURL) + guard manifestAuthorization.directory == rootAuthorization, + backupAuthorization.directory == rootAuthorization, + authorizationIsCurrent(manifestAuthorization), + authorizationIsCurrent(backupAuthorization), + recoveryArtifactIsVerified( + at: manifestURL, + expectedData: manifestData, + expectedChecksum: Self.checksum(manifestData), + requiresPropertyList: true + ), + let backupData, + recoveryArtifactIsVerified( + at: backup.backupURL, + expectedData: backupData, + expectedChecksum: backup.checksum, + requiresPropertyList: false + ) + else { + // A fresh occupant is evidence only. Retained-authority reconciliation drops + // the invalid pair; no destructive authority comes from the ambient namespace. + reconcileRecoveryMapsFromRetainedAuthority() + continue + } + var manifestDeletionCommitted = false + do { + try removeCleanupArtifact(manifestAuthorization) + manifestDeletionCommitted = true + try removeCleanupArtifact(backupAuthorization) + } catch { + let primaryPartial = error as? AutomationFilePartialMutation + if !manifestDeletionCommitted { + reconcileRecoveryMapsFromRetainedAuthority() + if let primaryPartial { throw primaryPartial } + throw error + } + var compensationPartial: AutomationFilePartialMutation? + var restoredManifest: AutomationFileAuthorization? + if !fileSystem.itemExists(at: manifestURL), + authorizationIsCurrent(backupAuthorization) { + do { + let absentManifest = try Self.fileAuthorization( + for: manifestURL, + directory: rootAuthorization, + resourceIdentifier: nil, + existed: false + ) + restoredManifest = try compensateRecoveryArtifact( + manifestData, + at: manifestURL, + authorization: rootAuthorization, + destination: absentManifest + ) + if let restoredManifest { + retainRestoredManifest( + for: backup.id, + authorization: restoredManifest, + data: manifestData + ) + } + if let primaryPartial { + try cleanupRecoveryArtifacts(primaryPartial.recoveryHandles.filter { + $0.fileURL.standardizedFileURL != manifestURL.standardizedFileURL + && $0.expectation == backupAuthorization.expectation + && $0.directory == rootAuthorization + }) + } + } catch let partial as AutomationFilePartialMutation { + compensationPartial = partial + } catch { + compensationPartial = AutomationFilePartialMutation( + kind: .replace, + commitState: .unknown, + observedFiles: [], + recoveryHandle: nil, + resultURL: manifestURL + ) + } + } + let currentArtifacts = [ + try? currentRecoveryAuthorization( + at: manifestURL, + authorization: rootAuthorization + ), + try? currentRecoveryAuthorization( + at: backup.backupURL, + authorization: rootAuthorization + ), + ].compactMap { $0 } + let knownRecoveryCandidates = [ + restoredManifest, + authorizationIsCurrent(manifestAuthorization) ? manifestAuthorization : nil, + authorizationIsCurrent(backupAuthorization) ? backupAuthorization : nil, + ].compactMap { $0 } + let observedSurvivors = currentAuthorizedSurvivors( + currentArtifacts + + knownRecoveryCandidates + + (primaryPartial?.observedFiles ?? []) + + (primaryPartial?.recoveryHandles ?? []) + + (compensationPartial?.observedFiles ?? []) + + (compensationPartial?.recoveryHandles ?? []) + ) + let recoverySurvivors = currentAuthorizedSurvivors( + knownRecoveryCandidates + + (primaryPartial?.recoveryHandles ?? []) + + (compensationPartial?.recoveryHandles ?? []) + ) + reconcileRecoveryMapsFromRetainedAuthority() + throw AutomationFilePartialMutation( + kind: .remove, + commitState: .unknown, + observedFiles: observedSurvivors, + recoveryHandle: recoverySurvivors.first, + recoveryHandles: recoverySurvivors, + resultURL: manifestURL + ) + } + if !fileSystem.itemExists(at: backup.backupURL), + !fileSystem.itemExists(at: manifestURL) + { + backupsByID.removeValue(forKey: backup.id) + manifestURLsByID.removeValue(forKey: backup.id) + backupAuthorizationsByID.removeValue(forKey: backup.id) + manifestAuthorizationsByID.removeValue(forKey: backup.id) + manifestDataByID.removeValue(forKey: backup.id) + } else { + let observedSurvivors = currentAuthorizedSurvivors([ + try? currentRecoveryAuthorization(at: manifestURL, authorization: rootAuthorization), + try? currentRecoveryAuthorization(at: backup.backupURL, authorization: rootAuthorization), + ].compactMap { $0 }) + let recoverySurvivors = currentAuthorizedSurvivors([ + manifestAuthorization, + backupAuthorization, + ]) + reconcileRecoveryMapsFromRetainedAuthority() + throw AutomationFilePartialMutation( + kind: .remove, + commitState: .unknown, + observedFiles: observedSurvivors, + recoveryHandle: recoverySurvivors.first, + recoveryHandles: recoverySurvivors, + resultURL: manifestURL + ) + } + } + } + + private func reconcileRecoveryMapsFromRetainedAuthority() { + guard recoveryRootIsAuthorized() else { + backupsByID.removeAll() + manifestURLsByID.removeAll() + backupAuthorizationsByID.removeAll() + manifestAuthorizationsByID.removeAll() + manifestDataByID.removeAll() + return + } + var retainedBackups: [AutomationBackup.ID: AutomationBackup] = [:] + var retainedManifestURLs: [AutomationBackup.ID: URL] = [:] + var retainedBackupAuthorizations: [ + AutomationBackup.ID: AutomationFileAuthorization + ] = [:] + var retainedManifestAuthorizations: [ + AutomationBackup.ID: AutomationFileAuthorization + ] = [:] + var retainedManifestData: [AutomationBackup.ID: Data] = [:] + for (id, backup) in backupsByID { + guard let manifestURL = manifestURLsByID[id], + let backupAuthorization = backupAuthorizationsByID[id], + let manifestAuthorization = manifestAuthorizationsByID[id], + let manifestData = manifestDataByID[id], + authorizationIsCurrent(backupAuthorization), + authorizationIsCurrent(manifestAuthorization), + let backupData = try? fileSystem.read(backup.backupURL), + recoveryArtifactIsVerified( + at: backup.backupURL, + expectedData: backupData, + expectedChecksum: backup.checksum, + requiresPropertyList: false + ), + recoveryArtifactIsVerified( + at: manifestURL, + expectedData: manifestData, + expectedChecksum: Self.checksum(manifestData), + requiresPropertyList: true + ) + else { continue } + retainedBackups[id] = backup + retainedManifestURLs[id] = manifestURL + retainedBackupAuthorizations[id] = backupAuthorization + retainedManifestAuthorizations[id] = manifestAuthorization + retainedManifestData[id] = manifestData + } + backupsByID = retainedBackups + manifestURLsByID = retainedManifestURLs + backupAuthorizationsByID = retainedBackupAuthorizations + manifestAuthorizationsByID = retainedManifestAuthorizations + manifestDataByID = retainedManifestData + } + + private func recoveryRootIsAuthorized() -> Bool { + guard let expectedIdentity = recoveryRootResourceIdentifier, + let metadata = try? fileSystem.metadata(for: backupDirectory), + metadata.canonicalURL.standardizedFileURL.path == backupDirectory.standardizedFileURL.path, + metadata.ownerUID == currentUID, + !metadata.isSymbolicLink, + metadata.resourceIdentifier == expectedIdentity, + metadata.permissions == 0o700 + else { return false } + return true + } + + private func removeCleanupArtifact(_ authorization: AutomationFileAuthorization) throws { + let outcome: AutomationFileMutationOutcome + do { + outcome = try fileSystem.removeItem(authorization) + } catch let partial as AutomationFilePartialMutation { + throw partial + } catch { + throw error + } + switch outcome { + case .committed: + return + case .unchanged: + throw AutomationManagerConfigurationError.recoverableSourceUnavailable + } + } + + private func cleanupRecoveryArtifacts( + _ authorizations: [AutomationFileAuthorization] + ) throws { + var committedDeletion = false + var partials: [AutomationFilePartialMutation] = [] + var firstRawError: Error? + for authorization in authorizations where fileSystem.itemExists(at: authorization.fileURL) { + do { + switch try fileSystem.removeItem(authorization) { + case .committed: + committedDeletion = true + case .unchanged: + firstRawError = firstRawError + ?? AutomationManagerConfigurationError.recoverableSourceUnavailable + } + } catch let partial as AutomationFilePartialMutation { + partials.append(partial) + } catch { + firstRawError = firstRawError ?? error + } + } + if !partials.isEmpty || committedDeletion && firstRawError != nil { + var observed = partials.flatMap(\.observedFiles) + var handles = partials.flatMap(\.recoveryHandles) + for authorization in authorizations { + guard authorizationIsCurrent(authorization) else { continue } + if !observed.contains(authorization) { observed.append(authorization) } + if !handles.contains(authorization) { handles.append(authorization) } + } + throw AutomationFilePartialMutation( + kind: .remove, + commitState: .unknown, + observedFiles: observed, + recoveryHandle: handles.first, + recoveryHandles: handles, + resultURL: nil + ) + } + if let firstRawError { throw firstRawError } + } + + private func currentAuthorizedSurvivors( + _ candidates: [AutomationFileAuthorization] + ) -> [AutomationFileAuthorization] { + var survivors: [AutomationFileAuthorization] = [] + for candidate in candidates { + guard authorizationIsCurrent(candidate), !survivors.contains(candidate) + else { continue } + survivors.append(candidate) + } + return survivors + } + + private func authorizationIsCurrent(_ authorization: AutomationFileAuthorization) -> Bool { + guard case .existing(let expectedIdentity) = authorization.expectation, + let directoryMetadata = try? fileSystem.metadata( + for: authorization.directory.directoryURL + ), + directoryMetadata.resourceIdentifier == authorization.directory.resourceIdentifier, + let metadata = try? fileSystem.metadata(for: authorization.fileURL), + metadata.resourceIdentifier == expectedIdentity + else { return false } + return true + } + + private func revalidationFailure( + operation: AutomationOperation, + record: AutomationRecord, + sourceURL: URL, + expectedChecksum: String, + initialMetadata: AutomationFileMetadata + ) async throws -> String? { + do { + let sourceData: Data + let metadata: AutomationFileMetadata + if record.sourceKind.requiresRecoverableSource { + let captured = try await recoverableSource(record) + guard captured.transactionURL.standardizedFileURL.path == sourceURL.standardizedFileURL.path, + captured.checksum == Self.sourceChecksum(captured.data, kind: record.kind), + try fileSystem.read(sourceURL) == captured.data, + try fileSystem.metadata(for: sourceURL) == captured.metadata + else { + return "The recoverable source identity changed immediately before mutation." + } + sourceData = captured.data + metadata = captured.metadata + } else { + sourceData = try fileSystem.read(sourceURL) + metadata = try fileSystem.metadata(for: sourceURL) + } + guard Self.sourceChecksum(sourceData, kind: record.kind) == expectedChecksum else { + return "The automation source changed immediately before mutation." + } + let context = try capabilityContext(record) + guard let resourceIdentifier = metadata.resourceIdentifier, + !resourceIdentifier.isEmpty, + metadata.canonicalURL.standardizedFileURL.path == sourceURL.standardizedFileURL.path, + metadata.ownerUID == context.sourceOwnerUID, + metadata.isSymbolicLink == context.isSymlink, + resourceIdentifier == initialMetadata.resourceIdentifier + else { + return "The automation source identity changed immediately before mutation." + } + let decision = AutomationCapabilityPolicy.decision(for: record, context: context) + guard decision.capabilities.contains(operation.requiredCapability) else { + return decision.reason ?? "This operation is no longer available for the source." + } + return nil + } catch let partial as AutomationFilePartialMutation { + throw partial + } catch { + return "DevScope could not revalidate the automation source immediately before mutation." + } + } + + private func verifyDestination( + _ proposal: SourceProposal, + record: AutomationRecord, + selectedSourceURL: URL, + selectedSourceData: Data, + selectedMetadata: AutomationFileMetadata + ) throws -> DestinationSnapshot { + let destination = proposal.destination.standardizedFileURL + let parent = destination.deletingLastPathComponent().standardizedFileURL + let parentMetadata = try fileSystem.metadata(for: parent) + let context = try destinationContext(record, destination) + guard context.canonicalPathIsApproved else { + throw ProposalError("The intended destination resolves outside the approved automation folder.") + } + guard !context.isManaged else { + throw ProposalError("The intended destination is managed by your organization.") + } + guard !context.isSymlink else { + throw ProposalError("The intended destination is a symbolic link.") + } + guard context.sourceOwnerUID == context.currentUID else { + throw ProposalError("The intended destination is not owned by the current user.") + } + guard parentMetadata.canonicalURL.standardizedFileURL.path == parent.path, + parentMetadata.ownerUID == context.currentUID, + !parentMetadata.isSymbolicLink, + parentMetadata.resourceIdentifier?.isEmpty == false + else { + throw ProposalError("The intended destination parent identity could not be established.") + } + + if destination == selectedSourceURL.standardizedFileURL, + fileSystem.itemExists(at: destination) { + guard selectedMetadata.ownerUID == context.currentUID, + selectedMetadata.resourceIdentifier != nil + else { + throw ProposalError("The intended destination identity could not be established.") + } + return DestinationSnapshot( + existed: true, + data: selectedSourceData, + metadata: selectedMetadata, + parentMetadata: parentMetadata + ) + } + + if fileSystem.itemExists(at: destination) { + let data = try fileSystem.read(destination) + let metadata = try fileSystem.metadata(for: destination) + guard metadata.canonicalURL.standardizedFileURL.path == destination.path, + metadata.ownerUID == context.currentUID, + !metadata.isSymbolicLink, + let resourceIdentifier = metadata.resourceIdentifier, + !resourceIdentifier.isEmpty + else { + throw ProposalError("The intended destination identity could not be established.") + } + guard let expected = proposal.expectedDestinationChecksum, + expected == Self.checksum(data) + else { + throw ProposalError("The intended destination changed since it was inspected.") + } + return DestinationSnapshot( + existed: true, + data: data, + metadata: metadata, + parentMetadata: parentMetadata + ) + } + + return DestinationSnapshot( + existed: false, + data: nil, + metadata: parentMetadata, + parentMetadata: parentMetadata + ) + } + + private func destinationRevalidationFailure( + _ proposal: SourceProposal, + snapshot: DestinationSnapshot, + record: AutomationRecord + ) -> String? { + do { + let destination = proposal.destination.standardizedFileURL + let context = try destinationContext(record, destination) + guard context.canonicalPathIsApproved, + context.sourceOwnerUID == context.currentUID, + !context.isSymlink, + !context.isManaged + else { + return "The intended destination authorization changed immediately before mutation." + } + let parent = destination.deletingLastPathComponent().standardizedFileURL + let currentParentMetadata = try fileSystem.metadata(for: parent) + guard currentParentMetadata.canonicalURL.standardizedFileURL.path == parent.path, + currentParentMetadata.ownerUID == snapshot.parentMetadata.ownerUID, + !currentParentMetadata.isSymbolicLink, + currentParentMetadata.resourceIdentifier == snapshot.parentMetadata.resourceIdentifier + else { + return "The intended destination parent changed immediately before mutation." + } + if snapshot.existed { + guard fileSystem.itemExists(at: destination), + let previousData = snapshot.data + else { + return "The intended destination changed immediately before mutation." + } + let data = try fileSystem.read(destination) + let metadata = try fileSystem.metadata(for: destination) + guard data == previousData, + metadata.canonicalURL.standardizedFileURL.path == destination.path, + metadata.ownerUID == snapshot.metadata.ownerUID, + metadata.isSymbolicLink == snapshot.metadata.isSymbolicLink, + metadata.resourceIdentifier != nil, + metadata.resourceIdentifier == snapshot.metadata.resourceIdentifier + else { + return "The intended destination identity changed immediately before mutation." + } + } else { + guard !fileSystem.itemExists(at: destination) else { + return "The intended destination appeared immediately before mutation." + } + let metadata = currentParentMetadata + guard metadata.canonicalURL.standardizedFileURL.path == parent.path, + metadata.ownerUID == snapshot.metadata.ownerUID, + !metadata.isSymbolicLink, + metadata.resourceIdentifier != nil, + metadata.resourceIdentifier == snapshot.metadata.resourceIdentifier + else { + return "The intended destination parent changed immediately before mutation." + } + } + return nil + } catch { + return "DevScope could not revalidate the intended destination immediately before mutation." + } + } + + private func makeProposal( + for operation: AutomationOperation, + record: AutomationRecord, + sourceURL: URL, + selectedSourceData: Data + ) throws -> SourceProposal? { + switch operation { + case .edit(let payload): + try Self.validate(payload) + let data = try Self.sourceData(for: payload, kind: record.kind) + try Self.validate( + data: data, + against: payload, + kind: record.kind, + sourceURL: sourceURL, + ownerUID: record.ownerUID ?? 0 + ) + let cronBinding = record.kind == .cron + ? try Self.cronProposalBinding( + originalData: selectedSourceData, + intendedData: data, + record: record, + payload: payload + ) + : nil + return SourceProposal( + data: data, + destination: sourceURL, + intendedLabel: payload.label, + intendedRecordID: cronBinding?.intendedRecordID, + cronBinding: cronBinding, + intendedChecksum: Self.sourceChecksum(data, kind: record.kind), + expectedDestinationChecksum: payload.expectedDestinationChecksum + ) + case .duplicate(let payload): + try Self.validate(payload) + guard let destination = payload.destination else { + throw ProposalError("A distinct duplicate destination is required.") + } + let data = try Self.sourceData(for: payload, kind: record.kind) + try Self.validate( + data: data, + against: payload, + kind: record.kind, + sourceURL: destination, + ownerUID: record.ownerUID ?? 0 + ) + let cronBinding = record.kind == .cron + ? try Self.cronDuplicateBinding( + originalData: selectedSourceData, + intendedData: data, + record: record, + payload: payload + ) + : nil + return SourceProposal( + data: data, + destination: destination, + intendedLabel: payload.label, + intendedRecordID: cronBinding?.intendedRecordID, + cronBinding: cronBinding, + intendedChecksum: Self.sourceChecksum(data, kind: record.kind), + expectedDestinationChecksum: payload.expectedDestinationChecksum + ) + case .importRecord(let payload): + guard payload.expectedKind == record.kind else { + throw ProposalError("The imported source kind does not match the selected automation.") + } + let sourceParent = sourceURL.deletingLastPathComponent().standardizedFileURL + let destinationParent = payload.destination.deletingLastPathComponent().standardizedFileURL + guard sourceParent == destinationParent else { + throw ProposalError("The import destination is outside the approved automation folder.") + } + if record.kind == .cron { + try Self.validateCronDocument(payload.data, against: record) + } + return SourceProposal( + data: payload.data, + destination: payload.destination, + intendedLabel: Self.parsedLabel( + data: payload.data, + kind: payload.expectedKind, + sourceURL: payload.destination, + ownerUID: record.ownerUID ?? 0 + ), + intendedRecordID: record.kind == .cron ? record.id : nil, + cronBinding: record.kind == .cron + ? try Self.cronImportBinding(data: payload.data, record: record) + : nil, + intendedChecksum: Self.sourceChecksum(payload.data, kind: record.kind), + expectedDestinationChecksum: payload.expectedDestinationChecksum + ) + case .restore(let id): + guard let backup = backupsByID[id], backup.authorizedRecordIDs.contains(record.id) else { + throw ProposalError("The selected recovery backup does not belong to this automation.") + } + let data = try fileSystem.read(backup.backupURL) + guard Self.checksum(data) == backup.checksum else { + throw ProposalError("The recovery backup failed its integrity check.") + } + let cronBinding = backup.kind == .cron && backup.sourceExisted + ? try Self.cronRestoreBinding(data: data, backup: backup, selectedRecord: record) + : nil + return SourceProposal( + data: data, + destination: sourceURL, + intendedLabel: Self.parsedLabel( + data: data, + kind: backup.kind, + sourceURL: sourceURL, + ownerUID: backup.ownerUID + ), + intendedRecordID: cronBinding?.intendedRecordID, + cronBinding: cronBinding, + intendedChecksum: Self.sourceChecksum(data, kind: backup.kind), + expectedDestinationChecksum: nil, + restoresSourcePresence: backup.sourceExisted + ) + case .enable where record.kind == .cron: + return try Self.cronEnabledStateProposal( + selectedSourceData, + sourceURL: sourceURL, + record: record, + isEnabled: true + ) + case .disable where record.kind == .cron, + .disableAndStop where record.kind == .cron: + return try Self.cronEnabledStateProposal( + selectedSourceData, + sourceURL: sourceURL, + record: record, + isEnabled: false + ) + case .remove where record.kind == .cron: + return try Self.cronRemovalProposal( + selectedSourceData, + sourceURL: sourceURL, + record: record + ) + case .startNow, .confirmedRunToCompletion, .stopCurrentRun, .enable, .disable, .disableAndStop, + .exportRecord, .remove: + return nil + } + } + + private static func cronEnabledStateProposal( + _ data: Data, + sourceURL: URL, + record: AutomationRecord, + isEnabled: Bool + ) throws -> SourceProposal { + guard let text = String(data: data, encoding: .utf8) else { + throw ProposalError("The complete current-user crontab is not valid UTF-8.") + } + let document = CronParser.parse(text) + let matches = document.entries.filter { cronEntry($0, matches: record) } + guard matches.count == 1, let selected = matches.first, + selected.lineNumber > 0, + selected.lineNumber <= document.originalLines.count + else { + throw ProposalError("The selected cron entry is missing or ambiguous in the displayed document generation.") + } + var lines = document.originalLines + let disabledPrefix = "# devscope-disabled: " + let trimmed = lines[selected.lineNumber - 1].trimmingCharacters(in: .whitespaces) + if isEnabled { + guard !selected.isEnabled, trimmed.hasPrefix(disabledPrefix) else { + throw ProposalError("The selected cron entry is already enabled or changed externally.") + } + lines[selected.lineNumber - 1] = String(trimmed.dropFirst(disabledPrefix.count)) + } else { + guard selected.isEnabled else { + throw ProposalError("The selected cron entry is already disabled or changed externally.") + } + lines[selected.lineNumber - 1] = disabledPrefix + trimmed + } + let intendedData = Data(lines.joined(separator: "\n").utf8) + let intended = CronParser.parse(String(decoding: intendedData, as: UTF8.self)) + let intendedMatches = intended.entries.filter { + CronParser.recordID(for: $0, ownerUID: record.ownerUID ?? 0) == record.id + && $0.command == selected.command + && $0.schedule.triggers == selected.schedule.triggers + && $0.environment == selected.environment + && $0.isEnabled == isEnabled + } + guard intended.invalidLines.isEmpty, + intendedMatches.count == 1, + let intendedEntry = intendedMatches.first + else { throw ProposalError("The rendered current-user crontab is not valid.") } + let binding = CronProposalBinding( + selectedRecordID: record.id, + intendedRecordID: CronParser.recordID(for: intendedEntry, ownerUID: record.ownerUID ?? 0), + command: intendedEntry.command, + schedule: intendedEntry.schedule, + environment: intendedEntry.environment, + enabledState: isEnabled ? .enabled : .disabled + ) + return SourceProposal( + data: intendedData, + destination: sourceURL, + intendedLabel: nil, + intendedRecordID: binding.intendedRecordID, + cronBinding: binding, + cronRemovalBinding: nil, + intendedChecksum: sourceChecksum(intendedData, kind: .cron), + expectedDestinationChecksum: nil + ) + } + + private static func cronRemovalProposal( + _ data: Data, + sourceURL: URL, + record: AutomationRecord + ) throws -> SourceProposal { + guard let text = String(data: data, encoding: .utf8) else { + throw ProposalError("The complete current-user crontab is not valid UTF-8.") + } + let document = CronParser.parse(text) + let indexedMatches = document.entries.enumerated().filter { cronEntry($0.element, matches: record) } + guard indexedMatches.count == 1, let selected = indexedMatches.first, + selected.element.lineNumber > 0, + selected.element.lineNumber <= document.originalLines.count + else { + throw ProposalError("The selected cron entry is missing or ambiguous in the displayed document generation.") + } + let fingerprint = cronFingerprint(selected.element) + var lines = document.originalLines + lines.remove(at: selected.element.lineNumber - 1) + let intendedData = Data(lines.joined(separator: "\n").utf8) + let intended = CronParser.parse(String(decoding: intendedData, as: UTF8.self)) + guard intended.invalidLines.isEmpty else { + throw ProposalError("The rendered current-user crontab is not valid.") + } + let binding = CronRemovalBinding( + command: fingerprint.command, + schedule: selected.element.schedule, + environment: selected.element.environment, + enabledState: selected.element.isEnabled ? .enabled : .disabled, + remainingMatchingCount: intended.entries.filter { cronFingerprint($0) == fingerprint }.count + ) + return SourceProposal( + data: intendedData, + destination: sourceURL, + intendedLabel: nil, + intendedRecordID: nil, + cronBinding: nil, + cronRemovalBinding: binding, + intendedChecksum: sourceChecksum(intendedData, kind: .cron), + expectedDestinationChecksum: nil + ) + } + + private static func cronEntry(_ entry: CronEntry, matches record: AutomationRecord) -> Bool { + CronParser.recordID(for: entry, ownerUID: record.ownerUID ?? 0) == record.id + && entry.command == record.commandSignature + && entry.schedule.triggers == record.schedule.triggers + && entry.environment == record.environment + && (entry.isEnabled ? AutomationEnabledState.enabled : .disabled) == record.enabledState + } + + private static func validate(_ payload: AutomationEditPayload) throws { + guard !payload.label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw ProposalError("An automation label is required.") + } + guard payload.executable.hasPrefix("/"), !payload.executable.contains("\0") else { + throw ProposalError("The automation executable must be an absolute path.") + } + guard payload.arguments.allSatisfy({ !$0.contains("\0") }), + payload.environment.allSatisfy({ key, value in + !key.isEmpty && !key.contains("=") && !key.contains("\0") && !value.contains("\0") + }) + else { + throw ProposalError("The automation arguments or environment contain invalid values.") + } + } + + private static func sourceData( + for payload: AutomationEditPayload, + kind: AutomationKind + ) throws -> Data { + if let rawRepresentation = payload.rawRepresentation { + return rawRepresentation + } + guard kind != .cron else { + throw ProposalError("Cron edits require a complete rendered crontab document.") + } + var propertyList: [String: Any] = [ + "Label": payload.label, + "ProgramArguments": [payload.executable] + payload.arguments, + ] + for trigger in payload.schedule.triggers { + switch trigger { + case .atLogin, .runAtLoad: + propertyList["RunAtLoad"] = true + case .interval(let seconds): + guard propertyList["StartInterval"] == nil, seconds > 0 else { + throw ProposalError("Generated launchd sources support one positive interval.") + } + propertyList["StartInterval"] = seconds + case .keepAlive: + propertyList["KeepAlive"] = true + case .demand: + break + case .calendar, .cron: + throw ProposalError("This schedule requires a validated raw source representation.") + } + } + if !payload.environment.isEmpty { + propertyList["EnvironmentVariables"] = payload.environment + } + if let workingDirectory = payload.workingDirectory { + propertyList["WorkingDirectory"] = workingDirectory + } + return try PropertyListSerialization.data( + fromPropertyList: propertyList, + format: .xml, + options: 0 + ) + } + + private static func validate( + data: Data, + against payload: AutomationEditPayload, + kind: AutomationKind, + sourceURL: URL, + ownerUID: uid_t + ) throws { + guard kind != .cron else { + let document = CronParser.parse(String(decoding: data, as: UTF8.self)) + guard document.invalidLines.isEmpty else { + throw ProposalError("The rendered crontab contains invalid lines.") + } + let reviewedCommand = ([payload.executable] + payload.arguments).joined(separator: " ") + guard document.entries.contains(where: { + $0.command == reviewedCommand + && $0.schedule.triggers == payload.schedule.triggers + && $0.environment == payload.environment + }) else { + throw ProposalError("The rendered crontab does not match the reviewed command, schedule, and environment.") + } + return + } + let parsed: AutomationRecord + do { + parsed = try LaunchdPlistParser.parse( + data: data, + sourceURL: sourceURL, + ownerUID: ownerUID, + ownership: .user + ) + } catch { + throw ProposalError("The launchd property list is not semantically valid.") + } + guard parsed.label == payload.label, + parsed.executable == payload.executable, + parsed.arguments == payload.arguments, + parsed.schedule.triggers == payload.schedule.triggers + else { + throw ProposalError("The raw source does not match the reviewed automation fields.") + } + guard let propertyList = try? PropertyListSerialization.propertyList( + from: data, + options: [], + format: nil + ) as? [String: Any] + else { + throw ProposalError("The launchd property list is not semantically valid.") + } + let environment = propertyList["EnvironmentVariables"] as? [String: String] ?? [:] + let workingDirectory = propertyList["WorkingDirectory"] as? String + guard environment == payload.environment, + workingDirectory == payload.workingDirectory + else { + throw ProposalError("The raw source does not match the reviewed environment or directory.") + } + } + + private static func validateCronDocument( + _ data: Data, + against record: AutomationRecord + ) throws { + let document = CronParser.parse(String(decoding: data, as: UTF8.self)) + guard document.invalidLines.isEmpty, + document.entries.contains(where: { + CronParser.recordID(for: $0, ownerUID: record.ownerUID ?? 0) == record.id + && $0.command == record.commandSignature + && $0.schedule.triggers == record.schedule.triggers + && $0.environment == record.environment + }) + else { + throw ProposalError("The imported crontab does not contain the reviewed target entry.") + } + } + + private static func cronProposalBinding( + originalData: Data, + intendedData: Data, + record: AutomationRecord, + payload: AutomationEditPayload + ) throws -> CronProposalBinding { + let ownerUID = record.ownerUID ?? 0 + let original = CronParser.parse(String(decoding: originalData, as: UTF8.self)) + let selected = original.entries.enumerated().filter { + CronParser.recordID(for: $0.element, ownerUID: ownerUID) == record.id + && $0.element.command == record.commandSignature + && $0.element.schedule.triggers == record.schedule.triggers + && $0.element.environment == record.environment + && ($0.element.isEnabled ? AutomationEnabledState.enabled : .disabled) == record.enabledState + } + guard selected.count == 1, let selectedMatch = selected.first else { + throw ProposalError("The selected cron entry is missing or ambiguous in the displayed document generation.") + } + + let command = ([payload.executable] + payload.arguments).joined(separator: " ") + let intendedDocument = CronParser.parse(String(decoding: intendedData, as: UTF8.self)) + let intended = intendedDocument.entries.enumerated().filter { + $0.element.command == command + && $0.element.schedule.triggers == payload.schedule.triggers + && $0.element.environment == payload.environment + } + guard intended.count == 1, let intendedMatch = intended.first else { + throw ProposalError("The intended cron entry is missing or ambiguous in the rendered document.") + } + var originalCounts = cronEntryCounts(original.entries) + var intendedCounts = cronEntryCounts(intendedDocument.entries) + guard removeOne(cronFingerprint(selectedMatch.element), from: &originalCounts), + removeOne(cronFingerprint(intendedMatch.element), from: &intendedCounts), + originalCounts == intendedCounts + else { + throw ProposalError( + "A cron edit must replace exactly the selected entry and preserve unrelated entries." + ) + } + guard selectedMatch.offset == intendedMatch.offset else { + throw ProposalError("The intended cron entry does not replace the selected occurrence.") + } + var originalRemainder = original.entries.map(cronFingerprint) + var intendedRemainder = intendedDocument.entries.map(cronFingerprint) + originalRemainder.remove(at: selectedMatch.offset) + intendedRemainder.remove(at: intendedMatch.offset) + guard originalRemainder == intendedRemainder + else { + throw ProposalError( + "A cron edit must replace exactly the selected entry and preserve unrelated entries." + ) + } + let entry = intendedMatch.element + return CronProposalBinding( + selectedRecordID: record.id, + intendedRecordID: CronParser.recordID(for: entry, ownerUID: ownerUID), + command: entry.command, + schedule: entry.schedule, + environment: entry.environment, + enabledState: entry.isEnabled ? .enabled : .disabled + ) + } + + private static func cronDuplicateBinding( + originalData: Data, + intendedData: Data, + record: AutomationRecord, + payload: AutomationEditPayload + ) throws -> CronProposalBinding { + let ownerUID = record.ownerUID ?? 0 + let original = CronParser.parse(String(decoding: originalData, as: UTF8.self)) + let intended = CronParser.parse(String(decoding: intendedData, as: UTF8.self)) + let selected = original.entries.filter { + CronParser.recordID(for: $0, ownerUID: ownerUID) == record.id + && $0.command == record.commandSignature + && $0.schedule.triggers == record.schedule.triggers + && $0.environment == record.environment + && ($0.isEnabled ? AutomationEnabledState.enabled : .disabled) == record.enabledState + } + guard selected.count == 1 else { + throw ProposalError("The selected cron entry is missing or ambiguous in the displayed document generation.") + } + + let before = cronEntryCounts(original.entries) + let after = cronEntryCounts(intended.entries) + let removedCount = before.reduce(into: 0) { total, pair in + total += max(0, pair.value - after[pair.key, default: 0]) + } + let added = after.flatMap { pair in + Array(repeating: pair.key, count: max(0, pair.value - before[pair.key, default: 0])) + } + let reviewedCommand = ([payload.executable] + payload.arguments).joined(separator: " ") + guard removedCount == 0, + added.count == 1, + let addedFingerprint = added.first, + addedFingerprint.command == reviewedCommand, + addedFingerprint.scheduleExpression == cronExpression(from: payload.schedule), + addedFingerprint.environment == cronEnvironmentFingerprint(payload.environment) + else { + throw ProposalError("A cron duplicate must add exactly one reviewed entry and preserve the document.") + } + + let originalIDs = Set(original.entries.map { + CronParser.recordID(for: $0, ownerUID: ownerUID) + }) + let candidates = intended.entries.filter { + cronFingerprint($0) == addedFingerprint + && !originalIDs.contains(CronParser.recordID(for: $0, ownerUID: ownerUID)) + } + guard candidates.count == 1, + let entry = candidates.first, + CronParser.recordID(for: entry, ownerUID: ownerUID) != record.id + else { + throw ProposalError("The added cron entry identity is not distinct.") + } + return CronProposalBinding( + selectedRecordID: record.id, + intendedRecordID: CronParser.recordID(for: entry, ownerUID: ownerUID), + command: entry.command, + schedule: entry.schedule, + environment: entry.environment, + enabledState: entry.isEnabled ? .enabled : .disabled + ) + } + + private static func cronRestoreBinding( + data: Data, + backup: AutomationBackup, + selectedRecord: AutomationRecord + ) throws -> CronProposalBinding { + let matches = CronParser.parse(String(decoding: data, as: UTF8.self)).entries.filter { + CronParser.recordID(for: $0, ownerUID: backup.ownerUID) == backup.recordID + } + guard matches.count == 1, let entry = matches.first else { + throw ProposalError("The recovery crontab does not contain one exact recovery-lineage entry.") + } + return CronProposalBinding( + selectedRecordID: selectedRecord.id, + intendedRecordID: backup.recordID, + command: entry.command, + schedule: entry.schedule, + environment: entry.environment, + enabledState: entry.isEnabled ? .enabled : .disabled + ) + } + + private static func cronEntryCounts( + _ entries: [CronEntry] + ) -> [CronEntryFingerprint: Int] { + entries.reduce(into: [:]) { counts, entry in + counts[cronFingerprint(entry), default: 0] += 1 + } + } + + private static func removeOne( + _ fingerprint: CronEntryFingerprint, + from counts: inout [CronEntryFingerprint: Int] + ) -> Bool { + guard let count = counts[fingerprint], count > 0 else { return false } + if count == 1 { + counts.removeValue(forKey: fingerprint) + } else { + counts[fingerprint] = count - 1 + } + return true + } + + private static func cronFingerprint(_ entry: CronEntry) -> CronEntryFingerprint { + CronEntryFingerprint( + scheduleExpression: entry.scheduleExpression, + command: entry.command, + environment: cronEnvironmentFingerprint(entry.environment), + isEnabled: entry.isEnabled + ) + } + + private static func cronEnvironmentFingerprint( + _ environment: [String: String] + ) -> [String] { + environment.keys.sorted().map { key in + key + "\u{0}" + (environment[key] ?? "") + } + } + + private static func cronExpression(from schedule: AutomationSchedule) -> String? { + guard schedule.triggers.count == 1, + case .cron(let expression) = schedule.triggers[0] + else { return nil } + return expression + } + + private static func cronImportBinding( + data: Data, + record: AutomationRecord + ) throws -> CronProposalBinding { + let ownerUID = record.ownerUID ?? 0 + let matches = CronParser.parse(String(decoding: data, as: UTF8.self)).entries.filter { + CronParser.recordID(for: $0, ownerUID: ownerUID) == record.id + && $0.command == record.commandSignature + && $0.schedule.triggers == record.schedule.triggers + && $0.environment == record.environment + && ($0.isEnabled ? AutomationEnabledState.enabled : .disabled) == record.enabledState + } + guard matches.count == 1, let entry = matches.first else { + throw ProposalError("The imported crontab does not contain one exact reviewed target entry.") + } + return CronProposalBinding( + selectedRecordID: record.id, + intendedRecordID: record.id, + command: entry.command, + schedule: entry.schedule, + environment: entry.environment, + enabledState: entry.isEnabled ? .enabled : .disabled + ) + } + + private static func parsedLabel( + data: Data, + kind: AutomationKind, + sourceURL: URL, + ownerUID: uid_t + ) -> String? { + guard kind != .cron else { return nil } + return try? LaunchdPlistParser.parse( + data: data, + sourceURL: sourceURL, + ownerUID: ownerUID, + ownership: .user + ).label + } + + private static func isValidSource( + _ data: Data, + kind: AutomationKind, + expectedLabel: String?, + sourceURL: URL, + ownerUID: uid_t + ) -> Bool { + if kind == .cron { + guard let text = String(data: data, encoding: .utf8) else { return false } + return CronParser.parse(text).invalidLines.isEmpty + } + guard let parsed = try? LaunchdPlistParser.parse( + data: data, + sourceURL: sourceURL, + ownerUID: ownerUID, + ownership: .user + ) else { return false } + return expectedLabel == nil || parsed.label == expectedLabel + } + + private func verify( + _ operation: AutomationOperation, + record: AutomationRecord, + executorResult: AutomationExecutorResult, + snapshot: AutomationInventorySnapshot, + proposal: SourceProposal?, + transactionSourceURL: URL, + controlledIdentities: Set, + liveProcesses: [DevProcess] + ) -> (satisfied: Bool, evidence: [String], verifiedRecordID: AutomationRecord.ID?) { + let requiredPostconditions: Set + if case .restore = operation, proposal?.restoresSourcePresence == false { + requiredPostconditions = [.sourceRemoved] + } else { + requiredPostconditions = operation.requiredPostconditions + } + guard executorResult.postconditions.isSuperset(of: requiredPostconditions) else { + return (false, ["The executor did not prove every required postcondition."], nil) + } + let refreshedRecord = snapshot.records.first { $0.id == record.id } + switch operation { + case .disable, .disableAndStop: + if record.sourceKind == .legacyLoginItem { + guard snapshot.health[.legacyLoginItem]?.state == .healthy else { + return (false, ["Authoritative legacy login-item health is not healthy."], nil) + } + guard !snapshot.records.contains(where: { Self.sameLegacyLoginItem($0, record) }) else { + return (false, ["The selected legacy login item is still present."], nil) + } + return (true, ["Refreshed login items confirm the selected path is absent."], nil) + } + guard refreshedRecord?.enabledState == .disabled else { + return (false, ["Refreshed inventory still reports future launches as enabled or unknown."], nil) + } + if case .disableAndStop = operation, + !runtimeStopIsVerified( + record: record, + snapshot: snapshot, + controlledIdentities: controlledIdentities, + liveProcesses: liveProcesses + ) { + return (false, ["Refreshed process truth still contains a controlled or relaunched process."], nil) + } + if record.kind == .cron { + guard let proposal, + executorResult.postconditions.contains(.sourceChecksum(proposal.intendedChecksum)), + refreshedRecord?.sourceChecksum == proposal.intendedChecksum, + fileSystem.itemExists(at: transactionSourceURL), + (try? Self.sourceChecksum(fileSystem.read(transactionSourceURL), kind: record.kind)) == proposal.intendedChecksum + else { + return (false, ["The refreshed current-user crontab does not match the complete disabled document."], nil) + } + } + return (true, ["Refreshed inventory confirms future launches are disabled."], nil) + case .enable: + guard refreshedRecord?.enabledState == .enabled else { + return (false, ["Refreshed inventory does not report future launches as enabled."], nil) + } + if record.kind == .cron { + guard let proposal, + executorResult.postconditions.contains(.sourceChecksum(proposal.intendedChecksum)), + refreshedRecord?.sourceChecksum == proposal.intendedChecksum, + fileSystem.itemExists(at: transactionSourceURL), + (try? Self.sourceChecksum(fileSystem.read(transactionSourceURL), kind: record.kind)) == proposal.intendedChecksum + else { + return (false, ["The refreshed current-user crontab does not match the complete enabled document."], nil) + } + } + return (true, ["Refreshed inventory confirms future launches are enabled."], nil) + case .confirmedRunToCompletion: + guard record.kind == .cron else { + return (false, ["Run-to-completion confirmation is only supported for cron records."], nil) + } + return (true, ["The explicitly confirmed cron command completed successfully."], nil) + case .startNow: + guard let refreshedRecord else { + return (false, ["Refreshed inventory no longer contains the requested automation."], nil) + } + let hasStrongFreshLink = AutomationProcessCorrelator.links( + records: [refreshedRecord], + processes: liveProcesses, + now: now() + ).contains { link in + link.recordID == refreshedRecord.id + && link.strength == .strong + && link.processIdentity.birthToken != nil + } + guard hasStrongFreshLink else { + return (false, ["Fresh process truth does not contain a strong birth-identified automation link."], nil) + } + return ( + true, + ["Fresh process truth confirms a strong link to the requested automation."], + refreshedRecord.id + ) + case .remove: + if record.kind == .cron { + guard let proposal, + let binding = proposal.cronRemovalBinding, + executorResult.postconditions.contains(.sourceChecksum(proposal.intendedChecksum)), + fileSystem.itemExists(at: transactionSourceURL), + (try? Self.sourceChecksum(fileSystem.read(transactionSourceURL), kind: record.kind)) == proposal.intendedChecksum + else { + return (false, ["The remaining current-user crontab document could not be verified."], nil) + } + let matchingRecords = snapshot.records.filter { + $0.kind == .cron + && $0.sourceKind == .crontab + && $0.ownerUID == record.ownerUID + && $0.commandSignature == binding.command + && $0.schedule.triggers == binding.schedule.triggers + && $0.environment == binding.environment + && $0.enabledState == binding.enabledState + && $0.sourceChecksum == proposal.intendedChecksum + } + guard matchingRecords.count == binding.remainingMatchingCount, + snapshot.records.filter({ + $0.kind == .cron && $0.sourceKind == .crontab && $0.ownerUID == record.ownerUID + }).allSatisfy({ $0.sourceChecksum == proposal.intendedChecksum }) + else { + return (false, ["The selected cron occurrence is still present or the remaining document changed."], nil) + } + return (true, ["Refreshed current-user crontab confirms the selected occurrence was removed."], nil) + } + if record.sourceKind == .legacyLoginItem { + guard snapshot.health[.legacyLoginItem]?.state == .healthy else { + return (false, ["Authoritative legacy login-item health is not healthy."], nil) + } + guard !snapshot.records.contains(where: { Self.sameLegacyLoginItem($0, record) }), + fileSystem.itemExists(at: transactionSourceURL) + else { + return (false, ["The selected legacy login item is still present or recovery evidence disappeared."], nil) + } + return (true, ["Refreshed login items confirm only the selected path was removed."], nil) + } + if record.sourceKind == .launchAgent, + !executorResult.postconditions.contains(.targetUnresolved) { + return (false, ["launchd still resolves the removed service target."], nil) + } + let canonicalTransactionPath = transactionSourceURL.standardizedFileURL.path + let recreatedAtTransactionIdentity = snapshot.records.contains { candidate in + if let candidateURL = candidate.sourceURL { + return candidateURL.standardizedFileURL.path == canonicalTransactionPath + } + return record.sourceKind.requiresRecoverableSource + && candidate.sourceKind == record.sourceKind + && candidate.ownerUID == record.ownerUID + } + guard refreshedRecord == nil, + !fileSystem.itemExists(at: transactionSourceURL), + !recreatedAtTransactionIdentity + else { + return (false, ["The removed source or transaction identity still exists."], nil) + } + return (true, ["Refreshed inventory confirms the source was removed."], nil) + case .edit, .duplicate, .importRecord, .restore: + if case .restore = operation, proposal?.restoresSourcePresence == false { + let stillPresent = fileSystem.itemExists(at: transactionSourceURL) + || snapshot.records.contains { candidate in + candidate.sourceURL?.standardizedFileURL.path == transactionSourceURL.standardizedFileURL.path + } + return stillPresent + ? (false, ["Refreshed inventory still contains the source restored to absence."], nil) + : (true, ["Refreshed inventory confirms the prior absent source state."], nil) + } + guard let proposal, + executorResult.postconditions.contains(.sourceChecksum(proposal.intendedChecksum)) + else { + return (false, ["The executor did not prove the intended source checksum."], nil) + } + let installed = snapshot.records.first { candidate in + if record.kind == .cron { + guard let binding = proposal.cronBinding else { return false } + return binding.selectedRecordID == record.id + && candidate.id == binding.intendedRecordID + && candidate.kind == .cron + && candidate.sourceKind == .crontab + && candidate.ownerUID == record.ownerUID + && candidate.commandSignature == binding.command + && candidate.schedule.triggers == binding.schedule.triggers + && candidate.environment == binding.environment + && candidate.enabledState == binding.enabledState + && candidate.sourceChecksum == proposal.intendedChecksum + } + let destinationMatches = record.sourceKind.requiresRecoverableSource + ? candidate.sourceKind == record.sourceKind + : candidate.sourceURL?.standardizedFileURL.path == proposal.destination.standardizedFileURL.path + return destinationMatches + && candidate.label == proposal.intendedLabel + && candidate.sourceChecksum == proposal.intendedChecksum + } + guard let installed else { + return (false, ["Refreshed inventory does not contain the intended destination, label, and checksum."], nil) + } + if case .duplicate = operation, record.sourceKind == .launchAgent { + guard executorResult.postconditions.contains(.futureLaunchesDisabled), + installed.enabledState == .disabled, + installed.loadState == .unloaded + else { + return ( + false, + ["The duplicated LaunchAgent is not verified disabled and unloaded."], + nil + ) + } + } + return (true, ["Refreshed inventory confirms the intended destination, label, and checksum."], installed.id) + case .stopCurrentRun: + guard runtimeStopIsVerified( + record: record, + snapshot: snapshot, + controlledIdentities: controlledIdentities, + liveProcesses: liveProcesses + ) else { + return (false, ["Refreshed process truth still contains a controlled or relaunched process."], nil) + } + return (true, ["Refreshed process truth confirms no controlled process remains."], nil) + case .exportRecord: + return (true, ["The executor resolved the export target."], nil) + } + } + + private func runtimeStopIsVerified( + record: AutomationRecord, + snapshot: AutomationInventorySnapshot, + controlledIdentities: Set, + liveProcesses: [DevProcess] + ) -> Bool { + guard !liveProcesses.contains(where: { + $0.birthToken == nil && Self.isStrongBirthlessCandidate($0, for: record) + }) else { return false } + let liveIdentities = Set(liveProcesses.compactMap { process -> AutomationLinkedProcessIdentity? in + guard let birth = process.birthToken else { return nil } + return AutomationLinkedProcessIdentity(processID: process.pid, birthToken: birth) + }) + guard controlledIdentities.isDisjoint(with: liveIdentities) else { return false } + let candidateIDs = Set(snapshot.records.filter { + $0.id == record.id + || ($0.sourceKind == record.sourceKind && $0.ownerUID == record.ownerUID && $0.label == record.label) + }.map(\.id)) + return AutomationProcessCorrelator.links( + records: snapshot.records, + processes: liveProcesses, + now: now() + ).allSatisfy { !candidateIDs.contains($0.recordID) } + } + + private static func isStrongBirthlessCandidate( + _ process: DevProcess, + for record: AutomationRecord + ) -> Bool { + if record.kind == .cron { + guard let signature = record.commandSignature, + let argv = process.argumentVector, + argv.count == 3, + argv[1] == "-c" + else { return false } + return argv[2] == signature + } + let exactLabel = process.launchLabel == record.label + let exactExecutableAndArguments = record.executable.map { + URL(fileURLWithPath: process.executable).standardizedFileURL.path + == URL(fileURLWithPath: $0).standardizedFileURL.path + && process.argumentVector == [$0] + record.arguments + } ?? false + return exactLabel || exactExecutableAndArguments + } + + private func failureAfterMutation( + operation: AutomationOperation, + record: AutomationRecord, + linkedProcesses: [ClassifiedDevProcess], + backup: AutomationBackup, + preTransactionState: AutomationPreTransactionState, + proposal: SourceProposal?, + originalSourceMetadata: AutomationFileMetadata, + postMutationResourceIdentifier: String?, + sourceMutationStep: String?, + phase: TransactionFailurePhase, + evidence: [String], + expectedAppliedState: AutomationExecutorAppliedState?, + fileMutationEvidence: AutomationFilePartialMutation? = nil + ) async -> AutomationOperationResult { + var appliedSteps = ["Created owner-only recovery backup."] + if let sourceMutationStep { + appliedSteps.append(sourceMutationStep) + } + if phase == .verificationFailed { + appliedSteps.append("Applied typed automation operation.") + } + + do { + guard try rollbackTargetIsUnchanged( + operation: operation, + proposal: proposal, + backup: backup, + sourceMutationStep: sourceMutationStep, + originalSourceMetadata: originalSourceMetadata, + postMutationResourceIdentifier: postMutationResourceIdentifier + ) else { + throw RollbackError.sourceChangedExternally + } + guard try writeTargetIsAuthorized( + record: record, + url: backup.sourceURL, + expectedResourceIdentifier: postMutationResourceIdentifier, + expectedParentResourceIdentifier: backup.parentResourceIdentifier, + requiredCapability: .restore + ) else { + throw RollbackError.sourceChangedExternally + } + let backupData = try fileSystem.read(backup.backupURL) + guard Self.checksum(backupData) == backup.checksum else { + throw RollbackError.integrityMismatch + } + let verifiedRecovery = AutomationVerifiedRecoveryInput( + backupID: backup.id, + backupURL: backup.backupURL, + data: backupData, + checksum: backup.checksum + ) + if backup.sourceExisted { + let parentAuthorization = try Self.directoryAuthorization( + for: backup.sourceURL.deletingLastPathComponent(), + resourceIdentifier: backup.parentResourceIdentifier + ) + let staged = try fileSystem.writeStagedFile( + nextTo: backup.sourceURL, + data: verifiedRecovery.data, + permissions: 0o600, + authorization: parentAuthorization + ) + guard try writeTargetIsAuthorized( + record: record, + url: backup.sourceURL, + expectedResourceIdentifier: postMutationResourceIdentifier, + expectedParentResourceIdentifier: backup.parentResourceIdentifier, + requiredCapability: .restore + ) else { + try removeCleanupArtifact(staged.authorization) + throw RollbackError.sourceChangedExternally + } + let destinationExists = fileSystem.itemExists(at: backup.sourceURL) + let destinationResourceIdentifier = destinationExists + ? (postMutationResourceIdentifier ?? originalSourceMetadata.resourceIdentifier) + : nil + let destinationAuthorization = try Self.fileAuthorization( + for: backup.sourceURL, + directory: parentAuthorization, + resourceIdentifier: destinationResourceIdentifier, + existed: destinationExists + ) + _ = try committedReplacement(at: destinationAuthorization, with: staged) + } else if fileSystem.itemExists(at: backup.sourceURL) { + guard try writeTargetIsAuthorized( + record: record, + url: backup.sourceURL, + expectedResourceIdentifier: postMutationResourceIdentifier, + expectedParentResourceIdentifier: backup.parentResourceIdentifier, + requiredCapability: .restore + ) else { + throw RollbackError.sourceChangedExternally + } + let parentAuthorization = try Self.directoryAuthorization( + for: backup.sourceURL.deletingLastPathComponent(), + resourceIdentifier: backup.parentResourceIdentifier + ) + let sourceAuthorization = try Self.fileAuthorization( + for: backup.sourceURL, + directory: parentAuthorization, + resourceIdentifier: postMutationResourceIdentifier, + existed: true + ) + _ = try Self.committedReceipt(fileSystem.removeItem(sourceAuthorization)) + } + appliedSteps.append("Restored source from recovery backup.") + + if !preTransactionState.sourceExisted { + let recoveredSnapshot = await refresh() + let sourcePath = backup.sourceURL.standardizedFileURL.path + guard !fileSystem.itemExists(at: backup.sourceURL), + !recoveredSnapshot.records.contains(where: { + $0.sourceURL?.standardizedFileURL.path == sourcePath + }) + else { + throw RollbackError.priorStateUnverified + } + appliedSteps.append("Refreshed and verified the prior absent source state.") + return AutomationOperationResult( + operation: operation.redactedForResult, + status: Self.statusAfterRecoveredMutation( + fileMutationEvidence, + otherwise: "The automation operation failed; the prior absent source state was restored." + ), + appliedSteps: appliedSteps, + verificationEvidence: evidence, + rollback: .restored(backup.id), + manualRecovery: nil, + backup: backup.redactedForResult, + fileMutationEvidence: fileMutationEvidence + ) + } + + if phase == .sourceMutationFailed { + let recoveredSnapshot = await refresh() + guard let recoveredRecord = recoveredSnapshot.records.first(where: { $0.id == record.id }), + recoveredRecord.enabledState == preTransactionState.enabledState, + recoveredRecord.loadState == preTransactionState.loadState, + fileSystem.itemExists(at: backup.sourceURL), + Self.checksum(try fileSystem.read(backup.sourceURL)) == backup.checksum + else { throw RollbackError.priorStateUnverified } + appliedSteps.append( + record.kind == .cron + ? "Verified live cron state was never mutated." + : "Verified the prior source and loaded state remain intact." + ) + return AutomationOperationResult( + operation: operation.redactedForResult, + status: Self.statusAfterRecoveredMutation( + fileMutationEvidence, + otherwise: record.kind == .cron + ? "The automation operation failed before the live crontab was changed." + : "The automation operation failed; the prior source and loaded state were restored." + ), + appliedSteps: appliedSteps, + verificationEvidence: evidence, + rollback: .restored(backup.id), + manualRecovery: nil, + backup: backup.redactedForResult, + fileMutationEvidence: fileMutationEvidence + ) + } + + let priorStateResult = try await executor.restorePreTransactionState( + preTransactionState, + for: record, + recovery: verifiedRecovery, + linkedProcesses: linkedProcesses, + expectedAppliedState: expectedAppliedState + ) + guard priorStateResult.postconditions.contains( + .preTransactionStateRestored(preTransactionState) + ) else { + throw RollbackError.priorStateUnverified + } + appliedSteps.append("Re-applied the exact pre-transaction state.") + + let recoveredSnapshot = await refresh() + let recoveredProcesses = preTransactionState.linkedProcesses.isEmpty + ? [] + : try await refreshProcesses() + guard let recoveredRecord = recoveredSnapshot.records.first(where: { $0.id == record.id }), + recoveredRecord.enabledState == preTransactionState.enabledState, + recoveredRecord.loadState == preTransactionState.loadState + else { + throw RollbackError.priorStateUnverified + } + if !preTransactionState.linkedProcesses.isEmpty { + let links = AutomationProcessCorrelator.links( + records: recoveredSnapshot.records, + processes: recoveredProcesses, + now: now() + ).filter { $0.recordID == recoveredRecord.id } + let restoredIdentities = Set(links.compactMap { link -> AutomationLinkedProcessIdentity? in + guard let birth = link.processIdentity.birthToken else { return nil } + return AutomationLinkedProcessIdentity( + processID: link.processIdentity.pid, + birthToken: birth + ) + }) + guard restoredIdentities.count >= preTransactionState.linkedProcesses.count else { + throw RollbackError.priorStateUnverified + } + } + if backup.sourceExisted { + guard fileSystem.itemExists(at: backup.sourceURL), + Self.checksum(try fileSystem.read(backup.sourceURL)) == backup.checksum + else { + throw RollbackError.priorStateUnverified + } + } else { + guard !fileSystem.itemExists(at: backup.sourceURL) else { + throw RollbackError.priorStateUnverified + } + } + appliedSteps.append("Refreshed and verified exact recovery state.") + + return AutomationOperationResult( + operation: operation.redactedForResult, + status: Self.statusAfterRecoveredMutation( + fileMutationEvidence, + otherwise: "The automation operation failed; the prior source and loaded state were restored." + ), + appliedSteps: appliedSteps, + verificationEvidence: evidence, + rollback: .restored(backup.id), + manualRecovery: nil, + backup: backup.redactedForResult, + fileMutationEvidence: fileMutationEvidence + ) + } catch let rollbackPartial as AutomationFilePartialMutation { + _ = await refresh() + return AutomationOperationResult( + operation: operation.redactedForResult, + status: .partialFailure( + "HIGH SEVERITY: the operation failed and automatic rollback reached a partial filesystem state." + ), + appliedSteps: appliedSteps, + verificationEvidence: evidence, + rollback: .failed("Automatic rollback did not complete."), + manualRecovery: "Preserve the authorized filesystem recovery handles and use DevScope Restore before retrying.", + backup: backup.redactedForResult, + fileMutationEvidence: fileMutationEvidence ?? rollbackPartial, + rollbackFileMutationEvidence: fileMutationEvidence == nil ? nil : rollbackPartial + ) + } catch { + _ = await refresh() + return AutomationOperationResult( + operation: operation.redactedForResult, + status: .partialFailure( + "HIGH SEVERITY: the operation failed and automatic rollback did not complete." + ), + appliedSteps: appliedSteps, + verificationEvidence: evidence, + rollback: .failed("Automatic rollback did not complete."), + manualRecovery: "Use DevScope Restore with the recorded recovery identifier before retrying.", + backup: backup.redactedForResult, + fileMutationEvidence: fileMutationEvidence + ) + } + } + + private static func statusAfterRecoveredMutation( + _ partial: AutomationFilePartialMutation?, + otherwise message: String + ) -> AutomationOperationStatus { + if partial != nil { + return .partialFailure( + "HIGH SEVERITY: the filesystem reported a partial mutation; automatic rollback restored the prior state." + ) + } + return .failed(message) + } + + private func rollbackTargetIsUnchanged( + operation: AutomationOperation, + proposal: SourceProposal?, + backup: AutomationBackup, + sourceMutationStep: String?, + originalSourceMetadata: AutomationFileMetadata, + postMutationResourceIdentifier: String? + ) throws -> Bool { + if case .remove = operation, backup.sourceKind == .legacyLoginItem { + guard fileSystem.itemExists(at: backup.sourceURL) else { return false } + let data = try fileSystem.read(backup.sourceURL) + let metadata = try fileSystem.metadata(for: backup.sourceURL) + return Self.checksum(data) == backup.checksum + && metadata.resourceIdentifier == originalSourceMetadata.resourceIdentifier + } + if case .remove = operation { + return !fileSystem.itemExists(at: backup.sourceURL) + } + if proposal?.restoresSourcePresence == false, sourceMutationStep != nil { + return !fileSystem.itemExists(at: backup.sourceURL) + } + guard fileSystem.itemExists(at: backup.sourceURL) else { return false } + let data = try fileSystem.read(backup.sourceURL) + let metadata = try fileSystem.metadata(for: backup.sourceURL) + guard let resourceIdentifier = metadata.resourceIdentifier else { return false } + if sourceMutationStep != nil, let proposal { + return Self.sourceChecksum(data, kind: backup.kind) == proposal.intendedChecksum + && resourceIdentifier == postMutationResourceIdentifier + } + return Self.checksum(data) == backup.checksum + && resourceIdentifier == originalSourceMetadata.resourceIdentifier + } + + private func writeTargetIsAuthorized( + record: AutomationRecord, + url: URL, + expectedResourceIdentifier: String?, + expectedParentResourceIdentifier: String?, + requiredCapability: AutomationCapability + ) throws -> Bool { + let destination = url.standardizedFileURL + let context = try destinationContext(record, destination) + let decision = AutomationCapabilityPolicy.decision(for: record, context: context) + guard decision.capabilities.contains(requiredCapability), + context.canonicalPathIsApproved, + context.sourceOwnerUID == context.currentUID, + !context.isManaged, + !context.isSymlink + else { return false } + let parent = destination.deletingLastPathComponent().standardizedFileURL + let parentMetadata = try fileSystem.metadata(for: parent) + guard parentMetadata.canonicalURL.standardizedFileURL.path == parent.path, + parentMetadata.ownerUID == context.currentUID, + !parentMetadata.isSymbolicLink, + parentMetadata.resourceIdentifier?.isEmpty == false, + expectedParentResourceIdentifier == nil + || parentMetadata.resourceIdentifier == expectedParentResourceIdentifier + else { return false } + if fileSystem.itemExists(at: destination) { + let metadata = try fileSystem.metadata(for: destination) + guard metadata.canonicalURL.standardizedFileURL.path == destination.path, + metadata.ownerUID == context.currentUID, + !metadata.isSymbolicLink, + let identity = metadata.resourceIdentifier, + !identity.isEmpty + else { return false } + return expectedResourceIdentifier == nil || identity == expectedResourceIdentifier + } + return expectedResourceIdentifier == nil + } + + private func rejected( + _ operation: AutomationOperation, + _ message: String + ) -> AutomationOperationResult { + AutomationOperationResult( + operation: operation.redactedForResult, + status: .rejected(message), + appliedSteps: [], + verificationEvidence: [], + rollback: .notNeeded, + manualRecovery: nil + ) + } + + private func failed( + _ operation: AutomationOperation, + _ message: String, + appliedSteps: [String] = [], + evidence: [String] = [], + manualRecovery: String? = nil, + backup: AutomationBackup? = nil + ) -> AutomationOperationResult { + AutomationOperationResult( + operation: operation.redactedForResult, + status: .failed(message), + appliedSteps: appliedSteps, + verificationEvidence: evidence, + rollback: .notNeeded, + manualRecovery: manualRecovery, + backup: backup?.redactedForResult + ) + } + + private static func checksum(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + + private static func sameLegacyLoginItem( + _ candidate: AutomationRecord, + _ selected: AutomationRecord + ) -> Bool { + guard candidate.sourceKind == .legacyLoginItem else { return false } + if candidate.id == selected.id { return true } + guard let candidatePath = candidate.executable, let selectedPath = selected.executable else { + return false + } + return URL(fileURLWithPath: candidatePath).standardizedFileURL.path + == URL(fileURLWithPath: selectedPath).standardizedFileURL.path + } + + private static func sourceChecksum(_ data: Data, kind: AutomationKind) -> String { + if kind == .cron, let normalized = CronDocumentChecksum.checksum(data) { + return normalized + } + return checksum(data) + } + + private static func directoryAuthorization( + for directory: URL, + resourceIdentifier: String? + ) throws -> AutomationDirectoryAuthorization { + guard let authorization = AutomationDirectoryAuthorization( + directoryURL: directory, + resourceIdentifier: resourceIdentifier + ) else { throw AutomationManagerConfigurationError.destinationAuthorizationUnavailable } + return authorization + } + + private static func committedReceipt( + _ outcome: AutomationFileMutationOutcome + ) throws -> AutomationFileMutationReceipt { + guard case .committed(let receipt) = outcome else { + throw AutomationManagerConfigurationError.recoverableSourceUnavailable + } + return receipt + } + + private func committedReplacement( + at destination: AutomationFileAuthorization, + with staged: AutomationStagedFile + ) throws -> AutomationFileMutationReceipt { + let outcome: AutomationFileMutationOutcome + do { + outcome = try fileSystem.replaceItem(at: destination, with: staged) + } catch let partial as AutomationFilePartialMutation { + throw partial + } catch { + try failAfterCleaningStaged(staged, originalError: error) + } + switch outcome { + case .committed(let receipt): + return receipt + case .unchanged: + try failAfterCleaningStaged( + staged, + originalError: AutomationManagerConfigurationError.recoverableSourceUnavailable + ) + } + } + + private func failAfterCleaningStaged( + _ staged: AutomationStagedFile, + originalError: Error + ) throws -> Never { + do { + try removeCleanupArtifact(staged.authorization) + } catch { + let survivors = currentAuthorizedSurvivors([staged.authorization]) + guard !survivors.isEmpty else { throw originalError } + throw AutomationFilePartialMutation( + kind: .remove, + commitState: .unknown, + observedFiles: survivors, + recoveryHandle: survivors.first, + recoveryHandles: survivors, + resultURL: staged.url + ) + } + throw originalError + } + + private static func fileAuthorization( + for file: URL, + directory: AutomationDirectoryAuthorization, + resourceIdentifier: String?, + existed: Bool + ) throws -> AutomationFileAuthorization { + let expectation: AutomationFileExpectation + if existed { + guard let resourceIdentifier, !resourceIdentifier.isEmpty else { + throw AutomationManagerConfigurationError.destinationAuthorizationUnavailable + } + expectation = .existing(resourceIdentifier: resourceIdentifier) + } else { + expectation = .absent + } + guard let authorization = AutomationFileAuthorization( + fileURL: file, + directory: directory, + expectation: expectation + ) else { throw AutomationManagerConfigurationError.destinationAuthorizationUnavailable } + return authorization + } +} + +private extension AutomationOperation { + var redactedForResult: AutomationOperation { + switch self { + case .confirmedRunToCompletion(let confirmation): + .confirmedRunToCompletion(AutomationRunToCompletionConfirmation( + recordID: confirmation.recordID, + sourceChecksum: confirmation.sourceChecksum, + exactCommand: "" + )) + case .edit(let payload): + .edit(payload.redactedForResult) + case .duplicate(let payload): + .duplicate(payload.redactedForResult) + case .importRecord(let payload): + .importRecord(AutomationImportPayload( + destination: URL(fileURLWithPath: "/redacted"), + data: Data(), + expectedKind: payload.expectedKind, + expectedDestinationChecksum: payload.expectedDestinationChecksum + )) + default: + self + } + } + + var requiredCapability: AutomationCapability { + switch self { + case .startNow, .confirmedRunToCompletion: .startNow + case .stopCurrentRun: .stopCurrentRun + case .enable: .enable + case .disable: .disable + case .disableAndStop: .disableAndStop + case .edit: .edit + case .duplicate: .duplicate + case .importRecord: .importRecord + case .exportRecord: .exportRecord + case .remove: .remove + case .restore: .restore + } + } + + var controlsLinkedProcesses: Bool { + switch self { + case .stopCurrentRun, .disableAndStop: + true + default: + false + } + } + + var requiresFreshProcessSnapshot: Bool { + if controlsLinkedProcesses { return true } + if case .startNow = self { return true } + return false + } + + var requiredPostconditions: Set { + switch self { + case .startNow: [.targetResolved, .currentRunStarted] + case .confirmedRunToCompletion: [.targetResolved, .runCompleted] + case .stopCurrentRun: [.noLinkedProcess] + case .enable: [.futureLaunchesEnabled] + case .disable: [.futureLaunchesDisabled] + case .disableAndStop: [.futureLaunchesDisabled, .noLinkedProcess] + case .edit, .duplicate, .importRecord, .restore: [.sourceInstalled] + case .exportRecord: [.targetResolved] + case .remove: [.sourceRemoved] + } + } +} + +private extension AutomationEditPayload { + var redactedForResult: AutomationEditPayload { + AutomationEditPayload( + label: "", + executable: "/redacted", + arguments: arguments.map { _ in "" }, + environment: environment.mapValues { _ in "" }, + workingDirectory: workingDirectory == nil ? nil : "/redacted", + schedule: schedule, + rawRepresentation: nil, + destination: destination == nil ? nil : URL(fileURLWithPath: "/redacted"), + expectedDestinationChecksum: expectedDestinationChecksum + ) + } +} + +private extension AutomationBackup { + var redactedForResult: AutomationBackup { + AutomationBackup( + id: id, + recordID: recordID, + sourceURL: URL(fileURLWithPath: "/redacted"), + backupURL: URL(fileURLWithPath: "/redacted"), + checksum: checksum, + createdAt: createdAt, + sourceExisted: sourceExisted, + ownerUID: ownerUID, + kind: kind, + sourceKind: sourceKind, + authorizedRecordIDs: authorizedRecordIDs, + parentResourceIdentifier: nil + ) + } +} + +private struct SourceProposal { + let data: Data + let destination: URL + let intendedLabel: String? + let intendedRecordID: AutomationRecord.ID? + let cronBinding: CronProposalBinding? + let cronRemovalBinding: CronRemovalBinding? + let intendedChecksum: String + let expectedDestinationChecksum: String? + var restoresSourcePresence: Bool + + init( + data: Data, + destination: URL, + intendedLabel: String?, + intendedRecordID: AutomationRecord.ID?, + cronBinding: CronProposalBinding?, + cronRemovalBinding: CronRemovalBinding? = nil, + intendedChecksum: String, + expectedDestinationChecksum: String?, + restoresSourcePresence: Bool = true + ) { + self.data = data + self.destination = destination + self.intendedLabel = intendedLabel + self.intendedRecordID = intendedRecordID + self.cronBinding = cronBinding + self.cronRemovalBinding = cronRemovalBinding + self.intendedChecksum = intendedChecksum + self.expectedDestinationChecksum = expectedDestinationChecksum + self.restoresSourcePresence = restoresSourcePresence + } +} + +private struct CronProposalBinding { + let selectedRecordID: AutomationRecord.ID + let intendedRecordID: AutomationRecord.ID + let command: String + let schedule: AutomationSchedule + let environment: [String: String] + let enabledState: AutomationEnabledState +} + +private struct CronRemovalBinding { + let command: String + let schedule: AutomationSchedule + let environment: [String: String] + let enabledState: AutomationEnabledState + let remainingMatchingCount: Int +} + +private struct CronEntryFingerprint: Hashable { + let scheduleExpression: String + let command: String + let environment: [String] + let isEnabled: Bool +} + +private struct DestinationSnapshot { + let existed: Bool + let data: Data? + let metadata: AutomationFileMetadata + let parentMetadata: AutomationFileMetadata +} + +private struct ProposalError: Error { + let message: String + + init(_ message: String) { + self.message = message + } +} + +private extension AutomationSourceKind { + var requiresRecoverableSource: Bool { + self == .crontab || self == .legacyLoginItem + } +} + +private enum RollbackError: Error { + case integrityMismatch + case priorStateUnverified + case sourceChangedExternally +} + +private enum TransactionFailurePhase: Equatable { + case sourceMutationFailed + case executorAttempted + case verificationFailed +} diff --git a/Sources/DevScopeCore/AutomationModels.swift b/Sources/DevScopeCore/AutomationModels.swift new file mode 100644 index 0000000..26b685a --- /dev/null +++ b/Sources/DevScopeCore/AutomationModels.swift @@ -0,0 +1,254 @@ +import CryptoKit +import Darwin +import Foundation + +public enum AutomationKind: String, CaseIterable, Hashable, Sendable { + case launchAgent, launchDaemon, loginItem, backgroundItem, cron +} + +public enum AutomationSourceKind: String, CaseIterable, Hashable, Sendable { + case launchAgent, launchDaemon, serviceManagement, legacyLoginItem, crontab +} + +public enum AutomationOwnership: String, CaseIterable, Hashable, Sendable { + case user, thirdPartySystem, appleSystem, managed +} + +public enum AutomationState: String, CaseIterable, Hashable, Sendable { + case running, idle, disabled, needsApproval, invalid, unresolved +} + +public enum AutomationEnabledState: String, Hashable, Sendable { + case enabled, disabled, unknown +} + +public enum AutomationLoadState: String, Hashable, Sendable { + case loaded, unloaded, unknown +} + +public enum AutomationApprovalState: String, Sendable { + case approved, needsApproval, notApplicable, unknown +} + +public enum AutomationCapability: String, CaseIterable, Hashable, Sendable { + case startNow, stopCurrentRun, enable, disable, disableAndStop + case edit, duplicate, importRecord, exportRecord, remove, restore +} + +public struct AutomationSchedule: Equatable, Hashable, Sendable { + public enum Trigger: Equatable, Hashable, Sendable { + case atLogin + case runAtLoad + case interval(seconds: Int) + case calendar(String) + case keepAlive + case cron(String) + case demand + } + + public let triggers: [Trigger] + public let summary: String + + public init(triggers: [Trigger], summary: String) { + self.triggers = triggers + self.summary = summary + } +} + +public struct AutomationEvidence: Equatable, Hashable, Sendable { + public enum Strength: Int, Comparable, Sendable { + case weak = 0 + case strong = 1 + + public static func < (lhs: Self, rhs: Self) -> Bool { + lhs.rawValue < rhs.rawValue + } + } + + public let strength: Strength + public let source: String + public let detail: String + + public init(strength: Strength, source: String, detail: String) { + self.strength = strength + self.source = source + self.detail = detail + } +} + +public struct AutomationRecord: Identifiable, Equatable, Sendable { + public struct ID: RawRepresentable, Hashable, Sendable { + public let rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + public init( + source: AutomationSourceKind, + ownerUID: uid_t, + label: String, + sourcePath: String + ) { + let canonicalPath = URL(fileURLWithPath: sourcePath).standardizedFileURL.path + var identity = Data() + for component in [source.rawValue, String(ownerUID), label, canonicalPath] { + let bytes = Data(component.utf8) + let byteCount = UInt64(bytes.count) + for shift in stride(from: 56, through: 0, by: -8) { + identity.append(UInt8(truncatingIfNeeded: byteCount >> shift)) + } + identity.append(bytes) + } + rawValue = SHA256.hash(data: identity) + .map { String(format: "%02x", $0) } + .joined() + } + } + + public let id: ID + public let kind: AutomationKind + public let sourceKind: AutomationSourceKind + public let label: String + public let displayName: String + public let providerBundleIdentifier: String? + public let ownerUID: uid_t? + public let ownership: AutomationOwnership + public let executable: String? + public let arguments: [String] + public let commandSignature: String? + public let environment: [String: String] + public let workingDirectory: String? + public let schedule: AutomationSchedule + public let sourceURL: URL? + public let sourceChecksum: String? + public let enabledState: AutomationEnabledState + public let loadState: AutomationLoadState + public let approvalState: AutomationApprovalState + public let state: AutomationState + public let evidence: [AutomationEvidence] + public let capabilities: Set + public let validationFindings: [String] + + public init( + id: ID, + kind: AutomationKind, + sourceKind: AutomationSourceKind, + label: String, + displayName: String, + providerBundleIdentifier: String?, + ownerUID: uid_t?, + ownership: AutomationOwnership, + executable: String?, + arguments: [String], + commandSignature: String? = nil, + environment: [String: String], + workingDirectory: String?, + schedule: AutomationSchedule, + sourceURL: URL?, + sourceChecksum: String?, + enabledState: AutomationEnabledState, + loadState: AutomationLoadState, + approvalState: AutomationApprovalState, + state: AutomationState, + evidence: [AutomationEvidence], + capabilities: Set, + validationFindings: [String] + ) { + self.id = id + self.kind = kind + self.sourceKind = sourceKind + self.label = label + self.displayName = displayName + self.providerBundleIdentifier = providerBundleIdentifier + self.ownerUID = ownerUID + self.ownership = ownership + self.executable = executable + self.arguments = arguments + self.commandSignature = commandSignature + self.environment = environment + self.workingDirectory = workingDirectory + self.schedule = schedule + self.sourceURL = sourceURL + self.sourceChecksum = sourceChecksum + self.enabledState = enabledState + self.loadState = loadState + self.approvalState = approvalState + self.state = state + self.evidence = evidence + self.capabilities = capabilities + self.validationFindings = validationFindings + } +} + +public enum AutomationSourceHealthState: String, Sendable { + case healthy, partial, failed, permissionRequired +} + +public struct AutomationSourceHealth: Equatable, Sendable { + public let kind: AutomationSourceKind + public let state: AutomationSourceHealthState + public let message: String? + public let refreshedAt: Date + + public init( + kind: AutomationSourceKind, + state: AutomationSourceHealthState, + message: String?, + refreshedAt: Date + ) { + self.kind = kind + self.state = state + self.message = message + self.refreshedAt = refreshedAt + } +} + +public struct AutomationSourceSnapshot: Equatable, Sendable { + public let records: [AutomationRecord] + public let health: AutomationSourceHealth + + public init(records: [AutomationRecord], health: AutomationSourceHealth) { + self.records = records + self.health = health + } + + public static func healthy( + kind: AutomationSourceKind, + records: [AutomationRecord], + refreshedAt: Date = Date() + ) -> Self { + Self( + records: records, + health: AutomationSourceHealth( + kind: kind, + state: .healthy, + message: nil, + refreshedAt: refreshedAt + ) + ) + } + + public static func failed( + kind: AutomationSourceKind, + message: String, + refreshedAt: Date = Date() + ) -> Self { + Self( + records: [], + health: AutomationSourceHealth( + kind: kind, + state: .failed, + message: message, + refreshedAt: refreshedAt + ) + ) + } +} + +public enum AutomationParseError: Error, Equatable, Sendable { + case unreadablePropertyList + case missingLabel + case missingProgram + case invalidField(String) +} diff --git a/Sources/DevScopeCore/AutomationPresentation.swift b/Sources/DevScopeCore/AutomationPresentation.swift new file mode 100644 index 0000000..3df8349 --- /dev/null +++ b/Sources/DevScopeCore/AutomationPresentation.swift @@ -0,0 +1,1004 @@ +import Foundation + +public struct AutomationPresentationContext: Equatable, Sendable { + public static let defaultLongRunningThreshold: TimeInterval = 14_400 + + public let inventory: AutomationInventorySnapshot + public let longRunningThreshold: TimeInterval + + public init( + inventory: AutomationInventorySnapshot, + longRunningThreshold: TimeInterval + ) { + self.inventory = inventory + if longRunningThreshold.isFinite { + self.longRunningThreshold = max(0, longRunningThreshold) + } else { + self.longRunningThreshold = Self.defaultLongRunningThreshold + } + } + + public func build( + processes: [DevProcess], + now: Date, + calendar: Calendar = .current, + isProcessSnapshotComplete: Bool = true + ) -> AutomationPresentationSnapshot { + AutomationPresentationSnapshot.build( + inventory: inventory, + processes: processes, + longRunningThreshold: longRunningThreshold, + now: now, + calendar: calendar, + isProcessSnapshotComplete: isProcessSnapshotComplete + ) + } +} + +public struct AutomationPresentationSnapshot: Equatable, Sendable { + public let inventory: AutomationInventorySnapshot + public let linksByProcessID: [Int32: AutomationProcessLink] + public let allLinksByProcessID: [Int32: [AutomationProcessLink]] + public let longRunningProcessIDs: Set + public let longRunningProcessIdentities: Set + public let processIdentitiesByID: [Int32: ProcessIdentity] + public let isProcessSnapshotComplete: Bool + + public init( + inventory: AutomationInventorySnapshot, + linksByProcessID: [Int32: AutomationProcessLink], + allLinksByProcessID: [Int32: [AutomationProcessLink]]? = nil, + longRunningProcessIDs: Set, + longRunningProcessIdentities: Set, + processIdentitiesByID: [Int32: ProcessIdentity], + isProcessSnapshotComplete: Bool = true + ) { + self.inventory = inventory + self.linksByProcessID = linksByProcessID + self.allLinksByProcessID = allLinksByProcessID ?? linksByProcessID.mapValues { [$0] } + self.longRunningProcessIDs = longRunningProcessIDs + self.longRunningProcessIdentities = longRunningProcessIdentities + self.processIdentitiesByID = processIdentitiesByID + self.isProcessSnapshotComplete = isProcessSnapshotComplete + } + + public static func build( + inventory: AutomationInventorySnapshot, + processes: [DevProcess], + longRunningThreshold: TimeInterval, + now: Date, + calendar: Calendar = .current, + isProcessSnapshotComplete: Bool = true + ) -> Self { + let processes = ProcessSnapshotNormalization.newestUnambiguous(processes) + let links = AutomationProcessCorrelator.links( + records: inventory.records, + processes: processes, + now: now, + calendar: calendar + ) + let allLinksByProcessID = Dictionary(grouping: links, by: \.processIdentity.pid) + let linksByProcessID = allLinksByProcessID.compactMapValues(\.first) + let stronglyRunningRecordIDs = Set( + links.filter { $0.strength == .strong }.map(\.recordID) + ) + let projectedInventory = AutomationInventorySnapshot( + generation: inventory.generation, + records: inventory.records.map { record in + stronglyRunningRecordIDs.contains(record.id) + ? record.withRuntimeState(.running) + : record + }, + health: inventory.health, + refreshedAt: inventory.refreshedAt + ) + let longRunningIdentities = Set(processes.compactMap { process in + LongRunningAssessment.isLongRunning(process.resourceUsage, threshold: longRunningThreshold) + ? ProcessIdentity(process: process) : nil + }) + let longRunning = Set(longRunningIdentities.map(\.pid)) + let identities = Dictionary(uniqueKeysWithValues: + processes.map { ($0.pid, ProcessIdentity(process: $0)) } + ) + return Self( + inventory: projectedInventory, + linksByProcessID: linksByProcessID, + allLinksByProcessID: allLinksByProcessID, + longRunningProcessIDs: longRunning, + longRunningProcessIdentities: longRunningIdentities, + processIdentitiesByID: identities, + isProcessSnapshotComplete: isProcessSnapshotComplete + ) + } + +} + +private extension AutomationRecord { + func withRuntimeState(_ state: AutomationState) -> AutomationRecord { + AutomationRecord( + id: id, + kind: kind, + sourceKind: sourceKind, + label: label, + displayName: displayName, + providerBundleIdentifier: providerBundleIdentifier, + ownerUID: ownerUID, + ownership: ownership, + executable: executable, + arguments: arguments, + commandSignature: commandSignature, + environment: environment, + workingDirectory: workingDirectory, + schedule: schedule, + sourceURL: sourceURL, + sourceChecksum: sourceChecksum, + enabledState: enabledState, + loadState: loadState, + approvalState: approvalState, + state: state, + evidence: evidence, + capabilities: capabilities, + validationFindings: validationFindings + ) + } +} + +public enum AutomationProcessBadge: Equatable, Sendable { + case automated + case longRunning(String) +} + +public enum AutomationSourceFilter: String, CaseIterable, Hashable, Sendable { + case all, launchd, loginItems, scheduled +} + +public enum AutomationOwnershipFilter: String, CaseIterable, Hashable, Sendable { + case all, user, thirdParty, appleSystem + + public func matches(_ ownership: AutomationOwnership) -> Bool { + switch self { + case .all: true + case .user: ownership == .user + case .thirdParty: ownership == .thirdPartySystem || ownership == .managed + case .appleSystem: ownership == .appleSystem + } + } +} + +public enum AutomationActivityTypeFilter: String, CaseIterable, Hashable, Sendable { + case all, automated, longRunning, both + + public func matches(isAutomated: Bool, isLongRunning: Bool) -> Bool { + switch self { + case .all: + true + case .automated: + isAutomated + case .longRunning: + isLongRunning + case .both: + isAutomated && isLongRunning + } + } +} + +public struct AutomationInventoryCountPresentation: Equatable, Sendable { + public let primaryText: String + public let contextText: String? + public let accessibilityLabel: String +} + +public enum AutomationPresentation { + public static func inventoryCount( + visibleCount: Int, + eligibleCount: Int, + totalCount: Int + ) -> AutomationInventoryCountPresentation { + let totalCount = max(0, totalCount) + let eligibleCount = min(max(0, eligibleCount), totalCount) + let visibleCount = min(max(0, visibleCount), eligibleCount) + let automationNoun = eligibleCount == 1 ? "automation" : "automations" + let primaryText = visibleCount == eligibleCount + ? "\(eligibleCount) \(automationNoun)" + : "\(visibleCount) of \(eligibleCount) \(automationNoun)" + let hiddenCount = totalCount - eligibleCount + let contextText = hiddenCount > 0 + ? "\(hiddenCount) Apple system service\(hiddenCount == 1 ? "" : "s") hidden" + : nil + let accessibilityLabel = if let contextText { + "\(primaryText). \(contextText). " + + "In Settings, open Automations and enable Include Apple System Services to review them." + } else { + primaryText + } + return AutomationInventoryCountPresentation( + primaryText: primaryText, + contextText: contextText, + accessibilityLabel: accessibilityLabel + ) + } + + public static func uniquelyStrongLinkedProcessIdentities( + for recordID: AutomationRecord.ID, + linksByProcessID: [Int32: [AutomationProcessLink]] + ) -> Set { + Set(linksByProcessID.values.compactMap { links in + let strongLinks = links.filter { $0.strength == .strong } + let strongRecordIDs = Set(strongLinks.map(\.recordID)) + let identities = Set(strongLinks.map(\.processIdentity)) + guard strongRecordIDs == [recordID], identities.count == 1 else { return nil } + return identities.first + }) + } + + public static func resolvedSelection( + current: AutomationRecord.ID?, + visibleIDs: [AutomationRecord.ID] + ) -> AutomationRecord.ID? { + guard let current, visibleIDs.contains(current) else { return visibleIDs.first } + return current + } + + public static func filtered( + _ records: [AutomationRecord], + source: AutomationSourceFilter, + state: AutomationState?, + ownership: AutomationOwnershipFilter, + searchText: String, + includeAppleSystemServices: Bool + ) -> [AutomationRecord] { + let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return records.filter { record in + guard includeAppleSystemServices || record.ownership != .appleSystem, + state == nil || record.state == state, + ownership.matches(record.ownership), + sourceMatches(record.sourceKind, filter: source) else { return false } + guard !query.isEmpty else { return true } + return [ + record.label, record.displayName, record.executable ?? "", + record.arguments.joined(separator: " "), record.sourceURL?.path ?? "", + record.providerBundleIdentifier ?? "", record.schedule.summary, + ].contains { $0.lowercased().contains(query) } + }.sorted(by: recordPrecedes) + } + + public static func badges( + isAutomated: Bool, + isLongRunning: Bool, + elapsed: String + ) -> [AutomationProcessBadge] { + var result: [AutomationProcessBadge] = [] + if isAutomated { result.append(.automated) } + if isLongRunning { result.append(.longRunning(compactElapsed(elapsed))) } + return result + } + + private static func sourceMatches( + _ kind: AutomationSourceKind, + filter: AutomationSourceFilter + ) -> Bool { + switch filter { + case .all: true + case .launchd: kind == .launchAgent || kind == .launchDaemon + case .loginItems: kind == .serviceManagement || kind == .legacyLoginItem + case .scheduled: kind == .crontab + } + } + + private static func recordPrecedes(_ lhs: AutomationRecord, _ rhs: AutomationRecord) -> Bool { + if lhs.label != rhs.label { + return lhs.label.utf8.lexicographicallyPrecedes(rhs.label.utf8) + } + return lhs.id.rawValue.utf8.lexicographicallyPrecedes(rhs.id.rawValue.utf8) + } + + private static func compactElapsed(_ elapsed: String) -> String { + let seconds = ProcessPresentation.elapsedSeconds(elapsed) + guard seconds >= 0 else { return elapsed } + if seconds >= 86_400 { return "\(seconds / 86_400)d" } + if seconds >= 3_600 { return "\(seconds / 3_600)h" } + if seconds >= 60 { return "\(seconds / 60)m" } + return "\(seconds)s" + } +} + +public enum AutomationManagementAction: Equatable, Hashable, Sendable { + case startNow + case stopCurrentRun + case enable + case disable + case disableAndStop + case edit + case duplicate + case importRecord + case exportRedacted + case exportUnredacted + case remove + case restore(AutomationBackup.ID) + + public var title: String { + switch self { + case .startNow: "Start Now" + case .stopCurrentRun: "Stop Current Run" + case .enable: "Enable" + case .disable: "Disable" + case .disableAndStop: "Disable and Stop" + case .edit: "Edit" + case .duplicate: "Duplicate" + case .importRecord: "Import" + case .exportRedacted: "Export Redacted" + case .exportUnredacted: "Export Unredacted" + case .remove: "Remove" + case .restore: "Restore" + } + } + + public var isDestructive: Bool { + switch self { + case .stopCurrentRun, .disableAndStop, .remove: + true + default: + false + } + } + + public var placement: AutomationManagementActionPlacement { + switch self { + case .startNow, .stopCurrentRun, .enable, .disable, .disableAndStop: + .pinned + case .restore: + .history + case .edit, .duplicate, .importRecord, .exportRedacted, .exportUnredacted, .remove: + .overflow + } + } + + public var emphasis: AutomationManagementActionEmphasis { + switch self { + case .startNow, .enable: + .positive + case .disable, .exportUnredacted: + .caution + case .stopCurrentRun, .disableAndStop, .remove: + .destructive + case .edit, .duplicate, .importRecord, .exportRedacted, .restore: + .neutral + } + } + + public var systemImage: String { + switch self { + case .startNow: "play.fill" + case .stopCurrentRun: "stop.fill" + case .enable: "checkmark.circle" + case .disable: "pause.circle" + case .disableAndStop: "stop.circle.fill" + case .edit: "square.and.pencil" + case .duplicate: "plus.square.on.square" + case .importRecord: "square.and.arrow.down" + case .exportRedacted: "square.and.arrow.up" + case .exportUnredacted: "lock.open.fill" + case .remove: "trash" + case .restore: "clock.arrow.circlepath" + } + } +} + +public enum AutomationManagementActionPlacement: Equatable, Sendable { + case pinned + case overflow + case history +} + +public enum AutomationManagementActionEmphasis: Equatable, Sendable { + case neutral + case positive + case caution + case destructive +} + +public struct AutomationConfirmationPolicy: Equatable, Sendable { + public let title: String + public let consequence: String + public let requiredLabel: String? + public let displayedCommand: String? + + public init( + title: String, + consequence: String, + requiredLabel: String? = nil, + displayedCommand: String? = nil + ) { + self.title = title + self.consequence = consequence + self.requiredLabel = requiredLabel + self.displayedCommand = displayedCommand + } + + public func isSatisfiedByLabel(_ candidate: String) -> Bool { + guard let requiredLabel else { return true } + return candidate == requiredLabel + } +} + +public struct AutomationOperationResultPresentation: Equatable, Sendable { + public let title: String + public let detail: String + public let appliedEvidence: [String] + public let verificationEvidence: [String] + public let rollbackEvidence: String + public let backupEvidence: String? + public let mutationEvidence: [String] + public let recoveryGuidance: String? + public let isFailure: Bool + + public init( + title: String, + detail: String, + appliedEvidence: [String], + verificationEvidence: [String], + rollbackEvidence: String, + backupEvidence: String?, + mutationEvidence: [String], + recoveryGuidance: String?, + isFailure: Bool + ) { + self.title = title + self.detail = detail + self.appliedEvidence = appliedEvidence + self.verificationEvidence = verificationEvidence + self.rollbackEvidence = rollbackEvidence + self.backupEvidence = backupEvidence + self.mutationEvidence = mutationEvidence + self.recoveryGuidance = recoveryGuidance + self.isFailure = isFailure + } +} + +public enum AutomationManagementPresentation { + public static func actions( + decision: AutomationCapabilityDecision, + backups: [AutomationBackup], + record: AutomationRecord + ) -> [AutomationManagementAction] { + let capabilities = decision.capabilities + var result: [AutomationManagementAction] = [] + if capabilities.contains(.startNow) { result.append(.startNow) } + if capabilities.contains(.stopCurrentRun) { result.append(.stopCurrentRun) } + if capabilities.contains(.enable) { result.append(.enable) } + if capabilities.contains(.disable) { result.append(.disable) } + if capabilities.contains(.disableAndStop) { result.append(.disableAndStop) } + if capabilities.contains(.edit) { result.append(.edit) } + if capabilities.contains(.duplicate) { result.append(.duplicate) } + if capabilities.contains(.importRecord) { result.append(.importRecord) } + if capabilities.contains(.exportRecord) { + result.append(.exportRedacted) + if record.ownership == .user, + record.kind != .launchDaemon, + record.kind != .backgroundItem, + record.sourceKind != .launchDaemon, + record.sourceKind != .serviceManagement { + result.append(.exportUnredacted) + } + } + if capabilities.contains(.remove) { result.append(.remove) } + if capabilities.contains(.restore) { + result.append(contentsOf: backups + .filter { $0.recordID == record.id } + .sorted { $0.createdAt > $1.createdAt } + .map { .restore($0.id) }) + } + return result + } + + public static func helpText( + for action: AutomationManagementAction, + record: AutomationRecord + ) -> String { + switch action { + case .startNow: + "Request this automation to run now" + case .stopCurrentRun where isLaunchAgent(record): + "Unload the exact launchd service after confirmation" + case .stopCurrentRun: + "Stop strongly linked current processes after confirmation" + case .enable: + "Allow future launches after confirmation" + case .disable: + "Prevent future launches after confirmation" + case .disableAndStop where isLaunchAgent(record): + "Prevent future launches and unload the exact launchd service" + case .disableAndStop: + "Prevent future launches and stop strongly linked current processes" + case .edit: + "Edit this user-owned automation through validated fields" + case .duplicate: + "Create a disabled copy with a distinct label" + case .importRecord: + "Validate and preview an automation file before importing" + case .exportRedacted: + "Export a secret-redacted inspection artifact" + case .exportUnredacted: + "Explicitly request an unredacted export when policy allows" + case .remove: + "Remove through a recoverable transaction after exact-label confirmation" + case .restore: + "Restore this concrete verified backup" + } + } + + public static func confirmation( + for action: AutomationManagementAction, + record: AutomationRecord, + backups: [AutomationBackup] = [] + ) -> AutomationConfirmationPolicy? { + switch action { + case .startNow where record.kind == .cron: + return AutomationConfirmationPolicy( + title: "Run this scheduled command now?", + consequence: "Runs this cron command to completion outside its normal schedule.", + displayedCommand: record.commandSignature.map(ProcessPresentation.redactedCommand) + ) + case .startNow: + return AutomationConfirmationPolicy( + title: "Start this automation now?", + consequence: "Requests an immediate launch. This does not change whether future launches are enabled." + ) + case .stopCurrentRun: + if record.kind == .launchAgent, record.sourceKind == .launchAgent { + return AutomationConfirmationPolicy( + title: "Stop the current run?", + consequence: "Unloads the exact launchd service. " + + "This does not disable future launches; Start Now or Enable can load it again." + ) + } + return AutomationConfirmationPolicy( + title: "Stop the current run?", + consequence: "Stops only strongly linked, birth-validated processes. " + + "A scheduled or supervising automation may launch again." + ) + case .enable: + return AutomationConfirmationPolicy( + title: "Enable future launches?", + consequence: "Allows future launches. This does not start a run now." + ) + case .disable: + return AutomationConfirmationPolicy( + title: "Disable future launches?", + consequence: "Prevents future launches. This does not stop a process that is already running." + ) + case .disableAndStop where isLaunchAgent(record): + return AutomationConfirmationPolicy( + title: "Disable and stop this automation?", + consequence: "Prevents future launches and unloads the exact launchd service." + ) + case .disableAndStop: + return AutomationConfirmationPolicy( + title: "Disable and stop this automation?", + consequence: "Prevents future launches and stops only strongly linked current processes." + ) + case .remove: + return AutomationConfirmationPolicy( + title: "Remove this automation?", + consequence: "Unloads it where applicable, removes its source through a recoverable transaction, and writes a restoration manifest.", + requiredLabel: record.label + ) + case .exportUnredacted: + return AutomationConfirmationPolicy( + title: "Export unredacted source?", + consequence: "The exported file may contain commands, paths, environment values, credentials, or other secrets. DevScope will not log its contents." + ) + case .restore(let backupID): + guard let backup = backups.first(where: { $0.id == backupID && $0.recordID == record.id }) else { + return nil + } + return AutomationConfirmationPolicy( + title: "Restore this verified backup?", + consequence: "Replaces the current definition with the verified backup from \(backup.createdAt.formatted(date: .abbreviated, time: .shortened)) after conflict and integrity checks." + ) + default: + return nil + } + } + + private static func isLaunchAgent(_ record: AutomationRecord) -> Bool { + record.kind == .launchAgent && record.sourceKind == .launchAgent + } + + public static func confirmedOperation( + for action: AutomationManagementAction, + record: AutomationRecord + ) -> AutomationOperation? { + switch action { + case .startNow where record.kind == .cron: + guard let exactCommand = record.commandSignature else { return nil } + return .confirmedRunToCompletion(AutomationRunToCompletionConfirmation( + recordID: record.id, + sourceChecksum: record.sourceChecksum, + exactCommand: exactCommand + )) + case .startNow: return AutomationOperation.startNow + case .stopCurrentRun: return AutomationOperation.stopCurrentRun + case .enable: return AutomationOperation.enable + case .disable: return AutomationOperation.disable + case .disableAndStop: return AutomationOperation.disableAndStop + case .exportRedacted: return AutomationOperation.exportRecord(redacted: true) + case .exportUnredacted: return AutomationOperation.exportRecord(redacted: false) + case .remove: return AutomationOperation.remove + case .restore(let backupID): return AutomationOperation.restore(backupID) + case .edit, .duplicate, .importRecord: return nil + } + } + + public static func result( + _ result: AutomationOperationResult + ) -> AutomationOperationResultPresentation { + let title: String + let detail: String + let isFailure: Bool + switch result.status { + case .succeeded: + title = "Completed" + detail = "The operation completed and DevScope refreshed the automation inventory." + isFailure = false + case .rejected(let reason): + title = "Not performed" + detail = ProcessPresentation.redactedCommand(reason) + isFailure = true + case .failed(let reason): + title = "Operation failed" + detail = ProcessPresentation.redactedCommand(reason) + isFailure = true + case .partialFailure(let reason): + title = "Partially completed" + detail = ProcessPresentation.redactedCommand(reason) + isFailure = true + } + + var recovery: [String] = [] + if let manualRecovery = result.manualRecovery { + recovery.append(ProcessPresentation.redactedCommand(manualRecovery)) + } + if case .failed(let reason) = result.rollback { + recovery.append("Rollback failed: \(ProcessPresentation.redactedCommand(reason))") + } + var recoveryPaths: Set = [] + for evidence in [result.fileMutationEvidence, result.rollbackFileMutationEvidence].compactMap({ $0 }) { + for handle in evidence.recoveryHandles { + let path = handle.fileURL.standardizedFileURL.path + if recoveryPaths.insert(path).inserted { + recovery.append("Recovery location: \(path)") + } + } + } + let rollbackEvidence: String + switch result.rollback { + case .notNeeded: + rollbackEvidence = "Rollback was not required." + case .restored(let backupID): + rollbackEvidence = "Rollback restored verified backup \(backupID.rawValue.uuidString.prefix(8))." + case .failed(let reason): + rollbackEvidence = "Rollback failed: \(ProcessPresentation.redactedCommand(reason))" + } + let backupEvidence = result.backup.map { + "Recovery backup verified · \($0.checksum.prefix(12)) · \($0.createdAt.formatted(date: .abbreviated, time: .shortened))" + } + var mutationEvidence: [String] = [] + if let evidence = result.fileMutationEvidence { + mutationEvidence.append(mutationSummary(evidence, subject: "Source")) + } + if let evidence = result.rollbackFileMutationEvidence { + mutationEvidence.append(mutationSummary(evidence, subject: "Rollback")) + } + return AutomationOperationResultPresentation( + title: title, + detail: detail, + appliedEvidence: result.appliedSteps.map(ProcessPresentation.redactedCommand), + verificationEvidence: result.verificationEvidence.map(ProcessPresentation.redactedCommand), + rollbackEvidence: rollbackEvidence, + backupEvidence: backupEvidence, + mutationEvidence: mutationEvidence, + recoveryGuidance: recovery.isEmpty ? nil : recovery.joined(separator: "\n"), + isFailure: isFailure + ) + } + + private static func mutationSummary( + _ evidence: AutomationFilePartialMutation, + subject: String + ) -> String { + let mutation: String + switch evidence.kind { + case .replace: mutation = "replacement" + case .remove: mutation = "removal" + case .trash: mutation = "trash move" + } + let state = evidence.commitState == .committed ? "committed" : "outcome is unknown" + return "\(subject) \(mutation) \(state); \(evidence.observedFiles.count) observed files and \(evidence.recoveryHandles.count) recovery handles were retained." + } +} + +public enum AutomationEditorPresentation { + public static func environmentText(for environment: [String: String]) -> String { + environment.keys.sorted().compactMap { key in + environment[key].map { "\(key)=\($0)" } + }.joined(separator: "\n") + } + + public static func environment(from text: String) -> [String: String]? { + var environment: [String: String] = [:] + for rawLine in text.split(whereSeparator: \.isNewline) { + let line = String(rawLine) + guard let separator = line.firstIndex(of: "=") else { return nil } + let key = String(line[.. String { + schedule.triggers.map { trigger in + switch trigger { + case .atLogin: "run-at-load" + case .runAtLoad: "run-at-load" + case .interval(let seconds): "interval \(seconds)" + case .calendar(let summary): "calendar \(summary)" + case .keepAlive: "keep-alive" + case .cron(let expression): "cron \(expression)" + case .demand: "on-demand" + } + }.joined(separator: "\n") + } + + public static func schedule(from text: String) -> AutomationSchedule? { + let lines = text.split(whereSeparator: \.isNewline).map { + String($0).trimmingCharacters(in: .whitespaces) + }.filter { !$0.isEmpty } + guard !lines.isEmpty else { return nil } + var triggers: [AutomationSchedule.Trigger] = [] + for line in lines { + switch line { + case "at-login": + triggers.append(.runAtLoad) + case "run-at-load": + triggers.append(.runAtLoad) + case "keep-alive": + triggers.append(.keepAlive) + case "on-demand": + triggers.append(.demand) + default: + if line.hasPrefix("interval "), + let seconds = Int(line.dropFirst("interval ".count)), + seconds > 0 { + triggers.append(.interval(seconds: seconds)) + } else if line.hasPrefix("calendar ") { + let summary = String(line.dropFirst("calendar ".count)) + guard !summary.isEmpty else { return nil } + triggers.append(.calendar(summary)) + } else if line.hasPrefix("cron ") { + let expression = String(line.dropFirst("cron ".count)) + guard !expression.isEmpty else { return nil } + triggers.append(.cron(expression)) + } else { + return nil + } + } + } + guard Set(triggers).count == triggers.count else { return nil } + if triggers.contains(.demand) { + guard triggers == [.demand] else { return nil } + return AutomationSchedule(triggers: triggers, summary: "On demand") + } + if triggers.contains(where: { + if case .cron = $0 { return true } + return false + }) { + guard triggers.count == 1, + case .cron(let expression) = triggers[0] else { return nil } + return AutomationSchedule(triggers: triggers, summary: expression) + } + guard triggers.filter({ + if case .interval = $0 { return true } + return false + }).count <= 1 else { return nil } + + if triggers.contains(.keepAlive), !triggers.contains(.runAtLoad) { + triggers.append(.runAtLoad) + } + var canonical: [AutomationSchedule.Trigger] = [] + if triggers.contains(.runAtLoad) { canonical.append(.runAtLoad) } + if triggers.contains(.keepAlive) { canonical.append(.keepAlive) } + canonical.append(contentsOf: triggers.filter { + if case .interval = $0 { return true } + return false + }) + canonical.append(contentsOf: triggers.filter { + if case .calendar = $0 { return true } + return false + }) + guard canonical.count == triggers.count else { return nil } + let summaries = canonical.map { triggerSummary($0) } + return AutomationSchedule(triggers: canonical, summary: summaries.joined(separator: ", ")) + } + + private static func triggerSummary(_ trigger: AutomationSchedule.Trigger) -> String { + switch trigger { + case .atLogin, .runAtLoad: "At load" + case .keepAlive: "Keep alive" + case .interval(let seconds): "Every \(seconds) seconds" + case .calendar(let summary): summary + case .cron(let expression): expression + case .demand: "On demand" + } + } + + public static func validationMessage( + record: AutomationRecord, + purposeIsDuplicate: Bool, + label: String, + executable: String, + arguments: [String], + environment: [String: String], + schedule: AutomationSchedule, + usesRawRepresentation: Bool, + rawData: Data?, + duplicateDestination: URL?, + workingDirectory: String? = nil + ) -> String? { + guard !label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return "A label is required." + } + guard !executable.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return "An executable is required." + } + guard executable.hasPrefix("/"), !executable.contains("\0") else { + return "The executable must be an absolute path without null characters." + } + if usesRawRepresentation { + guard let rawData, !rawData.isEmpty else { + return "Paste a raw representation before applying." + } + if record.kind == .launchAgent || record.kind == .launchDaemon { + guard let plist = try? PropertyListSerialization.propertyList(from: rawData, format: nil) as? [String: Any] else { + return "The raw property list is not valid." + } + guard plist["Label"] as? String == label else { + return "The raw property-list label does not match the reviewed label." + } + let rawExecutable: String? + let rawArguments: [String] + if let program = plist["Program"] as? String { + rawExecutable = program + rawArguments = plist["ProgramArguments"] as? [String] ?? [] + } else if let programArguments = plist["ProgramArguments"] as? [String], + let first = programArguments.first { + rawExecutable = first + rawArguments = Array(programArguments.dropFirst()) + } else { + rawExecutable = nil + rawArguments = [] + } + guard rawExecutable == executable, rawArguments == arguments else { + return "The raw property-list command does not match the reviewed executable and arguments." + } + guard let parsed = try? LaunchdPlistParser.parse( + data: rawData, + sourceURL: record.sourceURL ?? URL(fileURLWithPath: "/tmp/devscope-editor.plist"), + ownerUID: record.ownerUID ?? 0, + ownership: record.ownership + ), + (plist["EnvironmentVariables"] as? [String: String] ?? [:]) == environment, + parsed.schedule.triggers == schedule.triggers, + plist["WorkingDirectory"] as? String == workingDirectory + else { + return "The raw property-list environment, schedule, or working directory does not match the reviewed fields." + } + } else if record.kind == .cron { + let document = CronParser.parse(String(decoding: rawData, as: UTF8.self)) + guard document.invalidLines.isEmpty else { + return "The raw crontab contains invalid lines." + } + let reviewedCommand = ([executable] + arguments).joined(separator: " ") + let matchingReviewedEntries = document.entries.filter { + $0.command == reviewedCommand + && $0.schedule.triggers == schedule.triggers + && $0.environment == environment + } + guard !matchingReviewedEntries.isEmpty else { + return "The raw crontab does not contain the reviewed command, schedule, and environment." + } + if purposeIsDuplicate { + let originalCount = document.entries.filter { + $0.command == record.commandSignature + && $0.schedule.triggers == record.schedule.triggers + && $0.environment == record.environment + }.count + let requiredCount = record.commandSignature == reviewedCommand + && record.schedule.triggers == schedule.triggers + && record.environment == environment ? 2 : 1 + guard originalCount >= 1, matchingReviewedEntries.count >= requiredCount else { + return "The duplicate crontab must retain the selected entry and add one reviewed entry." + } + } + } + } + if purposeIsDuplicate && record.kind == .cron && !usesRawRepresentation { + return "Cron duplication requires an advanced raw crontab containing the existing entries and one reviewed new entry." + } + if record.kind == .cron && !usesRawRepresentation { + return "Cron edits require an advanced raw crontab containing the complete current-user document." + } + if !usesRawRepresentation, + schedule.triggers.contains(where: { + if case .calendar = $0 { return true } + if case .cron = $0 { return true } + return false + }) { + return "Calendar and cron schedules require an advanced raw source representation." + } + if purposeIsDuplicate && duplicateDestination == nil { + return "Choose a label that resolves to a distinct approved destination." + } + return nil + } +} + +public struct AutomationImportPresentation: Equatable, Sendable { + public let expectedKind: AutomationKind + public let destination: URL + public let targetLabel: String + public let targetOwnership: String + public let consequence: String + public let summary: String + public let validationMessage: String? + + public init( + data: Data, + expectedRecord: AutomationRecord, + destination: URL + ) { + expectedKind = expectedRecord.kind + self.destination = destination + targetLabel = expectedRecord.label + switch expectedRecord.ownership { + case .user: targetOwnership = "User owned" + case .thirdPartySystem: targetOwnership = "Third party" + case .appleSystem: targetOwnership = "Apple system" + case .managed: targetOwnership = "Managed" + } + consequence = "This import will replace \(expectedRecord.label) at its checksum-verified destination after ownership and kind are revalidated." + switch expectedRecord.kind { + case .launchAgent, .launchDaemon: + if let plist = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any], + let label = plist["Label"] as? String, + !label.isEmpty { + summary = "\(expectedRecord.kind.rawValue): \(label)" + validationMessage = nil + } else { + summary = expectedRecord.kind.rawValue + validationMessage = "The selected file is not a labeled launchd property list." + } + case .cron: + let document = CronParser.parse(String(decoding: data, as: UTF8.self)) + summary = "Current-user crontab · \(document.entries.count) entries" + if !document.invalidLines.isEmpty { + validationMessage = "The selected crontab contains invalid lines." + } else if !document.entries.contains(where: { $0.command == expectedRecord.commandSignature }) { + validationMessage = "The selected crontab does not contain the reviewed automation command." + } else { + validationMessage = nil + } + case .loginItem, .backgroundItem: + summary = expectedRecord.kind.rawValue + validationMessage = "This automation kind does not support file import." + } + } + + public var canApply: Bool { validationMessage == nil } +} diff --git a/Sources/DevScopeCore/AutomationProcessCorrelator.swift b/Sources/DevScopeCore/AutomationProcessCorrelator.swift new file mode 100644 index 0000000..69b3de9 --- /dev/null +++ b/Sources/DevScopeCore/AutomationProcessCorrelator.swift @@ -0,0 +1,388 @@ +import Foundation + +public struct AutomationProcessLink: Equatable, Sendable { + public let recordID: AutomationRecord.ID + public let processIdentity: ProcessIdentity + public let strength: AutomationEvidence.Strength + public let evidence: [AutomationEvidence] + + public init( + recordID: AutomationRecord.ID, + processIdentity: ProcessIdentity, + strength: AutomationEvidence.Strength, + evidence: [AutomationEvidence] + ) { + self.recordID = recordID + self.processIdentity = processIdentity + self.strength = strength + self.evidence = evidence + } +} + +struct AutomationCorrelationWork: Equatable { + var indexedProcesses = 0 + var ancestrySteps = 0 + var cronRecordLookups = 0 + var cronCandidateEvaluations = 0 + var nonCronRecordLookups = 0 + var nonCronCandidateEvaluations = 0 +} + +public enum AutomationProcessCorrelator { + public static func links( + records: [AutomationRecord], + processes: [DevProcess], + now: Date, + calendar: Calendar = .current + ) -> [AutomationProcessLink] { + var work = AutomationCorrelationWork() + return links(records: records, processes: processes, now: now, calendar: calendar, work: &work) + } + + static func links( + records: [AutomationRecord], + processes: [DevProcess], + now: Date, + calendar: Calendar = .current, + work: inout AutomationCorrelationWork + ) -> [AutomationProcessLink] { + let bornProcesses = ProcessSnapshotNormalization.newestUnambiguous(processes) + .filter { $0.birthToken != nil } + work.indexedProcesses += bornProcesses.count + let nonCronProcesses = nonCronIndex(processes: bornProcesses) + let ambiguousNilLabelProvenance = ambiguousNilLabelProvenance(in: records) + let processesByPID = Dictionary(uniqueKeysWithValues: bornProcesses.map { ($0.pid, $0) }) + let cronLineage = cronLineageByPID(processesByPID: processesByPID, work: &work) + let cronCarriers = Dictionary(grouping: bornProcesses.compactMap { process -> (CronKey, DevProcess)? in + guard let argv = process.argumentVector, argv.count == 3, argv[1] == "-c", + canonicalPath(argv[0]) == canonicalPath(process.executable) else { return nil } + return (CronKey(shell: argv[0], signature: argv[2]), process) + }, by: \.0).mapValues { $0.map(\.1) } + var result: [AutomationProcessLink] = [] + + for record in records { + var matchedByPID: [Int32: (DevProcess, String)] = [:] + + if record.kind == .cron { + guard let signature = nonempty(record.commandSignature), + record.enabledState == .enabled else { continue } + let shell = nonempty(record.environment["SHELL"]) ?? "/bin/sh" + work.cronRecordLookups += 1 + for process in cronCarriers[CronKey(shell: shell, signature: signature)] ?? [] { + work.cronCandidateEvaluations += 1 + guard process.argumentVector == [shell, "-c", signature], + cronLineage[process.parentPID] == true, + startMatchesSchedule(process, schedule: record.schedule, now: now, calendar: calendar) + else { continue } + matchedByPID[process.pid] = (process, "Exact cron carrier argv, schedule minute, and ancestry") + } + } else { + let allowsNilLaunchLabel = nilLabelProvenanceKey(for: record).map { + !ambiguousNilLabelProvenance.contains($0) + } ?? true + if let executable = canonicalPath(record.executable) { + work.nonCronRecordLookups += 1 + appendNonCronMatches( + identity: .executable(executable), record: record, + index: nonCronProcesses, allowsNilLaunchLabel: allowsNilLaunchLabel, + work: &work, matches: &matchedByPID + ) + } + if let bundleIdentifier = nonempty(record.providerBundleIdentifier) { + work.nonCronRecordLookups += 1 + appendNonCronMatches( + identity: .bundle(bundleIdentifier), record: record, + index: nonCronProcesses, allowsNilLaunchLabel: allowsNilLaunchLabel, + work: &work, matches: &matchedByPID + ) + } + } + + for (_, match) in matchedByPID.sorted(by: { $0.key < $1.key }) { + result.append(AutomationProcessLink( + recordID: record.id, + processIdentity: ProcessIdentity(process: match.0), + strength: .strong, + evidence: [AutomationEvidence( + strength: .strong, + source: "process snapshot", + detail: match.1 + )] + )) + } + } + + return result.sorted(by: linkPrecedes) + } + + private enum NonCronIdentity: Hashable { + case executable(String) + case bundle(String) + + var evidenceName: String { + switch self { + case .executable: "executable" + case .bundle: "bundle identity" + } + } + } + + private struct NonCronKey: Hashable { + let identity: NonCronIdentity + let arguments: [String] + let launchLabel: String? + } + + private struct NilLabelProvenanceKey: Hashable { + let executable: String + let arguments: [String] + } + + private static func ambiguousNilLabelProvenance( + in records: [AutomationRecord] + ) -> Set { + var labelsByKey: [NilLabelProvenanceKey: Set] = [:] + for record in records where record.kind != .cron { + guard let key = nilLabelProvenanceKey(for: record) else { continue } + labelsByKey[key, default: []].insert(record.label) + } + return Set(labelsByKey.compactMap { key, labels in labels.count > 1 ? key : nil }) + } + + private static func nilLabelProvenanceKey( + for record: AutomationRecord + ) -> NilLabelProvenanceKey? { + guard let executable = canonicalPath(record.executable) else { return nil } + return NilLabelProvenanceKey(executable: executable, arguments: record.arguments) + } + + private static func nonCronIndex(processes: [DevProcess]) -> [NonCronKey: [DevProcess]] { + var result: [NonCronKey: [DevProcess]] = [:] + for process in processes { + guard let argumentVector = process.argumentVector, + let executable = canonicalPath(process.executable), + argumentVector.first.flatMap(canonicalPath) == executable else { continue } + let arguments = Array(argumentVector.dropFirst()) + var identities: [NonCronIdentity] = [.executable(executable)] + if let bundleIdentifier = nonempty(process.bundleIdentifier) { + identities.append(.bundle(bundleIdentifier)) + } + for identity in identities { + let key = NonCronKey( + identity: identity, + arguments: arguments, + launchLabel: process.launchLabel + ) + result[key, default: []].append(process) + } + } + return result + } + + private static func appendNonCronMatches( + identity: NonCronIdentity, + record: AutomationRecord, + index: [NonCronKey: [DevProcess]], + allowsNilLaunchLabel: Bool, + work: inout AutomationCorrelationWork, + matches: inout [Int32: (DevProcess, String)] + ) { + let launchLabels: [String?] = allowsNilLaunchLabel ? [record.label, nil] : [record.label] + for launchLabel in launchLabels { + let key = NonCronKey( + identity: identity, + arguments: record.arguments, + launchLabel: launchLabel + ) + for process in index[key] ?? [] { + work.nonCronCandidateEvaluations += 1 + guard hasCompatibleLaunchProvenance(process, record: record) else { continue } + matches[process.pid] = ( + process, nonCronEvidenceDetail(identity: identity.evidenceName, process: process) + ) + } + } + } + + private struct CronKey: Hashable { + let shell: String + let signature: String + } + + private static func cronLineageByPID( + processesByPID: [Int32: DevProcess], + work: inout AutomationCorrelationWork + ) -> [Int32: Bool] { + var result: [Int32: Bool] = [:] + for startPID in processesByPID.keys.sorted() where result[startPID] == nil { + var path: [Int32] = [] + var pathSet = Set() + var cursor = startPID + var resolved = false + while let process = processesByPID[cursor] { + work.ancestrySteps += 1 + if let cached = result[cursor] { + resolved = cached + break + } + guard pathSet.insert(cursor).inserted else { + resolved = false + break + } + path.append(cursor) + if canonicalPath(process.executable) == "/usr/sbin/cron" { + resolved = true + break + } + cursor = process.parentPID + } + for processID in path.reversed() { result[processID] = resolved } + } + return result + } + + private static func linkPrecedes(_ lhs: AutomationProcessLink, _ rhs: AutomationProcessLink) -> Bool { + if lhs.processIdentity.pid != rhs.processIdentity.pid { + return lhs.processIdentity.pid < rhs.processIdentity.pid + } + let lhsBirth = lhs.processIdentity.birthToken.map { ($0.seconds, $0.microseconds) } ?? (0, 0) + let rhsBirth = rhs.processIdentity.birthToken.map { ($0.seconds, $0.microseconds) } ?? (0, 0) + if lhsBirth != rhsBirth { return lhsBirth < rhsBirth } + return lhs.recordID.rawValue.utf8.lexicographicallyPrecedes(rhs.recordID.rawValue.utf8) + } + + private static func hasCompatibleLaunchProvenance( + _ process: DevProcess, + record: AutomationRecord + ) -> Bool { + if let launchLabel = process.launchLabel { + return launchLabel == record.label + } + // Direct PID 1 parentage is conservative launchd-compatibility evidence only. + // It is not a claim that the process was launched under record.label. + return process.parentPID == 1 + } + + private static func nonCronEvidenceDetail(identity: String, process: DevProcess) -> String { + if process.launchLabel != nil { + return "Exact \(identity), arguments, and launch label" + } + return "Exact \(identity) and arguments with direct PID 1 compatibility" + } + + private static func startMatchesSchedule( + _ process: DevProcess, + schedule: AutomationSchedule, + now: Date, + calendar: Calendar + ) -> Bool { + guard let elapsed = process.resourceUsage.map({ ProcessPresentation.elapsedSeconds($0.elapsedTime) }), + elapsed >= 0 else { return false } + let start = now.addingTimeInterval(-TimeInterval(elapsed)) + return schedule.triggers.contains { trigger in + guard case let .cron(expression) = trigger else { return false } + return cronExpression(expression, matches: start, calendar: calendar) + } + } + + private static func cronExpression( + _ expression: String, + matches date: Date, + calendar: Calendar + ) -> Bool { + let expanded: String + switch expression { + case "@hourly": expanded = "0 * * * *" + case "@daily": expanded = "0 0 * * *" + case "@weekly": expanded = "0 0 * * 0" + case "@monthly": expanded = "0 0 1 * *" + case "@yearly": expanded = "0 0 1 1 *" + case "@reboot": return false + default: expanded = expression + } + let fields = expanded.split(whereSeparator: \.isWhitespace).map(String.init) + guard fields.count == 5 else { return false } + let components = calendar.dateComponents([.minute, .hour, .day, .month, .weekday], from: date) + guard let minute = components.minute, let hour = components.hour, let day = components.day, + let month = components.month, let weekday = components.weekday else { return false } + let cronWeekday = weekday - 1 + guard let minuteField = parsedField(fields[0], range: 0...59, names: [:]), + let hourField = parsedField(fields[1], range: 0...23, names: [:]), + let dayField = parsedField(fields[2], range: 1...31, names: [:]), + let monthField = parsedField(fields[3], range: 1...12, names: monthNames), + let weekdayField = parsedField(fields[4], range: 0...7, names: weekdayNames) + else { return false } + let dayMatch = dayField.values.contains(day) + let weekdayMatch = weekdayField.values.contains(cronWeekday) + || (cronWeekday == 0 && weekdayField.values.contains(7)) + let dayConstraint = dayField.wildcardOrigin || weekdayField.wildcardOrigin + ? dayMatch && weekdayMatch + : dayMatch || weekdayMatch + return minuteField.values.contains(minute) + && hourField.values.contains(hour) + && monthField.values.contains(month) + && dayConstraint + } + + private struct ParsedCronField { + let values: Set + let wildcardOrigin: Bool + } + + private static func parsedField( + _ expression: String, + range: ClosedRange, + names: [String: Int] + ) -> ParsedCronField? { + let items = expression.lowercased().split(separator: ",", omittingEmptySubsequences: false) + guard !items.isEmpty else { return nil } + var values = Set() + var wildcardOrigin = false + for item in items { + let stepParts = item.split(separator: "/", omittingEmptySubsequences: false) + guard stepParts.count == 1 || stepParts.count == 2 else { return nil } + let step = stepParts.count == 2 ? Int(stepParts[1]) : 1 + guard let step, step > 0 else { return nil } + let base = String(stepParts[0]) + let bounds: ClosedRange + if base == "*" { + bounds = range + wildcardOrigin = true + } else { + let parts = base.split(separator: "-", omittingEmptySubsequences: false) + // macOS crontab(5) permits steps only after `*` or an explicit range. + guard stepParts.count == 1 || parts.count == 2 else { return nil } + let lower = valueOf(parts.first.map(String.init) ?? "", names: names) + let upper = parts.count == 2 ? valueOf(String(parts[1]), names: names) : lower + guard parts.count <= 2, let lower, let upper, + range.contains(lower), range.contains(upper), lower <= upper else { return nil } + bounds = lower...upper + } + for value in bounds where (value - bounds.lowerBound).isMultiple(of: step) { + values.insert(value) + } + } + return ParsedCronField(values: values, wildcardOrigin: wildcardOrigin) + } + + private static func valueOf(_ value: String, names: [String: Int]) -> Int? { + names[value.lowercased()] ?? Int(value) + } + + private static let monthNames = Dictionary(uniqueKeysWithValues: + zip(["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"], 1...12) + ) + private static let weekdayNames = [ + "sun": 0, "mon": 1, "tue": 2, "wed": 3, "thu": 4, "fri": 5, "sat": 6, + ] + + private static func canonicalPath(_ value: String?) -> String? { + guard let value = nonempty(value), value.hasPrefix("/") else { return nil } + return URL(fileURLWithPath: value).resolvingSymlinksInPath().standardizedFileURL.path + } + + private static func nonempty(_ value: String?) -> String? { + guard let value, !value.isEmpty else { return nil } + return value + } +} diff --git a/Sources/DevScopeCore/AutomationSource.swift b/Sources/DevScopeCore/AutomationSource.swift new file mode 100644 index 0000000..ed921b1 --- /dev/null +++ b/Sources/DevScopeCore/AutomationSource.swift @@ -0,0 +1,231 @@ +import Darwin +import CryptoKit +import Foundation + +public protocol AutomationSource: Sendable { + var kind: AutomationSourceKind { get } + func snapshot() async -> AutomationSourceSnapshot +} + +public struct AutomationCommand: Equatable, Sendable { + public let executable: String + public let arguments: [String] + public let environment: [String: String] + + public init( + executable: String, + arguments: [String], + environment: [String: String] = [:] + ) { + self.executable = executable + self.arguments = arguments + self.environment = environment + } +} + +public struct AutomationCommandResult: Equatable, Sendable { + public let status: Int32 + public let standardOutput: Data + public let standardError: Data + + public init(status: Int32, standardOutput: Data, standardError: Data) { + self.status = status + self.standardOutput = standardOutput + self.standardError = standardError + } +} + +public protocol AutomationCommandRunning: Sendable { + func run(_ command: AutomationCommand) async throws -> AutomationCommandResult +} + +public struct AutomationFileMetadata: Equatable, Sendable { + public let canonicalURL: URL + public let ownerUID: uid_t + public let isSymbolicLink: Bool + public let modificationDate: Date + public let resourceIdentifier: String? + public let permissions: Int? + + public init( + canonicalURL: URL, + ownerUID: uid_t, + isSymbolicLink: Bool, + modificationDate: Date, + resourceIdentifier: String? = nil, + permissions: Int? = nil + ) { + self.canonicalURL = canonicalURL + self.ownerUID = ownerUID + self.isSymbolicLink = isSymbolicLink + self.modificationDate = modificationDate + self.resourceIdentifier = resourceIdentifier + self.permissions = permissions + } +} + +public protocol AutomationFileSystem: Sendable { + func itemExists(at url: URL) -> Bool + func plistURLs(in directory: URL) throws -> [URL] + func read(_ url: URL) throws -> Data + func metadata(for url: URL) throws -> AutomationFileMetadata + func createDirectory(_ url: URL, permissions: Int) throws + func writeStagedFile( + nextTo url: URL, + data: Data, + permissions: Int, + authorization: AutomationDirectoryAuthorization + ) throws -> AutomationStagedFile + func replaceItem( + at destination: AutomationFileAuthorization, + with staged: AutomationStagedFile + ) throws -> AutomationFileMutationOutcome + func fileMatchesBinding( + _ file: AutomationFileAuthorization, + binding: AutomationStagedFileBinding + ) throws -> Bool + func moveToTrash(_ source: AutomationFileAuthorization) throws -> AutomationFileMutationOutcome + func removeItem(_ source: AutomationFileAuthorization) throws -> AutomationFileMutationOutcome +} + +public struct AutomationDirectoryAuthorization: Equatable, Sendable { + public let directoryURL: URL + public let resourceIdentifier: String + + public init?(directoryURL: URL, resourceIdentifier: String?) { + guard let resourceIdentifier, !resourceIdentifier.isEmpty else { return nil } + self.directoryURL = directoryURL.standardizedFileURL + self.resourceIdentifier = resourceIdentifier + } +} + +public enum AutomationFileExpectation: Equatable, Sendable { + case absent + case existing(resourceIdentifier: String) +} + +public struct AutomationFileAuthorization: Equatable, Sendable { + public let fileURL: URL + public let directory: AutomationDirectoryAuthorization + public let expectation: AutomationFileExpectation + + public init?( + fileURL: URL, + directory: AutomationDirectoryAuthorization, + expectation: AutomationFileExpectation + ) { + let fileURL = fileURL.standardizedFileURL + guard fileURL.deletingLastPathComponent().standardizedFileURL.path + == directory.directoryURL.standardizedFileURL.path + else { + return nil + } + if case .existing(let resourceIdentifier) = expectation, + resourceIdentifier.isEmpty { return nil } + self.fileURL = fileURL + self.directory = directory + self.expectation = expectation + } +} + +public struct AutomationStagedFile: Equatable, Sendable { + public let url: URL + public let authorization: AutomationFileAuthorization + public let binding: AutomationStagedFileBinding + + public init( + url: URL, + authorization: AutomationFileAuthorization, + binding: AutomationStagedFileBinding + ) { + self.url = url + self.authorization = authorization + self.binding = binding + } +} + +public struct AutomationStagedFileBinding: Equatable, Sendable { + public let data: Data + public let checksum: String + public let resourceIdentifier: String + public let ownerUID: uid_t + public let permissions: Int + public let linkCount: UInt64 + + public init( + data: Data, + resourceIdentifier: String, + ownerUID: uid_t, + permissions: Int, + linkCount: UInt64 + ) { + self.data = data + checksum = SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + self.resourceIdentifier = resourceIdentifier + self.ownerUID = ownerUID + self.permissions = permissions + self.linkCount = linkCount + } +} + +public struct AutomationFileMutationReceipt: Equatable, Sendable { + public let primaryFile: AutomationFileAuthorization? + public let resultURL: URL? + public let bindingVerified: Bool + + public init( + primaryFile: AutomationFileAuthorization?, + resultURL: URL?, + bindingVerified: Bool + ) { + self.primaryFile = primaryFile + self.resultURL = resultURL + self.bindingVerified = bindingVerified + } +} + +public enum AutomationFileMutationOutcome: Equatable, Sendable { + case committed(AutomationFileMutationReceipt) + case unchanged +} + +public enum AutomationFileMutationCommitState: Equatable, Sendable { + case committed + case unknown +} + +public enum AutomationFileMutationKind: Equatable, Sendable { + case replace + case remove + case trash +} + +public struct AutomationFilePartialMutation: Error, Equatable, Sendable { + public let kind: AutomationFileMutationKind + public let commitState: AutomationFileMutationCommitState + public let observedFiles: [AutomationFileAuthorization] + public let recoveryHandle: AutomationFileAuthorization? + public let recoveryHandles: [AutomationFileAuthorization] + public let resultURL: URL? + + public init( + kind: AutomationFileMutationKind, + commitState: AutomationFileMutationCommitState, + observedFiles: [AutomationFileAuthorization], + recoveryHandle: AutomationFileAuthorization?, + recoveryHandles: [AutomationFileAuthorization] = [], + resultURL: URL? + ) { + self.kind = kind + self.commitState = commitState + self.observedFiles = observedFiles + var handles: [AutomationFileAuthorization] = [] + for handle in [recoveryHandle].compactMap({ $0 }) + recoveryHandles + where !handles.contains(handle) { + handles.append(handle) + } + self.recoveryHandle = handles.first + self.recoveryHandles = handles + self.resultURL = resultURL + } +} diff --git a/Sources/DevScopeCore/BoundedSystemCommandRunner.swift b/Sources/DevScopeCore/BoundedSystemCommandRunner.swift new file mode 100644 index 0000000..88c8c78 --- /dev/null +++ b/Sources/DevScopeCore/BoundedSystemCommandRunner.swift @@ -0,0 +1,313 @@ +import Darwin +import Foundation + +struct BoundedSystemCommandResult: Equatable, Sendable { + let status: Int32 + let standardOutput: Data + let standardError: Data + var processIdentifier: Int32? = nil +} + +enum BoundedSystemCommandError: Error, Equatable { + case invalidCommand + case launchFailed + case outputLimitExceeded + case executionTimedOut +} + +protocol SystemCommandRunning: Sendable { + func run(executableURL: URL, arguments: [String]) throws -> BoundedSystemCommandResult +} + +struct BoundedSystemCommandRunner: SystemCommandRunning { + private static let defaultMaximumCapturedBytes = 8 * 1_024 * 1_024 + + private let maximumCapturedBytes: Int + private let executionTimeout: TimeInterval + private let terminationGraceInterval: TimeInterval + + init( + maximumCapturedBytes: Int = Self.defaultMaximumCapturedBytes, + executionTimeout: TimeInterval = 10, + terminationGraceInterval: TimeInterval = 0.5 + ) { + self.maximumCapturedBytes = max(0, maximumCapturedBytes) + self.executionTimeout = max(0, executionTimeout) + self.terminationGraceInterval = max(0, terminationGraceInterval) + } + + func run(executableURL: URL, arguments: [String]) throws -> BoundedSystemCommandResult { + let executable = executableURL.path + guard executable.hasPrefix("/"), + !executable.contains("\0"), + arguments.allSatisfy({ !$0.contains("\0") }) else { + throw BoundedSystemCommandError.invalidCommand + } + + let spawned = try Self.spawn(executable: executable, arguments: arguments) + let group = DispatchGroup() + let output = LockedBox?>(nil) + let error = LockedBox?>(nil) + let status = LockedBox?>(nil) + let outputLimitExceeded = LockedBox(false) + + group.enter() + DispatchQueue.global(qos: .userInitiated).async { + output.value = Result { + try Self.drain( + spawned.standardOutput, + maximumCapturedBytes: maximumCapturedBytes, + onLimitExceeded: { outputLimitExceeded.value = true } + ) + } + group.leave() + } + group.enter() + DispatchQueue.global(qos: .userInitiated).async { + error.value = Result { + try Self.drain( + spawned.standardError, + maximumCapturedBytes: maximumCapturedBytes, + onLimitExceeded: { outputLimitExceeded.value = true } + ) + } + group.leave() + } + group.enter() + DispatchQueue.global(qos: .userInitiated).async { + status.value = Result { try Self.reap(spawned.pid) } + group.leave() + } + + var remainingWait = executionTimeout + var completed = false + while !completed, !outputLimitExceeded.value { + if group.wait(timeout: .now()) == .success { + completed = true + break + } + guard remainingWait > 0 else { break } + let interval = min(remainingWait, 0.01) + completed = group.wait(timeout: .now() + interval) == .success + remainingWait -= interval + } + guard completed else { + Self.terminateAndDrain( + spawned, + group: group, + terminationGraceInterval: terminationGraceInterval + ) + throw outputLimitExceeded.value + ? BoundedSystemCommandError.outputLimitExceeded + : BoundedSystemCommandError.executionTimedOut + } + + let capturedOutput = try output.value?.get() ?? { throw BoundedSystemCommandError.launchFailed }() + let capturedError = try error.value?.get() ?? { throw BoundedSystemCommandError.launchFailed }() + let waitStatus = try status.value?.get() ?? { throw BoundedSystemCommandError.launchFailed }() + guard !capturedOutput.exceededLimit, !capturedError.exceededLimit else { + throw BoundedSystemCommandError.outputLimitExceeded + } + return BoundedSystemCommandResult( + status: Self.commandStatus(fromWaitStatus: waitStatus), + standardOutput: capturedOutput.data, + standardError: capturedError.data, + processIdentifier: spawned.pid + ) + } + + private struct SpawnedCommand: Sendable { + let pid: pid_t + let standardOutput: FileHandle + let standardError: FileHandle + } + + private struct CapturedStream: Sendable { + let data: Data + let exceededLimit: Bool + } + + private static func spawn(executable: String, arguments: [String]) throws -> SpawnedCommand { + var output = [Int32](repeating: -1, count: 2) + var error = [Int32](repeating: -1, count: 2) + guard output.withUnsafeMutableBufferPointer({ Darwin.pipe($0.baseAddress!) }) == 0 else { + throw BoundedSystemCommandError.launchFailed + } + guard error.withUnsafeMutableBufferPointer({ Darwin.pipe($0.baseAddress!) }) == 0 else { + close(output[0]); close(output[1]) + throw BoundedSystemCommandError.launchFailed + } + for descriptor in output + error { + let existingFlags = fcntl(descriptor, F_GETFD) + guard existingFlags >= 0, + fcntl(descriptor, F_SETFD, existingFlags | FD_CLOEXEC) == 0 else { + for descriptor in output + error { close(descriptor) } + throw BoundedSystemCommandError.launchFailed + } + } + + var actions: posix_spawn_file_actions_t? = nil + var attributes: posix_spawnattr_t? = nil + guard posix_spawn_file_actions_init(&actions) == 0 else { + for descriptor in output + error { close(descriptor) } + throw BoundedSystemCommandError.launchFailed + } + defer { posix_spawn_file_actions_destroy(&actions) } + guard posix_spawnattr_init(&attributes) == 0 else { + for descriptor in output + error { close(descriptor) } + throw BoundedSystemCommandError.launchFailed + } + defer { posix_spawnattr_destroy(&attributes) } + + var result = posix_spawn_file_actions_addopen( + &actions, STDIN_FILENO, "/dev/null", O_RDONLY, 0 + ) + if result == 0 { result = posix_spawn_file_actions_adddup2(&actions, output[1], STDOUT_FILENO) } + if result == 0 { result = posix_spawn_file_actions_adddup2(&actions, error[1], STDERR_FILENO) } + if result == 0 { result = posix_spawn_file_actions_addclose(&actions, output[0]) } + if result == 0 { result = posix_spawn_file_actions_addclose(&actions, error[0]) } + if result == 0 { result = posix_spawn_file_actions_addclose(&actions, output[1]) } + if result == 0 { result = posix_spawn_file_actions_addclose(&actions, error[1]) } + + var emptyMask = sigset_t() + var defaultSignals = sigset_t() + sigemptyset(&emptyMask) + sigemptyset(&defaultSignals) + for signal in [SIGINT, SIGHUP, SIGQUIT, SIGTERM, SIGPIPE] { + sigaddset(&defaultSignals, signal) + } + if result == 0 { result = posix_spawnattr_setsigmask(&attributes, &emptyMask) } + if result == 0 { result = posix_spawnattr_setsigdefault(&attributes, &defaultSignals) } + if result == 0 { result = posix_spawnattr_setpgroup(&attributes, 0) } + let flags = Int16( + POSIX_SPAWN_SETPGROUP | POSIX_SPAWN_SETSIGMASK | POSIX_SPAWN_SETSIGDEF + | POSIX_SPAWN_CLOEXEC_DEFAULT + ) + if result == 0 { result = posix_spawnattr_setflags(&attributes, flags) } + guard result == 0 else { + for descriptor in output + error { close(descriptor) } + throw BoundedSystemCommandError.launchFailed + } + + let argv = [executable] + arguments + let environment = [ + "HOME=\(FileManager.default.homeDirectoryForCurrentUser.path)", + "LC_ALL=C", + "LOGNAME=\(NSUserName())", + "PATH=/usr/bin:/bin:/usr/sbin:/sbin", + "USER=\(NSUserName())", + ] + var pid: pid_t = 0 + let spawnResult = withMutableCStringArray(argv) { argvPointer in + withMutableCStringArray(environment) { environmentPointer in + posix_spawn( + &pid, + executable, + &actions, + &attributes, + argvPointer, + environmentPointer + ) + } + } + close(output[1]) + close(error[1]) + guard spawnResult == 0, pid > 1 else { + close(output[0]) + close(error[0]) + throw BoundedSystemCommandError.launchFailed + } + return SpawnedCommand( + pid: pid, + standardOutput: FileHandle(fileDescriptor: output[0], closeOnDealloc: true), + standardError: FileHandle(fileDescriptor: error[0], closeOnDealloc: true) + ) + } + + private static func withMutableCStringArray( + _ strings: [String], + _ body: (UnsafeMutablePointer?>) -> Result + ) -> Result { + let pointers = strings.map { strdup($0)! } + defer { + for pointer in pointers { free(pointer) } + } + var terminated = pointers.map(Optional.some) + terminated.append(nil) + return terminated.withUnsafeMutableBufferPointer { buffer in + body(buffer.baseAddress!) + } + } + + private static func drain( + _ handle: FileHandle, + maximumCapturedBytes: Int, + onLimitExceeded: @Sendable () -> Void + ) throws -> CapturedStream { + defer { try? handle.close() } + var captured = Data() + var exceededLimit = false + while let chunk = try handle.read(upToCount: 64 * 1_024), !chunk.isEmpty { + let available = maximumCapturedBytes - captured.count + if available > 0 { + captured.append(chunk.prefix(available)) + } + if chunk.count > available, !exceededLimit { + exceededLimit = true + onLimitExceeded() + } + } + return CapturedStream(data: captured, exceededLimit: exceededLimit) + } + + private static func reap(_ pid: pid_t) throws -> Int32 { + var status: Int32 = 0 + while true { + let result = waitpid(pid, &status, 0) + if result == pid { return status } + if result == -1, errno == EINTR { continue } + throw BoundedSystemCommandError.launchFailed + } + } + + private static func terminate(processGroupID: pid_t, signal: Int32) { + guard processGroupID > 1 else { return } + _ = Darwin.kill(-processGroupID, signal) + } + + private static func terminateAndDrain( + _ command: SpawnedCommand, + group: DispatchGroup, + terminationGraceInterval: TimeInterval + ) { + terminate(processGroupID: command.pid, signal: SIGTERM) + if group.wait(timeout: .now() + terminationGraceInterval) == .timedOut { + terminate(processGroupID: command.pid, signal: SIGKILL) + _ = Darwin.kill(command.pid, SIGKILL) + } + if group.wait(timeout: .now() + 1) == .timedOut { + try? command.standardOutput.close() + try? command.standardError.close() + _ = group.wait(timeout: .now() + 1) + } + } + + private static func commandStatus(fromWaitStatus status: Int32) -> Int32 { + let signal = status & 0x7f + return signal == 0 ? (status >> 8) & 0xff : 128 + signal + } +} + +private final class LockedBox: @unchecked Sendable { + private let lock = NSLock() + private var storedValue: Value + + init(_ value: Value) { + storedValue = value + } + + var value: Value { + get { lock.withLock { storedValue } } + set { lock.withLock { storedValue = newValue } } + } +} diff --git a/Sources/DevScopeCore/CronAutomationSource.swift b/Sources/DevScopeCore/CronAutomationSource.swift new file mode 100644 index 0000000..30b164a --- /dev/null +++ b/Sources/DevScopeCore/CronAutomationSource.swift @@ -0,0 +1,461 @@ +import CryptoKit +import Darwin +import Foundation + +public struct CronDocument: Equatable, Sendable { + public let originalText: String + public let originalLines: [String] + public let environmentAssignments: [CronEnvironmentAssignment] + public let environment: [String: String] + public let entries: [CronEntry] + public let invalidLines: [CronInvalidLine] +} + +public enum CronDocumentChecksum { + public static func normalizedData(_ data: Data) -> Data? { + guard let text = String(data: data, encoding: .utf8) else { return nil } + var normalized = text + .replacingOccurrences(of: "\r\n", with: "\n") + .replacingOccurrences(of: "\r", with: "\n") + while normalized.hasSuffix("\n") { + normalized.removeLast() + } + normalized.append("\n") + return Data(normalized.utf8) + } + + public static func checksum(_ data: Data) -> String? { + normalizedData(data).map { + SHA256.hash(data: $0).map { String(format: "%02x", $0) }.joined() + } + } +} + +public struct CronEnvironmentAssignment: Equatable, Sendable { + public let lineNumber: Int + public let name: String + public let value: String +} + +public struct CronInvalidLine: Equatable, Sendable { + public let lineNumber: Int + public let content: String + public let reason: String +} + +public struct CronEntry: Identifiable, Equatable, Sendable { + public let id: String + public let lineNumber: Int + public let scheduleExpression: String + public let command: String + public let isEnabled: Bool + public let environment: [String: String] + public let schedule: AutomationSchedule +} + +public enum CronParser { + private static let disabledPrefix = "# devscope-disabled: " + + public static func parse(_ text: String) -> CronDocument { + let lines = text.components(separatedBy: "\n") + var environment: [String: String] = [:] + var environmentAssignments: [CronEnvironmentAssignment] = [] + var entries: [CronEntry] = [] + var invalidLines: [CronInvalidLine] = [] + var scheduleOccurrences: [String: Int] = [:] + + for (offset, originalLine) in lines.enumerated() { + let lineNumber = offset + 1 + let trimmed = originalLine.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + + let isDisabled = trimmed.hasPrefix(disabledPrefix) + if trimmed.hasPrefix("#"), !isDisabled { continue } + + if let assignment = environmentAssignment(in: trimmed) { + environment[assignment.name] = assignment.value + environmentAssignments.append(CronEnvironmentAssignment( + lineNumber: lineNumber, + name: assignment.name, + value: assignment.value + )) + continue + } + + let isEnabled = !isDisabled + let candidate = isEnabled ? trimmed : String(trimmed.dropFirst(disabledPrefix.count)) + guard let parsed = parseEntry(candidate) else { + invalidLines.append(CronInvalidLine( + lineNumber: lineNumber, + content: originalLine, + reason: "Invalid crontab line" + )) + continue + } + + let occurrence = scheduleOccurrences[parsed.expression, default: 0] + scheduleOccurrences[parsed.expression] = occurrence + 1 + entries.append(CronEntry( + id: stableEntryID(scheduleExpression: parsed.expression, occurrence: occurrence), + lineNumber: lineNumber, + scheduleExpression: parsed.expression, + command: parsed.command, + isEnabled: isEnabled, + environment: environment, + schedule: parsed.schedule + )) + } + + return CronDocument( + originalText: text, + originalLines: lines, + environmentAssignments: environmentAssignments, + environment: environment, + entries: entries, + invalidLines: invalidLines + ) + } + + public static func recordID(for entry: CronEntry, ownerUID: uid_t) -> AutomationRecord.ID { + AutomationRecord.ID( + source: .crontab, + ownerUID: ownerUID, + label: "cron-entry", + sourcePath: "/.devscope/current-user-crontab/\(entry.id)" + ) + } + + private static func environmentAssignment(in line: String) -> (name: String, value: String)? { + guard let equals = line.firstIndex(of: "=") else { return nil } + let rawName = line[..= 2, rawName.last == quote else { return nil } + name = String(rawName.dropFirst().dropLast()) + } else { + guard !rawName.contains(where: \.isWhitespace), + !rawName.contains("\""), + !rawName.contains("'") + else { return nil } + name = String(rawName) + } + guard !name.isEmpty else { return nil } + + var value = line[line.index(after: equals)...] + .trimmingCharacters(in: .whitespacesAndNewlines) + if let quote = value.first, quote == "\"" || quote == "'" { + guard value.count >= 2, value.last == quote else { return nil } + value = String(value.dropFirst().dropLast()) + } + return (name, String(value)) + } + + private static func parseEntry( + _ line: String + ) -> (expression: String, command: String, schedule: AutomationSchedule)? { + let firstFields = line.split(maxSplits: 1, whereSeparator: \.isWhitespace) + guard !firstFields.isEmpty else { return nil } + + if firstFields[0].hasPrefix("@") { + guard firstFields.count == 2 else { return nil } + let expression = String(firstFields[0]) + let summary: String + switch expression { + case "@reboot": summary = "At startup" + case "@hourly": summary = "Hourly" + case "@daily": summary = "Daily" + case "@weekly": summary = "Weekly" + case "@monthly": summary = "Monthly" + case "@yearly": summary = "Yearly" + default: return nil + } + return ( + expression, + String(firstFields[1]), + AutomationSchedule(triggers: [.cron(expression)], summary: summary) + ) + } + + let fields = line.split(maxSplits: 5, whereSeparator: \.isWhitespace) + guard fields.count == 6 else { return nil } + let specifications = [ + CronFieldSpecification(minimum: 0, maximum: 59), + CronFieldSpecification(minimum: 0, maximum: 23), + CronFieldSpecification(minimum: 1, maximum: 31), + CronFieldSpecification( + minimum: 1, + maximum: 12, + names: ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"] + ), + CronFieldSpecification( + minimum: 0, + maximum: 7, + names: ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] + ), + ] + guard zip(fields.prefix(5), specifications).allSatisfy({ field, specification in + isValid(field: String(field), specification: specification) + }) else { return nil } + + let expression = fields.prefix(5).joined(separator: " ") + let summary = expression == "0 9 * * 1" ? "Mondays at 09:00" : "Cron: \(expression)" + return ( + expression, + String(fields[5]), + AutomationSchedule(triggers: [.cron(expression)], summary: summary) + ) + } + + private struct CronFieldSpecification { + let minimum: Int + let maximum: Int + var names: Set = [] + } + + private static func isValid( + field: String, + specification: CronFieldSpecification + ) -> Bool { + if specification.names.contains(field.lowercased()) { + return true + } + + let items = field.split(separator: ",", omittingEmptySubsequences: false) + guard !items.isEmpty, + items.count == 1 || !items.contains(where: { $0.contains("*") }) + else { return false } + return items.allSatisfy { item in + let stepParts = item.split(separator: "/", omittingEmptySubsequences: false) + guard stepParts.count == 1 || stepParts.count == 2 else { return false } + let base = String(stepParts[0]) + + if stepParts.count == 2 { + guard isASCIIDigits(stepParts[1]), + let step = Int(stepParts[1]), step > 0, + base == "*" || base.contains("-") + else { return false } + } + + if base == "*" { return true } + let rangeParts = base.split(separator: "-", omittingEmptySubsequences: false) + if rangeParts.count == 2 { + guard isASCIIDigits(rangeParts[0]), + isASCIIDigits(rangeParts[1]), + let lower = Int(rangeParts[0]), + let upper = Int(rangeParts[1]), + specification.minimum...specification.maximum ~= lower, + specification.minimum...specification.maximum ~= upper + else { return false } + return lower <= upper + } + guard rangeParts.count == 1, + isASCIIDigits(base), + let value = Int(base) + else { return false } + return specification.minimum...specification.maximum ~= value + } + } + + private static func isASCIIDigits(_ value: S) -> Bool { + !value.isEmpty && value.allSatisfy { character in + character >= "0" && character <= "9" + } + } + + private static func stableEntryID(scheduleExpression: String, occurrence: Int) -> String { + var identity = Data("devscope-crontab-entry\0".utf8) + identity.append(Data(scheduleExpression.utf8)) + identity.append(0) + identity.append(Data(String(occurrence).utf8)) + return SHA256.hash(data: identity) + .map { String(format: "%02x", $0) } + .joined() + } +} + +public struct CronAutomationSource: AutomationSource { + public let kind: AutomationSourceKind = .crontab + + private let commandRunner: any AutomationCommandRunning + private let currentUID: uid_t + private let currentUsername: String + + public init( + commandRunner: any AutomationCommandRunning, + currentUID: uid_t, + currentUsername: String + ) { + self.commandRunner = commandRunner + self.currentUID = currentUID + self.currentUsername = currentUsername + } + + public func snapshot() async -> AutomationSourceSnapshot { + let result: AutomationCommandResult + do { + result = try await commandRunner.run(AutomationCommand( + executable: "/usr/bin/crontab", + arguments: ["-l"], + environment: ["LC_ALL": "C"] + )) + } catch { + return .failed( + kind: kind, + message: "Unable to list the current-user crontab." + ) + } + + if result.status == 1, + result.standardOutput.isEmpty, + Self.isNoCrontabDiagnostic( + result.standardError, + currentUsername: currentUsername + ) + { + return .healthy(kind: kind, records: []) + } + guard result.status == 0 else { + return .failed( + kind: kind, + message: "Unable to list the current-user crontab (exit status \(result.status))." + ) + } + guard let text = String(data: result.standardOutput, encoding: .utf8) else { + return .failed( + kind: kind, + message: "The current-user crontab is not valid UTF-8." + ) + } + + let document = CronParser.parse(text) + guard let documentChecksum = CronDocumentChecksum.checksum(result.standardOutput) else { + return .failed( + kind: kind, + message: "The current-user crontab is not valid UTF-8." + ) + } + let entryRecords = document.entries.enumerated().map { index, entry in + Self.record( + for: entry, + index: index, + ownerUID: currentUID, + documentChecksum: documentChecksum + ) + } + let invalidRecords = document.invalidLines.map { + Self.invalidRecord(for: $0, ownerUID: currentUID, documentChecksum: documentChecksum) + } + let records = entryRecords + invalidRecords + guard !invalidRecords.isEmpty else { + return .healthy(kind: kind, records: records) + } + + let count = invalidRecords.count + return AutomationSourceSnapshot( + records: records, + health: AutomationSourceHealth( + kind: kind, + state: .partial, + message: "\(count) invalid crontab line\(count == 1 ? "." : "s.")", + refreshedAt: Date() + ) + ) + } + + private static func record( + for entry: CronEntry, + index: Int, + ownerUID: uid_t, + documentChecksum: String + ) -> AutomationRecord { + let label = "Cron entry \(index + 1)" + return AutomationRecord( + id: CronParser.recordID(for: entry, ownerUID: ownerUID), + kind: .cron, + sourceKind: .crontab, + label: label, + displayName: label, + providerBundleIdentifier: nil, + ownerUID: ownerUID, + ownership: .user, + executable: nil, + arguments: [], + commandSignature: entry.command, + environment: entry.environment, + workingDirectory: nil, + schedule: entry.schedule, + sourceURL: nil, + sourceChecksum: documentChecksum, + enabledState: entry.isEnabled ? .enabled : .disabled, + loadState: .unknown, + approvalState: .notApplicable, + state: entry.isEnabled ? .idle : .disabled, + evidence: [AutomationEvidence( + strength: .strong, + source: "current-user crontab", + detail: "Listed by the current-user crontab source" + )], + capabilities: [], + validationFindings: [] + ) + } + + private static func invalidRecord( + for invalidLine: CronInvalidLine, + ownerUID: uid_t, + documentChecksum: String + ) -> AutomationRecord { + let label = "Invalid crontab line \(invalidLine.lineNumber)" + return AutomationRecord( + id: AutomationRecord.ID( + source: .crontab, + ownerUID: ownerUID, + label: "invalid-cron-entry", + sourcePath: "/.devscope/current-user-crontab/invalid/\(invalidLine.lineNumber)" + ), + kind: .cron, + sourceKind: .crontab, + label: label, + displayName: label, + providerBundleIdentifier: nil, + ownerUID: ownerUID, + ownership: .user, + executable: nil, + arguments: [], + environment: [:], + workingDirectory: nil, + schedule: AutomationSchedule( + triggers: [.cron("invalid")], + summary: "Invalid cron schedule" + ), + sourceURL: nil, + sourceChecksum: documentChecksum, + enabledState: .unknown, + loadState: .unknown, + approvalState: .notApplicable, + state: .invalid, + evidence: [AutomationEvidence( + strength: .strong, + source: "current-user crontab", + detail: "The listed crontab contains an invalid line" + )], + capabilities: [], + validationFindings: ["Invalid crontab line"] + ) + } + + private static func isNoCrontabDiagnostic( + _ data: Data, + currentUsername: String + ) -> Bool { + guard !currentUsername.isEmpty, + !currentUsername.contains(where: \.isNewline), + let diagnostic = String(data: data, encoding: .utf8) + else { return false } + + let expected = "crontab: no crontab for \(currentUsername)" + return diagnostic == expected + || diagnostic == expected + "\n" + || diagnostic == expected + "\r\n" + } +} diff --git a/Sources/DevScopeCore/DevProcess.swift b/Sources/DevScopeCore/DevProcess.swift new file mode 100644 index 0000000..aebe982 --- /dev/null +++ b/Sources/DevScopeCore/DevProcess.swift @@ -0,0 +1,221 @@ +import Foundation + +public struct ProcessBirthToken: Equatable, Hashable, Sendable { + public let seconds: UInt64 + public let microseconds: UInt64 + + public init(seconds: UInt64, microseconds: UInt64) { + self.seconds = seconds + self.microseconds = microseconds + } +} + +public struct DevProcess: Identifiable, Equatable, Hashable, Sendable { + public let pid: Int32 + public let parentPID: Int32 + public let executable: String + public let command: String + public let argumentVector: [String]? + public let currentDirectory: String? + public let resourceUsage: DevProcessResourceUsage? + public let birthToken: ProcessBirthToken? + public let bundleIdentifier: String? + /// Exact service-manager metadata when supplied by a trusted scanner boundary. + /// Nil does not imply a label and callers must never infer one from names or paths. + public let launchLabel: String? + + public var id: Int32 { pid } + + public init( + pid: Int32, + parentPID: Int32, + executable: String, + command: String, + argumentVector: [String]? = nil, + currentDirectory: String? = nil, + resourceUsage: DevProcessResourceUsage? = nil, + birthToken: ProcessBirthToken? = nil, + bundleIdentifier: String? = nil, + launchLabel: String? = nil + ) { + self.pid = pid + self.parentPID = parentPID + self.executable = executable + self.command = command + self.argumentVector = argumentVector + self.currentDirectory = currentDirectory + self.resourceUsage = resourceUsage + self.birthToken = birthToken + self.bundleIdentifier = bundleIdentifier + self.launchLabel = launchLabel + } + + public var executableName: String { + URL(fileURLWithPath: executable).lastPathComponent + } +} + +enum ProcessSnapshotNormalization { + static func newestUnambiguous(_ processes: [DevProcess]) -> [DevProcess] { + let grouped = Dictionary(grouping: processes, by: \.pid) + return grouped.keys.sorted().compactMap { processID in + guard let rows = grouped[processID] else { return nil } + let newestBirth = rows.compactMap(\.birthToken).max(by: birthPrecedes) + let candidates = rows.filter { $0.birthToken == newestBirth } + guard let first = candidates.first, + candidates.dropFirst().allSatisfy({ $0 == first }) else { return nil } + return first + } + } + + private static func birthPrecedes(_ lhs: ProcessBirthToken, _ rhs: ProcessBirthToken) -> Bool { + (lhs.seconds, lhs.microseconds) < (rhs.seconds, rhs.microseconds) + } +} + +public struct DevProcessResourceUsage: Equatable, Hashable, Sendable { + public let cpuPercent: Double + public let residentMemoryBytes: Int64 + public let elapsedTime: String + + public init(cpuPercent: Double, residentMemoryBytes: Int64, elapsedTime: String) { + self.cpuPercent = cpuPercent + self.residentMemoryBytes = residentMemoryBytes + self.elapsedTime = elapsedTime + } +} + +public struct DevProcessMetricSample: Identifiable, Equatable, Sendable { + public let id: UUID + public let timestamp: Date + public let cpuPercent: Double + public let residentMemoryBytes: Int64 + public let gpuPercent: Double? + + public init( + id: UUID = UUID(), + timestamp: Date, + cpuPercent: Double, + residentMemoryBytes: Int64, + gpuPercent: Double? = nil + ) { + self.id = id + self.timestamp = timestamp + self.cpuPercent = cpuPercent + self.residentMemoryBytes = residentMemoryBytes + self.gpuPercent = gpuPercent + } +} + +public struct DevProcessTag: Identifiable, Equatable, Hashable, Sendable { + public let id: String + public let title: String + public let symbolName: String + + public init(id: String, title: String, symbolName: String) { + self.id = id + self.title = title + self.symbolName = symbolName + } + + public static let notebook = DevProcessTag(id: "notebook", title: "Notebook", symbolName: "book") + public static let training = DevProcessTag(id: "training", title: "Training", symbolName: "chart.line.uptrend.xyaxis") + public static let llm = DevProcessTag(id: "llm", title: "LLM", symbolName: "brain.head.profile") + public static let inference = DevProcessTag(id: "inference", title: "Inference", symbolName: "brain") + public static let llmServer = DevProcessTag(id: "llm-server", title: "LLM Server", symbolName: "text.bubble") + public static let vectorDB = DevProcessTag(id: "vector-db", title: "Vector DB", symbolName: "square.stack.3d.up") + public static let api = DevProcessTag(id: "api", title: "API", symbolName: "network") + public static let mcp = DevProcessTag(id: "mcp", title: "MCP", symbolName: "point.3.connected.trianglepath.dotted") + public static let experiment = DevProcessTag(id: "experiment", title: "Experiment", symbolName: "flask") + public static let dataApp = DevProcessTag(id: "data-app", title: "Data App", symbolName: "chart.bar") +} + +public enum DevRuntimeKind: String, CaseIterable, Identifiable, Sendable { + case javascript = "JavaScript" + case python = "Python" + case swift = "Swift" + case rust = "Rust" + case go = "Go" + case flutter = "Flutter" + case java = "Java" + case database = "Database" + case container = "Container" + case webServer = "Web Server" + case ai = "AI" + case mcp = "MCP" + case browser = "Browser" + case macApp = "App" + case backgroundAgent = "Background Agent" + case systemService = "System Service" + case shell = "Shell" + case other = "Other" + + public var id: String { rawValue } + + public var symbolName: String { + switch self { + case .javascript: + "curlybraces" + case .python: + "terminal" + case .swift: + "swift" + case .rust: + "hammer" + case .go: + "bolt.horizontal" + case .flutter: + "iphone.and.arrow.forward" + case .java: + "cup.and.saucer" + case .database: + "cylinder" + case .container: + "shippingbox" + case .webServer: + "network" + case .ai: + "brain" + case .mcp: + "point.3.connected.trianglepath.dotted" + case .browser: + "safari" + case .macApp: + "macwindow" + case .backgroundAgent: + "gearshape.2" + case .systemService: + "server.rack" + case .shell: + "terminal.fill" + case .other: + "questionmark.circle" + } + } +} + +public struct DevProcessClassification: Equatable, Sendable { + public let kind: DevRuntimeKind + public let displayName: String + public let projectHint: String? + public let tags: [DevProcessTag] + + public init(kind: DevRuntimeKind, displayName: String, projectHint: String?, tags: [DevProcessTag] = []) { + self.kind = kind + self.displayName = displayName + self.projectHint = projectHint + self.tags = tags + } +} + +public struct ClassifiedDevProcess: Identifiable, Equatable, Sendable { + public let process: DevProcess + public let classification: DevProcessClassification + + public var id: Int32 { process.pid } + + public init(process: DevProcess, classification: DevProcessClassification) { + self.process = process + self.classification = classification + } +} diff --git a/Sources/DevScopeCore/FullDiskAccessGuidance.swift b/Sources/DevScopeCore/FullDiskAccessGuidance.swift new file mode 100644 index 0000000..325a4a6 --- /dev/null +++ b/Sources/DevScopeCore/FullDiskAccessGuidance.swift @@ -0,0 +1,67 @@ +import Foundation + +public enum FullDiskAccessSettingsRoute { + public static let deepLink = + "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles" + + @discardableResult + public static func open( + deepLink: String = deepLink, + using openURL: (URL) -> Bool, + fallback: () -> Void + ) -> Bool { + guard let url = URL(string: deepLink), openURL(url) else { + fallback() + return false + } + return true + } +} + +public struct FullDiskAccessGuidance: Equatable, Sendable { + public let appPath: String + public let isSandboxed: Bool + + public init(appPath: String, isSandboxed: Bool) { + self.appPath = appPath + self.isSandboxed = isSandboxed + } + + public var isInstalledInApplications: Bool { + appPath.hasPrefix("/Applications/") + } + + public var title: String { + isSandboxed + ? "This sandbox build cannot use Full Disk Access" + : "Add DevScope to Full Disk Access" + } + + public var detail: String { + if isSandboxed { + if !isInstalledInApplications { + return + "This validation/development build is running at \(appPath) and uses App Sandbox. Install the full DevScope build in Applications; Full Disk Access cannot override App Sandbox for process inspection." + } + return + "Install the full DevScope build in Applications. Full Disk Access cannot override App Sandbox for process inspection." + } + if !isInstalledInApplications { + return + "Install DevScope in Applications first so macOS keeps permission attached to the app you launch. Current app: \(appPath)" + } + return "macOS requires you to add and enable the exact DevScope app manually." + } + + public var steps: [String] { + guard !isSandboxed else { return [] } + return [ + "Open Full Disk Access.", + "Click + and authenticate.", + "Select \(appPath).", + "Enable the DevScope toggle.", + "Quit and reopen DevScope.", + "Return here and choose Check Access.", + ] + } +} diff --git a/Sources/DevScopeCore/GPUMetricProvider.swift b/Sources/DevScopeCore/GPUMetricProvider.swift new file mode 100644 index 0000000..30df1dd --- /dev/null +++ b/Sources/DevScopeCore/GPUMetricProvider.swift @@ -0,0 +1,85 @@ +import Foundation + +public struct DevGPUMetric: Equatable, Sendable { + public let utilizationPercent: Double + public let modelName: String? + + public init(utilizationPercent: Double, modelName: String? = nil) { + self.utilizationPercent = utilizationPercent + self.modelName = modelName + } +} + +public protocol GPUMetricProviding: Sendable { + func snapshot() throws -> DevGPUMetric? +} + +public struct SystemGPUMetricProvider: GPUMetricProviding { + private let executableURL: URL + private let commandRunner: any SystemCommandRunning + + public init(executableURL: URL = URL(fileURLWithPath: "/usr/sbin/ioreg")) { + self.executableURL = executableURL + commandRunner = BoundedSystemCommandRunner() + } + + init( + executableURL: URL, + commandRunner: any SystemCommandRunning + ) { + self.executableURL = executableURL + self.commandRunner = commandRunner + } + + public func snapshot() throws -> DevGPUMetric? { + let result = try commandRunner.run( + executableURL: executableURL, + arguments: ["-r", "-c", "AGXAccelerator", "-d", "1"] + ) + + guard result.status == 0 else { + return nil + } + + return Self.parse(String(decoding: result.standardOutput, as: UTF8.self)) + } + + public static func parse(_ output: String) -> DevGPUMetric? { + guard let utilization = number(after: #""Device Utilization %"\s*=\s*"#, in: output) else { + return nil + } + + return DevGPUMetric( + utilizationPercent: min(max(utilization, 0), 100), + modelName: quotedValue(after: #""model"\s*=\s*"#, in: output) + ) + } + + private static func number(after prefixPattern: String, in output: String) -> Double? { + guard let regex = try? NSRegularExpression(pattern: prefixPattern + #"([0-9]+(?:\.[0-9]+)?)"#) else { + return nil + } + let range = NSRange(output.startIndex.. 1, + let matchRange = Range(match.range(at: 1), in: output) else { + return nil + } + + return Double(output[matchRange]) + } + + private static func quotedValue(after prefixPattern: String, in output: String) -> String? { + guard let regex = try? NSRegularExpression(pattern: prefixPattern + #""([^"]+)""#) else { + return nil + } + let range = NSRange(output.startIndex.. 1, + let matchRange = Range(match.range(at: 1), in: output) else { + return nil + } + + return String(output[matchRange]) + } +} diff --git a/Sources/DevScopeCore/InterfacePresentation.swift b/Sources/DevScopeCore/InterfacePresentation.swift new file mode 100644 index 0000000..2a933f1 --- /dev/null +++ b/Sources/DevScopeCore/InterfacePresentation.swift @@ -0,0 +1,74 @@ +import Foundation + +public enum DevScopeWorkspaceMode: String, CaseIterable, Sendable { + case processes + case automations +} + +public enum AutomationPresentationSettings { + public static let defaultLongRunningThreshold: TimeInterval = 14_400 + public static let minimumLongRunningThreshold: TimeInterval = 3_600 + public static let maximumLongRunningThreshold: TimeInterval = 604_800 + public static let longRunningThresholdStep: TimeInterval = 3_600 + + public static let longRunningThresholdSecondsKey = "longRunningThresholdSeconds" + public static let includeAppleSystemServicesKey = "includeAppleSystemServices" + public static let selectedWorkspaceModeKey = "selectedWorkspaceMode" + public static let notifyLongRunningAutomationKey = "notifyLongRunningAutomation" + public static let notifyUnexpectedAutomationExitKey = "notifyUnexpectedAutomationExit" + public static let notifyRepeatedAutomationFailureKey = "notifyRepeatedAutomationFailure" + + public static func normalizedThreshold(_ value: TimeInterval) -> TimeInterval { + guard value.isFinite else { return defaultLongRunningThreshold } + let clamped = min(max(value, minimumLongRunningThreshold), maximumLongRunningThreshold) + return (clamped / longRunningThresholdStep).rounded() * longRunningThresholdStep + } +} + +public enum InterfaceSelectionDirection: Sendable { + case previous + case next +} + +public enum InterfacePresentation { + public static let defaultFocusLimit = 6 + + public static func visibleFocusWorkflows( + _ workflows: [DevWorkflow], + selectedID: String, + showsAll: Bool + ) -> [DevWorkflow] { + guard !showsAll else { + return workflows + } + + var visible = Array(workflows.prefix(defaultFocusLimit)) + if let selected = workflows.first(where: { $0.id == selectedID }), + !visible.contains(where: { $0.id == selected.id }) { + visible.append(selected) + } + return visible + } + + public static func movedSelection( + in visibleIDs: [Int32], + current: Int32?, + direction: InterfaceSelectionDirection + ) -> Int32? { + guard !visibleIDs.isEmpty else { + return nil + } + + guard let current, let currentIndex = visibleIDs.firstIndex(of: current) else { + return direction == .next ? visibleIDs.first : visibleIDs.last + } + + let destination = switch direction { + case .previous: + max(visibleIDs.startIndex, currentIndex - 1) + case .next: + min(visibleIDs.index(before: visibleIDs.endIndex), currentIndex + 1) + } + return visibleIDs[destination] + } +} diff --git a/Sources/DevScopeCore/LaunchctlServiceTargetClassifier.swift b/Sources/DevScopeCore/LaunchctlServiceTargetClassifier.swift new file mode 100644 index 0000000..a139c64 --- /dev/null +++ b/Sources/DevScopeCore/LaunchctlServiceTargetClassifier.swift @@ -0,0 +1,25 @@ +import Foundation + +public enum LaunchctlServiceTargetState: Equatable, Sendable { + case loaded + case absent + case unknown +} + +public enum LaunchctlServiceTargetClassifier { + public static func classify( + _ result: AutomationCommandResult, + label: String, + guiUID: uid_t + ) -> LaunchctlServiceTargetState { + if result.status == 0 { return .loaded } + guard result.status == 113 else { return .unknown } + + let expectedDiagnostic = + "Bad request.\nCould not find service \"\(label)\" in domain for user gui: \(guiUID)\n" + guard String(data: result.standardError, encoding: .utf8) == expectedDiagnostic else { + return .unknown + } + return .absent + } +} diff --git a/Sources/DevScopeCore/LaunchdAutomationSource.swift b/Sources/DevScopeCore/LaunchdAutomationSource.swift new file mode 100644 index 0000000..5532c02 --- /dev/null +++ b/Sources/DevScopeCore/LaunchdAutomationSource.swift @@ -0,0 +1,787 @@ +import Darwin +import CoreFoundation +import CryptoKit +import Foundation + +public struct LaunchdScanRoot: Equatable, Sendable { + public let url: URL + public let kind: AutomationKind + public let ownership: AutomationOwnership + public let isMutable: Bool + + public init( + url: URL, + kind: AutomationKind, + ownership: AutomationOwnership, + isMutable: Bool + ) { + self.url = url + self.kind = kind + self.ownership = ownership + self.isMutable = isMutable + } +} + +public struct LaunchdRuntimeState: Equatable, Sendable { + public let enabledState: AutomationEnabledState + public let loadState: AutomationLoadState + + public init( + enabledState: AutomationEnabledState, + loadState: AutomationLoadState + ) { + self.enabledState = enabledState + self.loadState = loadState + } +} + +public protocol LaunchdRuntimeStateProviding: Sendable { + func states( + for labels: [String], + guiUID: uid_t + ) async -> [String: LaunchdRuntimeState] +} + +typealias LaunchdRecordParser = @Sendable ( + Data, + URL, + uid_t, + AutomationOwnership, + AutomationKind, + Bool +) throws -> AutomationRecord + +public actor LaunchdAutomationSource: AutomationSource { + private struct CacheIdentity: Hashable, Sendable { + let canonicalURL: URL + let resourceIdentifier: String? + let modificationDate: Date + let checksum: String + let ownerUID: uid_t + let ownership: AutomationOwnership + let kind: AutomationKind + let isMutable: Bool + } + + private struct CacheEntry: Sendable { + let identity: CacheIdentity + let record: AutomationRecord + } + + public nonisolated let kind: AutomationSourceKind = .launchAgent + + private let fileSystem: any AutomationFileSystem + private let currentUID: uid_t + private let roots: [LaunchdScanRoot] + private let runtimeStateProvider: (any LaunchdRuntimeStateProviding)? + private let recordParser: LaunchdRecordParser + private var cache: [URL: CacheEntry] = [:] + + public init( + fileSystem: any AutomationFileSystem, + currentUID: uid_t, + roots: [LaunchdScanRoot], + runtimeStateProvider: (any LaunchdRuntimeStateProviding)? = nil + ) { + self.fileSystem = fileSystem + self.currentUID = currentUID + self.roots = roots + self.runtimeStateProvider = runtimeStateProvider + recordParser = { data, sourceURL, ownerUID, ownership, kind, isMutable in + try LaunchdPlistParser.parse( + data: data, + canonicalSourceURL: sourceURL, + ownerUID: ownerUID, + ownership: ownership, + kind: kind, + isMutable: isMutable + ) + } + } + + init( + fileSystem: any AutomationFileSystem, + currentUID: uid_t, + roots: [LaunchdScanRoot], + runtimeStateProvider: (any LaunchdRuntimeStateProviding)? = nil, + recordParser: @escaping LaunchdRecordParser + ) { + self.fileSystem = fileSystem + self.currentUID = currentUID + self.roots = roots + self.runtimeStateProvider = runtimeStateProvider + self.recordParser = recordParser + } + + public static func defaultRoots(homeDirectory: URL) -> [LaunchdScanRoot] { + [ + LaunchdScanRoot( + url: homeDirectory.appending(path: "Library/LaunchAgents"), + kind: .launchAgent, + ownership: .user, + isMutable: true + ), + LaunchdScanRoot( + url: URL(fileURLWithPath: "/Library/LaunchAgents"), + kind: .launchAgent, + ownership: .thirdPartySystem, + isMutable: false + ), + LaunchdScanRoot( + url: URL(fileURLWithPath: "/Library/LaunchDaemons"), + kind: .launchDaemon, + ownership: .thirdPartySystem, + isMutable: false + ), + LaunchdScanRoot( + url: URL(fileURLWithPath: "/System/Library/LaunchAgents"), + kind: .launchAgent, + ownership: .appleSystem, + isMutable: false + ), + LaunchdScanRoot( + url: URL(fileURLWithPath: "/System/Library/LaunchDaemons"), + kind: .launchDaemon, + ownership: .appleSystem, + isMutable: false + ), + ] + } + + public func snapshot() async -> AutomationSourceSnapshot { + var records: [AutomationRecord] = [] + var errors: [String] = [] + var seenCanonicalURLs: Set = [] + + for root in roots { + let urls: [URL] + do { + urls = try fileSystem.plistURLs(in: root.url) + } catch { + errors.append("\(root.url.path): \(error)") + continue + } + + for url in urls { + let metadata: AutomationFileMetadata + do { + metadata = try fileSystem.metadata(for: url) + } catch { + let finding = "Unable to inspect launchd definition: \(error)" + errors.append("\(url.path): \(finding)") + records.append(Self.invalidRecord( + root: root, + sourceURL: url.standardizedFileURL, + ownerUID: root.ownership == .user ? currentUID : 0, + checksum: nil, + finding: finding + )) + continue + } + + let canonicalURL = Self.canonical(metadata.canonicalURL) + let ownerUID = metadata.ownerUID + seenCanonicalURLs.insert(canonicalURL) + let data: Data + do { + data = try fileSystem.read(url) + } catch { + cache.removeValue(forKey: canonicalURL) + let finding = "Unable to read launchd definition: \(error)" + errors.append("\(url.path): \(finding)") + records.append(Self.invalidRecord( + root: root, + sourceURL: canonicalURL, + ownerUID: ownerUID, + checksum: nil, + finding: finding + )) + continue + } + + let isMutable = Self.isSafelyMutable( + root: root, + metadata: metadata, + canonicalURL: canonicalURL, + currentUID: currentUID + ) + let checksum = Self.checksum(data) + let identity = CacheIdentity( + canonicalURL: canonicalURL, + resourceIdentifier: metadata.resourceIdentifier, + modificationDate: metadata.modificationDate, + checksum: checksum, + ownerUID: ownerUID, + ownership: root.ownership, + kind: root.kind, + isMutable: isMutable + ) + if let entry = cache[canonicalURL], entry.identity == identity { + records.append(entry.record) + continue + } + + do { + let record = try recordParser( + data, + canonicalURL, + ownerUID, + root.ownership, + root.kind, + isMutable + ) + records.append(record) + cache[canonicalURL] = CacheEntry(identity: identity, record: record) + } catch { + cache.removeValue(forKey: canonicalURL) + let finding = Self.finding(for: error) + errors.append("\(url.path): \(finding)") + records.append(Self.invalidRecord( + root: root, + sourceURL: canonicalURL, + ownerUID: ownerUID, + checksum: checksum, + finding: finding + )) + } + } + } + + cache = cache.filter { seenCanonicalURLs.contains($0.key) } + + if let runtimeStateProvider { + let labels = records.compactMap { record -> String? in + guard record.kind == .launchAgent, + record.sourceKind == .launchAgent, + record.ownership == .user, + record.ownerUID == currentUID, + !record.label.isEmpty, + !record.label.contains("/"), + !record.label.contains("\0") + else { return nil } + return record.label + }.sorted() + let runtimeStates = await runtimeStateProvider.states( + for: labels, + guiUID: currentUID + ) + records = records.map { record in + guard let runtimeState = runtimeStates[record.label], + record.kind == .launchAgent, + record.ownership == .user, + record.ownerUID == currentUID + else { return record } + return Self.applying(runtimeState, to: record) + } + } + + if errors.isEmpty { + return .healthy(kind: kind, records: records) + } + return AutomationSourceSnapshot( + records: records, + health: AutomationSourceHealth( + kind: kind, + state: records.isEmpty ? .failed : .partial, + message: Self.healthMessage(for: errors), + refreshedAt: Date() + ) + ) + } + + private static func applying( + _ runtimeState: LaunchdRuntimeState, + to record: AutomationRecord + ) -> AutomationRecord { + let state: AutomationState + switch runtimeState.enabledState { + case .enabled: + state = .idle + case .disabled: + state = .disabled + case .unknown: + state = .unresolved + } + return AutomationRecord( + id: record.id, + kind: record.kind, + sourceKind: record.sourceKind, + label: record.label, + displayName: record.displayName, + providerBundleIdentifier: record.providerBundleIdentifier, + ownerUID: record.ownerUID, + ownership: record.ownership, + executable: record.executable, + arguments: record.arguments, + commandSignature: record.commandSignature, + environment: record.environment, + workingDirectory: record.workingDirectory, + schedule: record.schedule, + sourceURL: record.sourceURL, + sourceChecksum: record.sourceChecksum, + enabledState: runtimeState.enabledState, + loadState: runtimeState.loadState, + approvalState: record.approvalState, + state: state, + evidence: record.evidence, + capabilities: record.capabilities, + validationFindings: record.validationFindings + ) + } + + static func healthMessage(for errors: [String]) -> String { + precondition(!errors.isEmpty) + let examples = errors.prefix(2).map { error in + let compact = error.split(whereSeparator: \.isWhitespace).joined(separator: " ") + guard compact.count > 120 else { return compact } + return String(compact.prefix(119)) + "…" + } + let issueLabel = errors.count == 1 ? "issue" : "issues" + let exampleLabel = examples.count == 1 ? "Example" : "Examples" + return "Launchd inspection found \(errors.count) \(issueLabel). " + + "\(exampleLabel): \(examples.joined(separator: "; ")). " + + "Select an Invalid record for per-definition details." + } + + private static func invalidRecord( + root: LaunchdScanRoot, + sourceURL: URL, + ownerUID: uid_t, + checksum: String?, + finding: String + ) -> AutomationRecord { + let sourceURL = sourceURL.standardizedFileURL + let label = sourceURL.deletingPathExtension().lastPathComponent + let sourceKind: AutomationSourceKind = root.kind == .launchDaemon + ? .launchDaemon : .launchAgent + return AutomationRecord( + id: AutomationRecord.ID( + source: sourceKind, + ownerUID: ownerUID, + label: label, + sourcePath: sourceURL.path + ), + kind: root.kind, + sourceKind: sourceKind, + label: label, + displayName: label, + providerBundleIdentifier: nil, + ownerUID: ownerUID, + ownership: root.ownership, + executable: nil, + arguments: [], + environment: [:], + workingDirectory: nil, + schedule: AutomationSchedule(triggers: [.demand], summary: "Invalid definition"), + sourceURL: sourceURL, + sourceChecksum: checksum, + enabledState: .unknown, + loadState: .unknown, + approvalState: .notApplicable, + state: .invalid, + evidence: [AutomationEvidence( + strength: .weak, + source: "launchd property list", + detail: finding + )], + capabilities: [], + validationFindings: [finding] + ) + } + + private static func finding(for error: Error) -> String { + switch error as? AutomationParseError { + case .unreadablePropertyList: + return "Unreadable property list" + case .missingLabel: + return "Missing required Label" + case .missingProgram: + return "Missing Program, BundleProgram, or ProgramArguments" + case .invalidField(let field): + return "Invalid \(field)" + case nil: + return "Unable to parse launchd definition: \(error)" + } + } + + private static func checksum(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + + private static func isSafelyMutable( + root: LaunchdScanRoot, + metadata: AutomationFileMetadata, + canonicalURL: URL, + currentUID: uid_t + ) -> Bool { + guard + root.isMutable, + root.kind == .launchAgent, + root.ownership == .user, + metadata.ownerUID == currentUID, + !metadata.isSymbolicLink + else { + return false + } + + let rootComponents = canonical(root.url).pathComponents + let fileComponents = canonicalURL.pathComponents + return fileComponents.count > rootComponents.count + && fileComponents.prefix(rootComponents.count).elementsEqual(rootComponents) + } + + private static func canonical(_ url: URL) -> URL { + URL(fileURLWithPath: url.standardizedFileURL.path) + } +} + +public enum LaunchdPlistParser { + public static func parse( + data: Data, + sourceURL: URL, + ownerUID: uid_t, + ownership: AutomationOwnership + ) throws -> AutomationRecord { + let canonicalSourceURL = sourceURL.standardizedFileURL + let kind: AutomationKind = canonicalSourceURL.deletingLastPathComponent().lastPathComponent + == "LaunchDaemons" ? .launchDaemon : .launchAgent + return try parse( + data: data, + canonicalSourceURL: canonicalSourceURL, + ownerUID: ownerUID, + ownership: ownership, + kind: kind, + isMutable: false + ) + } + + static func parse( + data: Data, + canonicalSourceURL: URL, + ownerUID: uid_t, + ownership: AutomationOwnership, + kind: AutomationKind, + isMutable: Bool + ) throws -> AutomationRecord { + guard + let plist = try? PropertyListSerialization.propertyList( + from: data, + options: [], + format: nil + ) as? [String: Any] + else { + throw AutomationParseError.unreadablePropertyList + } + guard let labelValue = plist["Label"] else { + throw AutomationParseError.missingLabel + } + guard let label = labelValue as? String, !label.isEmpty else { + throw AutomationParseError.invalidField("Label") + } + + let program = try optionalNonemptyString("Program", in: plist) + let bundleProgram = try optionalNonemptyString("BundleProgram", in: plist) + let programArguments = try optionalStringArray("ProgramArguments", in: plist) + let environment = try optionalStringDictionary("EnvironmentVariables", in: plist) ?? [:] + let workingDirectory = try optionalNonemptyString("WorkingDirectory", in: plist) + let executable: String + let arguments: [String] + if let program { + guard program.hasPrefix("/") else { + throw AutomationParseError.invalidField("Program") + } + executable = program + arguments = programArguments ?? [] + } else if let bundleProgram { + guard !bundleProgram.hasPrefix("/") else { + throw AutomationParseError.invalidField("BundleProgram") + } + executable = bundleProgram + arguments = programArguments ?? [] + } else { + guard + let programArguments, + let firstArgument = programArguments.first, + !firstArgument.isEmpty + else { + throw AutomationParseError.missingProgram + } + executable = firstArgument + arguments = Array(programArguments.dropFirst()) + } + + let schedule = try parseSchedule(plist) + let sourceKind: AutomationSourceKind = kind == .launchDaemon ? .launchDaemon : .launchAgent + let checksum = SHA256.hash(data: data) + .map { String(format: "%02x", $0) } + .joined() + let capabilities: Set = isMutable + ? [.edit, .duplicate, .exportRecord, .remove] + : [.exportRecord] + + return AutomationRecord( + id: AutomationRecord.ID( + source: sourceKind, + ownerUID: ownerUID, + label: label, + sourcePath: canonicalSourceURL.path + ), + kind: kind, + sourceKind: sourceKind, + label: label, + displayName: label, + providerBundleIdentifier: nil, + ownerUID: ownerUID, + ownership: ownership, + executable: executable, + arguments: arguments, + environment: environment, + workingDirectory: workingDirectory, + schedule: schedule, + sourceURL: canonicalSourceURL, + sourceChecksum: checksum, + enabledState: .unknown, + loadState: .unknown, + approvalState: .notApplicable, + state: .unresolved, + evidence: [AutomationEvidence( + strength: .strong, + source: "launchd property list", + detail: "Parsed \(canonicalSourceURL.lastPathComponent)" + )], + capabilities: capabilities, + validationFindings: [] + ) + } + + private static func parseSchedule(_ plist: [String: Any]) throws -> AutomationSchedule { + var triggers: [AutomationSchedule.Trigger] = [] + var summaries: [String] = [] + + if let value = plist["RunAtLoad"] { + guard let runAtLoad = strictBoolean(value) else { + throw AutomationParseError.invalidField("RunAtLoad") + } + if runAtLoad { + triggers.append(.runAtLoad) + summaries.append("At load") + } + } + + if let value = plist["KeepAlive"] { + if let keepAlive = strictBoolean(value) { + if keepAlive { + if !triggers.contains(.runAtLoad) { + triggers.append(.runAtLoad) + summaries.append("At load") + } + triggers.append(.keepAlive) + summaries.append("kept alive") + } + } else if let conditions = value as? [String: Any] { + try validateKeepAliveConditions(conditions) + if !triggers.contains(.runAtLoad) { + triggers.append(.runAtLoad) + summaries.append("At load") + } + triggers.append(.keepAlive) + summaries.append("kept alive conditionally") + } else { + throw AutomationParseError.invalidField("KeepAlive") + } + } + + if let value = plist["StartInterval"] { + guard let seconds = strictInteger(value), seconds > 0 else { + throw AutomationParseError.invalidField("StartInterval") + } + triggers.append(.interval(seconds: seconds)) + summaries.append(intervalSummary(seconds)) + } + + if let value = plist["StartCalendarInterval"] { + let descriptions = try parseCalendarIntervals(value) + triggers.append(contentsOf: descriptions.map(AutomationSchedule.Trigger.calendar)) + summaries.append(descriptions.joined(separator: "; ")) + } + + if triggers.isEmpty { + return AutomationSchedule(triggers: [.demand], summary: "On demand") + } + return AutomationSchedule(triggers: triggers, summary: summaries.joined(separator: ", ")) + } + + private static func parseCalendarIntervals(_ value: Any) throws -> [String] { + if let fields = value as? [String: Any] { + return [try calendarSummary(fields)] + } + guard let intervals = value as? [[String: Any]], !intervals.isEmpty else { + throw AutomationParseError.invalidField("StartCalendarInterval") + } + return try intervals.map(calendarSummary) + } + + private static func validateKeepAliveConditions(_ conditions: [String: Any]) throws { + let booleanKeys: Set = ["SuccessfulExit", "NetworkState", "Crashed"] + let dictionaryKeys: Set = ["PathState", "OtherJobEnabled"] + guard Set(conditions.keys).isSubset(of: booleanKeys.union(dictionaryKeys)) else { + throw AutomationParseError.invalidField("KeepAlive") + } + + for (key, value) in conditions { + if booleanKeys.contains(key) { + guard strictBoolean(value) != nil else { + throw AutomationParseError.invalidField("KeepAlive") + } + } else { + guard + let dictionary = value as? [String: Any], + dictionary.values.allSatisfy({ strictBoolean($0) != nil }) + else { + throw AutomationParseError.invalidField("KeepAlive") + } + } + } + } + + private static func calendarSummary(_ fields: [String: Any]) throws -> String { + let supportedKeys: Set = ["Minute", "Hour", "Day", "Weekday", "Month"] + guard Set(fields.keys).isSubset(of: supportedKeys) else { + throw AutomationParseError.invalidField("StartCalendarInterval") + } + + let minute = try calendarInteger("Minute", in: fields, range: 0...59) + let hour = try calendarInteger("Hour", in: fields, range: 0...23) + let day = try calendarInteger("Day", in: fields, range: 1...31) + let weekday = try calendarInteger("Weekday", in: fields, range: 0...7) + let month = try calendarInteger("Month", in: fields, range: 1...12) + + let weekdayNames = [ + "Sundays", "Mondays", "Tuesdays", "Wednesdays", + "Thursdays", "Fridays", "Saturdays", "Sundays", + ] + let monthNames = [ + "", "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December", + ] + let weekdayDescription = weekday.map { weekdayNames[$0] } + let dateDescription: String + if let month { + let monthName = monthNames[month] + switch (day, weekdayDescription) { + case (.some(let day), .some(let weekdayDescription)): + dateDescription = "\(monthName) (day \(day) or \(weekdayDescription))" + case (.some(let day), .none): + dateDescription = "\(monthName) \(day)" + case (.none, .some(let weekdayDescription)): + dateDescription = "\(weekdayDescription) in \(monthName)" + case (.none, .none): + dateDescription = "Every \(monthName)" + } + } else { + switch (day, weekdayDescription) { + case (.some(let day), .some(let weekdayDescription)): + dateDescription = "\(weekdayDescription) or Day \(day) of every month" + case (.some(let day), .none): + dateDescription = "Day \(day) of every month" + case (.none, .some(let weekdayDescription)): + dateDescription = weekdayDescription + case (.none, .none): + dateDescription = "Every day" + } + } + + if let hour, let minute { + return "\(dateDescription) at \(String(format: "%02d:%02d", hour, minute))" + } + if let hour { + return "\(dateDescription) during hour \(String(format: "%02d", hour))" + } + if let minute { + return "\(dateDescription) at minute \(String(format: "%02d", minute)) of every hour" + } + return "\(dateDescription) every minute" + } + + private static func calendarInteger( + _ key: String, + in fields: [String: Any], + range: ClosedRange + ) throws -> Int? { + guard let value = fields[key] else { return nil } + guard let integer = strictInteger(value), range.contains(integer) else { + throw AutomationParseError.invalidField("StartCalendarInterval.\(key)") + } + return integer + } + + private static func optionalNonemptyString( + _ key: String, + in plist: [String: Any] + ) throws -> String? { + guard let value = plist[key] else { return nil } + guard let string = value as? String, !string.isEmpty else { + throw AutomationParseError.invalidField(key) + } + return string + } + + private static func optionalStringArray( + _ key: String, + in plist: [String: Any] + ) throws -> [String]? { + guard let value = plist[key] else { return nil } + guard let values = value as? [Any], values.allSatisfy({ $0 is String }) else { + throw AutomationParseError.invalidField(key) + } + return values.compactMap { $0 as? String } + } + + private static func optionalStringDictionary( + _ key: String, + in plist: [String: Any] + ) throws -> [String: String]? { + guard let value = plist[key] else { return nil } + guard let dictionary = value as? [String: Any], + dictionary.keys.allSatisfy({ !$0.isEmpty }), + dictionary.values.allSatisfy({ $0 is String }) else { + throw AutomationParseError.invalidField(key) + } + return dictionary.reduce(into: [String: String]()) { result, entry in + result[entry.key] = entry.value as? String + } + } + + private static func strictBoolean(_ value: Any) -> Bool? { + guard + let number = value as? NSNumber, + CFGetTypeID(number) == CFBooleanGetTypeID() + else { + return nil + } + return number.boolValue + } + + private static func strictInteger(_ value: Any) -> Int? { + guard + let number = value as? NSNumber, + CFGetTypeID(number) != CFBooleanGetTypeID(), + let integer = value as? Int + else { + return nil + } + return integer + } + + private static func intervalSummary(_ seconds: Int) -> String { + if seconds.isMultiple(of: 3_600) { + let hours = seconds / 3_600 + return "Every \(hours) \(hours == 1 ? "hour" : "hours")" + } + if seconds.isMultiple(of: 60) { + let minutes = seconds / 60 + return "Every \(minutes) \(minutes == 1 ? "minute" : "minutes")" + } + return "Every \(seconds) \(seconds == 1 ? "second" : "seconds")" + } +} diff --git a/Sources/DevScopeCore/LiveActivityLayoutPolicy.swift b/Sources/DevScopeCore/LiveActivityLayoutPolicy.swift new file mode 100644 index 0000000..5239c2c --- /dev/null +++ b/Sources/DevScopeCore/LiveActivityLayoutPolicy.swift @@ -0,0 +1,69 @@ +import Foundation + +public enum LiveActivityLayoutMode: Equatable, Sendable { + case wide + case stacked + case compact +} + +public enum LiveActivityVerticalMode: Equatable, Sendable { + case normal + case condensed +} + +public enum LiveActivityLayoutPolicy { + public static let defaultHeight = 190.0 + public static let minimumHeight = 120.0 + public static let maximumHeight = 360.0 + public static let minimumProcessHeight = 360.0 + private static let splitAllowance = 8.0 + private static let persistenceTolerance = 0.5 + private static let compactWidthThreshold = 800.0 + private static let condensedHeightThreshold = 160.0 + private static let stackedContentHeight = 225.0 + + public static func resolvedHeight(preferredHeight: Double, workspaceHeight: Double) -> Double { + let preferred = preferredHeight.isFinite ? preferredHeight : defaultHeight + let clampedPreference = min(max(preferred, minimumHeight), maximumHeight) + let available = max(minimumHeight, workspaceHeight - minimumProcessHeight - splitAllowance) + return min(clampedPreference, available) + } + + public static func shouldPersist(measuredHeight: Double, workspaceHeight: Double) -> Bool { + measuredHeight >= minimumHeight + && measuredHeight <= maximumHeight + && workspaceHeight > minimumProcessHeight + measuredHeight + splitAllowance + } + + public static func updatedPreferredHeight( + currentPreferredHeight: Double, + measuredHeight: Double, + workspaceHeight: Double + ) -> Double { + guard shouldPersist(measuredHeight: measuredHeight, workspaceHeight: workspaceHeight) else { + return currentPreferredHeight + } + guard + !currentPreferredHeight.isFinite + || abs(measuredHeight - currentPreferredHeight) >= persistenceTolerance + else { + return currentPreferredHeight + } + return measuredHeight + } + + public static func mode(availableWidth: Double) -> LiveActivityLayoutMode { + if availableWidth >= 1_000 { return .wide } + if availableWidth >= compactWidthThreshold { return .stacked } + return .compact + } + + public static func verticalMode( + availableHeight: Double, + layoutMode: LiveActivityLayoutMode + ) -> LiveActivityVerticalMode { + if availableHeight < condensedHeightThreshold { return .condensed } + if layoutMode == .stacked, availableHeight < stackedContentHeight { return .condensed } + return .normal + } +} diff --git a/Sources/DevScopeCore/LoginBackgroundAutomationSource.swift b/Sources/DevScopeCore/LoginBackgroundAutomationSource.swift new file mode 100644 index 0000000..6a12e61 --- /dev/null +++ b/Sources/DevScopeCore/LoginBackgroundAutomationSource.swift @@ -0,0 +1,532 @@ +import CryptoKit +import Darwin +import Foundation + +public struct LegacyLoginItemDescriptor: Equatable, Sendable { + public let name: String + public let path: String + public let isHidden: Bool + + public init(name: String, path: String, isHidden: Bool) { + self.name = name + self.path = path + self.isHidden = isHidden + } +} + +struct LegacyLoginItemRecoveryDescriptor: Codable, Equatable, Sendable { + let version: Int + let recordID: String + let ownerUID: uid_t + let name: String + let path: String + let isHidden: Bool +} + +public enum LegacyLoginItemRecoveryDocumentError: Error, Equatable, Sendable { + case invalidBinding +} + +public enum LegacyLoginItemRecoveryDocument { + public static func checksum(ofEncodedDocument data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + + public static func encode( + selectedRecord: AutomationRecord, + descriptor: LegacyLoginItemDescriptor, + currentUID: uid_t + ) throws -> Data { + guard descriptor.path.hasPrefix("/"), !descriptor.path.contains("\0") else { + throw LegacyLoginItemRecoveryDocumentError.invalidBinding + } + let canonicalPath = URL(fileURLWithPath: descriptor.path).standardizedFileURL.path + let expectedID = AutomationRecord.ID( + source: .legacyLoginItem, + ownerUID: currentUID, + label: descriptor.name, + sourcePath: canonicalPath + ) + guard selectedRecord.kind == .loginItem, + selectedRecord.sourceKind == .legacyLoginItem, + selectedRecord.ownership == .user, + selectedRecord.ownerUID == currentUID, + selectedRecord.id == expectedID, + selectedRecord.label == descriptor.name, + let executable = selectedRecord.executable, + URL(fileURLWithPath: executable).standardizedFileURL.path + == canonicalPath + else { throw LegacyLoginItemRecoveryDocumentError.invalidBinding } + let recovery = LegacyLoginItemRecoveryDescriptor( + version: 1, + recordID: selectedRecord.id.rawValue, + ownerUID: currentUID, + name: descriptor.name, + path: canonicalPath, + isHidden: descriptor.isHidden + ) + do { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + return try encoder.encode(recovery) + } catch { + throw LegacyLoginItemRecoveryDocumentError.invalidBinding + } + } +} + +public enum LegacyLoginItemAdapterError: Error, Equatable, Sendable { + case permissionDenied + case unavailable +} + +public protocol LegacyLoginItemListing: Sendable { + func currentUserLoginItems() async throws -> [LegacyLoginItemDescriptor] +} + +public struct OSACommandLegacyLoginItemAdapter: LegacyLoginItemListing { + private struct Item: Decodable { + let name: String + let path: String + let hidden: Bool + } + + private static let fixedProgram = """ + const systemEvents = Application('System Events'); + JSON.stringify(systemEvents.loginItems().map(item => ({ + name: item.name(), + path: item.path(), + hidden: item.hidden() + }))); + """ + + private let runner: any AutomationCommandRunning + + public init(runner: any AutomationCommandRunning) { + self.runner = runner + } + + public func currentUserLoginItems() async throws -> [LegacyLoginItemDescriptor] { + let result: AutomationCommandResult + do { + result = try await runner.run(AutomationCommand( + executable: "/usr/bin/osascript", + arguments: ["-l", "JavaScript", "-e", Self.fixedProgram] + )) + } catch { + throw LegacyLoginItemAdapterError.unavailable + } + + guard result.status == 0 else { + throw LegacyLoginItemAdapterError.permissionDenied + } + guard let items = try? JSONDecoder().decode([Item].self, from: result.standardOutput) else { + throw LegacyLoginItemAdapterError.unavailable + } + return items.map { + LegacyLoginItemDescriptor(name: $0.name, path: $0.path, isHidden: $0.hidden) + } + } +} + +public struct LegacyLoginItemAutomationSource: AutomationSource { + public let kind: AutomationSourceKind = .legacyLoginItem + + private let adapter: any LegacyLoginItemListing + private let currentUID: uid_t + private let now: @Sendable () -> Date + + public init( + adapter: any LegacyLoginItemListing, + currentUID: uid_t, + now: @escaping @Sendable () -> Date = Date.init + ) { + self.adapter = adapter + self.currentUID = currentUID + self.now = now + } + + public func snapshot() async -> AutomationSourceSnapshot { + let items: [LegacyLoginItemDescriptor] + do { + items = try await adapter.currentUserLoginItems() + } catch LegacyLoginItemAdapterError.permissionDenied { + return AutomationSourceSnapshot( + records: [], + health: AutomationSourceHealth( + kind: kind, + state: .permissionRequired, + message: "Allow DevScope to inspect current-user login items in System Settings.", + refreshedAt: now() + ) + ) + } catch { + return .failed( + kind: kind, + message: "Current-user login items are unavailable.", + refreshedAt: now() + ) + } + + var invalidCount = 0 + let records = items.compactMap { item -> AutomationRecord? in + guard !item.name.isEmpty, item.path.hasPrefix("/") else { + invalidCount += 1 + return nil + } + let provisionalRecord = Self.record( + for: item, + currentUID: currentUID, + sourceChecksum: nil + ) + guard let recoveryData = try? LegacyLoginItemRecoveryDocument.encode( + selectedRecord: provisionalRecord, + descriptor: item, + currentUID: currentUID + ) else { + invalidCount += 1 + return nil + } + return Self.record( + for: item, + currentUID: currentUID, + sourceChecksum: LegacyLoginItemRecoveryDocument.checksum( + ofEncodedDocument: recoveryData + ) + ) + } + let refreshDate = now() + return AutomationSourceSnapshot( + records: records, + health: AutomationSourceHealth( + kind: kind, + state: invalidCount == 0 ? .healthy : (records.isEmpty ? .failed : .partial), + message: invalidCount == 0 ? nil : "Some login-item records were not usable.", + refreshedAt: refreshDate + ) + ) + } + + private static func record( + for item: LegacyLoginItemDescriptor, + currentUID: uid_t, + sourceChecksum: String? + ) -> AutomationRecord { + let sourceURL = URL(fileURLWithPath: item.path).standardizedFileURL + return AutomationRecord( + id: AutomationRecord.ID( + source: .legacyLoginItem, + ownerUID: currentUID, + label: item.name, + sourcePath: sourceURL.path + ), + kind: .loginItem, + sourceKind: .legacyLoginItem, + label: item.name, + displayName: item.name, + providerBundleIdentifier: nil, + ownerUID: currentUID, + ownership: .user, + executable: sourceURL.path, + arguments: [], + environment: [:], + workingDirectory: nil, + schedule: AutomationSchedule(triggers: [.atLogin], summary: "At login"), + sourceURL: sourceURL, + sourceChecksum: sourceChecksum, + enabledState: .enabled, + loadState: .unknown, + approvalState: .notApplicable, + state: .unresolved, + evidence: [AutomationEvidence( + strength: .strong, + source: "Current-user legacy login items", + detail: item.isHidden ? "Configured hidden at login" : "Configured at login" + )], + capabilities: [.exportRecord], + validationFindings: [] + ) + } +} + +public enum BackgroundTaskDiagnosticPolicy: Equatable, Sendable { + case available + case administratorApprovalRequired + + public static var currentSystem: Self { + forOperatingSystemMajorVersion( + ProcessInfo.processInfo.operatingSystemVersion.majorVersion + ) + } + + public static func forOperatingSystemMajorVersion(_ majorVersion: Int) -> Self { + majorVersion >= 27 ? .administratorApprovalRequired : .available + } +} + +public actor BackgroundTaskAutomationSource: AutomationSource { + public nonisolated let kind: AutomationSourceKind = .serviceManagement + + private struct ParsedDiagnostic: Sendable { + let records: [AutomationRecord] + let boundaryCount: Int + let unusableCount: Int + let rawOutputHash: String + } + + private struct BoundedFields { + var values: [String: String] = [:] + var hasConflict = false + } + + private static let recognizedKeys: Set = [ + "Name", + "Identifier", + "URL", + "Bundle Identifier", + "Executable Path", + "Disposition", + "Parent Identifier", + "Team Identifier", + "Developer Name", + "Type", + ] + + private let runner: any AutomationCommandRunning + private let diagnosticPolicy: BackgroundTaskDiagnosticPolicy + private let now: @Sendable () -> Date + private var rawOutputHash: String? + + public init( + runner: any AutomationCommandRunning, + diagnosticPolicy: BackgroundTaskDiagnosticPolicy = .currentSystem, + now: @escaping @Sendable () -> Date = Date.init + ) { + self.runner = runner + self.diagnosticPolicy = diagnosticPolicy + self.now = now + } + + public func currentRawOutputHash() -> String? { + rawOutputHash + } + + public func snapshot() async -> AutomationSourceSnapshot { + guard diagnosticPolicy == .available else { + return AutomationSourceSnapshot( + records: [], + health: AutomationSourceHealth( + kind: kind, + state: .permissionRequired, + message: "macOS 27 or later requires administrator approval for this Background Items " + + "diagnostic. Review Background Items in System Settings; DevScope did not run the diagnostic.", + refreshedAt: now() + ) + ) + } + + let result: AutomationCommandResult + do { + result = try await runner.run(AutomationCommand( + executable: "/usr/bin/sfltool", + arguments: ["dumpbtm"] + )) + } catch { + return failedSnapshot(message: "Background Task Management diagnostic is unavailable.") + } + + rawOutputHash = Self.hash(result.standardOutput) + guard result.status == 0 else { + return failedSnapshot(message: "Background Task Management diagnostic failed.") + } + guard let parsed = Self.parse(result.standardOutput) else { + return failedSnapshot(message: "Background Task Management output could not be interpreted.") + } + rawOutputHash = parsed.rawOutputHash + + let refreshDate = now() + let healthState: AutomationSourceHealthState + let message: String? + if parsed.boundaryCount == 0 { + healthState = .failed + message = "Background Task Management record boundaries were not recognized." + } else if parsed.unusableCount > 0 { + healthState = parsed.records.isEmpty ? .failed : .partial + message = "Some Background Task Management records were unresolved." + } else { + healthState = .healthy + message = nil + } + return AutomationSourceSnapshot( + records: parsed.records, + health: AutomationSourceHealth( + kind: kind, + state: healthState, + message: message, + refreshedAt: refreshDate + ) + ) + } + + private func failedSnapshot(message: String) -> AutomationSourceSnapshot { + .failed(kind: kind, message: message, refreshedAt: now()) + } + + private static func parse(_ data: Data) -> ParsedDiagnostic? { + guard let text = String(data: data, encoding: .utf8) else { return nil } + var boundedRecords: [BoundedFields] = [] + var currentRecord: BoundedFields? + var driftCount = 0 + + for line in text.components(separatedBy: .newlines) { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if isRecordBoundary(trimmed) { + if let currentRecord { + boundedRecords.append(currentRecord) + } + currentRecord = BoundedFields() + continue + } + if isBoundaryLike(trimmed) { + if let currentRecord { + boundedRecords.append(currentRecord) + } + currentRecord = nil + driftCount += 1 + continue + } + guard currentRecord != nil, + let delimiter = trimmed.range(of: ": ") + else { continue } + let key = String(trimmed[.. AutomationRecord? in + guard !bounded.hasConflict else { return nil } + return record(from: bounded.values, fingerprint: fingerprint) + } + return ParsedDiagnostic( + records: records, + boundaryCount: boundedRecords.count, + unusableCount: boundedRecords.count - records.count + driftCount, + rawOutputHash: fingerprint + ) + } + + private static func isRecordBoundary(_ line: String) -> Bool { + guard line.first == "#", line.last == ":" else { return false } + let digits = line.dropFirst().dropLast() + return !digits.isEmpty && digits.utf8.allSatisfy { byte in + byte >= Character("0").asciiValue! && byte <= Character("9").asciiValue! + } + } + + private static func isBoundaryLike(_ line: String) -> Bool { + line.first == "#" && line.last == ":" + } + + private static func record( + from fields: [String: String], + fingerprint: String + ) -> AutomationRecord? { + let bundleIdentifier = fields["Bundle Identifier"] + let identifier = fields["Identifier"] + let executable = normalizedFilePath(fields["Executable Path"]) + let sourceURL = normalizedFileURL(fields["URL"]) + let hasStrongBundleIdentity = bundleIdentifier?.isEmpty == false + let hasStrongLabeledPathIdentity = identifier?.isEmpty == false + && (executable != nil || sourceURL != nil) + guard hasStrongBundleIdentity || hasStrongLabeledPathIdentity else { return nil } + + let label = identifier ?? bundleIdentifier! + let displayName = fields["Name"] ?? label + let identityPath = sourceURL?.path ?? executable ?? "bundle:\(bundleIdentifier!)" + var evidence: [AutomationEvidence] = [] + if let bundleIdentifier { + evidence.append(AutomationEvidence( + strength: .strong, + source: "Background Task Management diagnostic", + detail: "Exact bundle identifier: \(bundleIdentifier)" + )) + } + if let identifier { + evidence.append(AutomationEvidence( + strength: executable == nil && sourceURL == nil ? .weak : .strong, + source: "Background Task Management diagnostic", + detail: "Exact item identifier: \(identifier)" + )) + } + for key in [ + "Disposition", "Parent Identifier", "Team Identifier", "Developer Name", "Type", + ] { + if let value = fields[key] { + evidence.append(AutomationEvidence( + strength: .weak, + source: "Background Task Management diagnostic \(key)", + detail: value + )) + } + } + + return AutomationRecord( + id: AutomationRecord.ID( + source: .serviceManagement, + ownerUID: 0, + label: label, + sourcePath: identityPath + ), + kind: .backgroundItem, + sourceKind: .serviceManagement, + label: label, + displayName: displayName, + providerBundleIdentifier: bundleIdentifier, + ownerUID: nil, + ownership: .thirdPartySystem, + executable: executable, + arguments: [], + environment: [:], + workingDirectory: nil, + schedule: AutomationSchedule(triggers: [.demand], summary: "System-managed background item"), + sourceURL: sourceURL, + sourceChecksum: fingerprint, + enabledState: .unknown, + loadState: .unknown, + approvalState: .unknown, + state: .unresolved, + evidence: evidence, + capabilities: [.exportRecord], + validationFindings: [] + ) + } + + private static func normalizedFileURL(_ value: String?) -> URL? { + guard let value else { return nil } + if value.hasPrefix("/") { + return URL(fileURLWithPath: value).standardizedFileURL + } + guard let url = URL(string: value), url.isFileURL else { return nil } + return url.standardizedFileURL + } + + private static func normalizedFilePath(_ value: String?) -> String? { + normalizedFileURL(value)?.path + } + + private static func hash(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } +} diff --git a/Sources/DevScopeCore/LongRunningAssessment.swift b/Sources/DevScopeCore/LongRunningAssessment.swift new file mode 100644 index 0000000..5a3fa32 --- /dev/null +++ b/Sources/DevScopeCore/LongRunningAssessment.swift @@ -0,0 +1,15 @@ +import Foundation + +public enum LongRunningAssessment { + public static func isLongRunning( + _ usage: DevProcessResourceUsage?, + threshold: TimeInterval = 14_400 + ) -> Bool { + guard let usage else { + return false + } + + let seconds = ProcessPresentation.elapsedSeconds(usage.elapsedTime) + return seconds >= 0 && Double(seconds) >= threshold + } +} diff --git a/Sources/DevScopeCore/PersistentCopyStore.swift b/Sources/DevScopeCore/PersistentCopyStore.swift new file mode 100644 index 0000000..5a47b56 --- /dev/null +++ b/Sources/DevScopeCore/PersistentCopyStore.swift @@ -0,0 +1,133 @@ +import Foundation + +public enum PersistentCopyStoreError: Error, Equatable, Sendable { + case payloadTooLarge +} + +public struct PersistedCopyPayload: Codable, Equatable, Sendable { + public let label: String + public let text: String + public let timestamp: Date + public let isTruncated: Bool + + public init(label: String, text: String, timestamp: Date = Date(), isTruncated: Bool = false) { + self.label = label + self.text = text + self.timestamp = timestamp + self.isTruncated = isTruncated + } +} + +public struct PersistentCopyStore: Sendable { + public let directory: URL + public let maxStoredBytes: Int + private let maxReadablePayloadBytes: Int + + private var payloadURL: URL { + directory.appendingPathComponent("last-copy.json", isDirectory: false) + } + + public init(directory: URL? = nil, maxStoredBytes: Int = 2_000_000) { + if let directory { + self.directory = directory + } else { + let baseDirectory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first ?? + FileManager.default.temporaryDirectory + self.directory = baseDirectory + .appendingPathComponent("DevScope", isDirectory: true) + .appendingPathComponent("Clipboard", isDirectory: true) + } + self.maxStoredBytes = max(0, maxStoredBytes) + let (escapedTextLimit, escapedTextOverflow) = self.maxStoredBytes.multipliedReportingOverflow(by: 6) + let encodedTextLimit = escapedTextOverflow ? Int.max - 1 : escapedTextLimit + let (readLimit, readLimitOverflow) = encodedTextLimit.addingReportingOverflow(64 * 1_024) + self.maxReadablePayloadBytes = readLimitOverflow ? Int.max - 1 : readLimit + } + + @discardableResult + public func save(text: String, label: String, timestamp: Date = Date()) throws -> PersistedCopyPayload { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: directory.path) + + let bounded = boundedText(text) + let payload = PersistedCopyPayload( + label: label, + text: bounded.text, + timestamp: timestamp, + isTruncated: bounded.isTruncated + ) + let data = try JSONEncoder.devScopeCopyStore.encode(payload) + guard data.count <= maxReadablePayloadBytes else { + throw PersistentCopyStoreError.payloadTooLarge + } + try data.write(to: payloadURL, options: [.atomic]) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: payloadURL.path) + return payload + } + + public func load() throws -> PersistedCopyPayload? { + guard FileManager.default.fileExists(atPath: payloadURL.path) else { + return nil + } + + let handle = try FileHandle(forReadingFrom: payloadURL) + defer { try? handle.close() } + let data = try handle.read(upToCount: maxReadablePayloadBytes + 1) ?? Data() + guard data.count <= maxReadablePayloadBytes else { + throw PersistentCopyStoreError.payloadTooLarge + } + return try JSONDecoder.devScopeCopyStore.decode(PersistedCopyPayload.self, from: data) + } + + public func clear() throws { + guard FileManager.default.fileExists(atPath: payloadURL.path) else { + return + } + + try FileManager.default.removeItem(at: payloadURL) + } + + private func boundedText(_ text: String) -> (text: String, isTruncated: Bool) { + let data = Data(text.utf8) + guard data.count > maxStoredBytes else { + return (text, false) + } + + let suffix = "\n[truncated for DevScope recovery cache]" + let suffixData = Data(suffix.utf8).prefix(maxStoredBytes) + let prefixLimit = max(0, maxStoredBytes - suffixData.count) + var boundedData = validUTF8Prefix(of: data, maxBytes: prefixLimit) + boundedData.append(suffixData) + + return (String(decoding: boundedData, as: UTF8.self), true) + } + + private func validUTF8Prefix(of data: Data, maxBytes: Int) -> Data { + var end = min(data.count, maxBytes) + while end > 0 { + let candidate = Data(data.prefix(end)) + if String(data: candidate, encoding: .utf8) != nil { + return candidate + } + end -= 1 + } + return Data() + } +} + +private extension JSONEncoder { + static var devScopeCopyStore: JSONEncoder { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + return encoder + } +} + +private extension JSONDecoder { + static var devScopeCopyStore: JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + } +} diff --git a/Sources/DevScopeCore/ProcessAccessAssessment.swift b/Sources/DevScopeCore/ProcessAccessAssessment.swift new file mode 100644 index 0000000..9cb7e5d --- /dev/null +++ b/Sources/DevScopeCore/ProcessAccessAssessment.swift @@ -0,0 +1,142 @@ +import Foundation + +public enum ProcessAccessRequirementKind: String, Sendable { + case processMetadata + case workingDirectories +} + +public enum ProcessAccessRequirementState: String, Sendable { + case ready + case needed + case blocked +} + +public enum ProcessAccessAction: String, Sendable { + case privacySecurity + case fullDiskAccess +} + +public struct ProcessAccessRequirement: Equatable, Sendable { + public let kind: ProcessAccessRequirementKind + public let state: ProcessAccessRequirementState + public let title: String + public let detail: String + public let action: ProcessAccessAction? + + public init( + kind: ProcessAccessRequirementKind, + state: ProcessAccessRequirementState, + title: String, + detail: String, + action: ProcessAccessAction? + ) { + self.kind = kind + self.state = state + self.title = title + self.detail = detail + self.action = action + } +} + +public struct ProcessAccessAssessment: Equatable, Sendable { + public let isSandboxed: Bool + public let processCount: Int + public let currentDirectoryCount: Int + public let scanErrorDescription: String? + public let requirements: [ProcessAccessRequirement] + + public var hasNeededAction: Bool { + requirements.contains { $0.action != nil && $0.state == .needed } + } + + public var hasBlockedRequirement: Bool { + requirements.contains { $0.state == .blocked } + } + + public init( + isSandboxed: Bool, + processCount: Int, + currentDirectoryCount: Int, + scanErrorDescription: String?, + requirements: [ProcessAccessRequirement] + ) { + self.isSandboxed = isSandboxed + self.processCount = processCount + self.currentDirectoryCount = currentDirectoryCount + self.scanErrorDescription = scanErrorDescription + self.requirements = requirements + } + + public static func assess( + isSandboxed: Bool, + processes: [DevProcess]?, + errorDescription: String? + ) -> ProcessAccessAssessment { + let processCount = processes?.count ?? 0 + let currentDirectoryCount = processes?.filter { $0.currentDirectory != nil }.count ?? 0 + var requirements: [ProcessAccessRequirement] = [] + + if isSandboxed { + requirements.append( + ProcessAccessRequirement( + kind: .processMetadata, + state: .blocked, + title: "Process Metadata", + detail: "Blocked by App Sandbox. There is no macOS privacy permission that grants unrestricted process inspection to this build.", + action: nil + ) + ) + return ProcessAccessAssessment( + isSandboxed: isSandboxed, + processCount: processCount, + currentDirectoryCount: currentDirectoryCount, + scanErrorDescription: errorDescription, + requirements: requirements + ) + } + + if processCount > 0 { + requirements.append( + ProcessAccessRequirement( + kind: .processMetadata, + state: .ready, + title: "Process Metadata", + detail: "\(processCount) local processes are visible to DevScope.", + action: nil + ) + ) + } else { + requirements.append( + ProcessAccessRequirement( + kind: .processMetadata, + state: .blocked, + title: "Process Metadata", + detail: errorDescription ?? "macOS is not returning process metadata to DevScope. This is not a known user-grantable Privacy & Security permission.", + action: nil + ) + ) + } + + if processCount > 0 { + requirements.append( + ProcessAccessRequirement( + kind: .workingDirectories, + state: currentDirectoryCount > 0 ? .ready : .needed, + title: "Working Directories", + detail: currentDirectoryCount > 0 + ? "\(currentDirectoryCount) process folders are visible for project grouping." + : "Process rows are visible, but folder context is hidden. Grant Full Disk Access only if you need project folders and richer grouping.", + action: currentDirectoryCount > 0 ? nil : .fullDiskAccess + ) + ) + } + + return ProcessAccessAssessment( + isSandboxed: isSandboxed, + processCount: processCount, + currentDirectoryCount: currentDirectoryCount, + scanErrorDescription: errorDescription, + requirements: requirements + ) + } +} diff --git a/Sources/DevScopeCore/ProcessActionPolicy.swift b/Sources/DevScopeCore/ProcessActionPolicy.swift new file mode 100644 index 0000000..e0aee97 --- /dev/null +++ b/Sources/DevScopeCore/ProcessActionPolicy.swift @@ -0,0 +1,40 @@ +import Foundation + +public enum ProcessActionDecision: Equatable, Sendable { + case allowed + case protected(reason: String) + + public var isAllowed: Bool { + if case .allowed = self { return true } + return false + } + + public var reason: String? { + guard case .protected(let reason) = self else { return nil } + return reason + } +} + +public enum ProcessActionPolicy { + private static let protectedExecutables: Set = [ + "kernel_task", "launchd", "loginwindow", "WindowServer", "runningboardd", + "securityd", "tccd", "opendirectoryd", "powerd", + ] + + public static func decision( + for item: ClassifiedDevProcess, + currentProcessID: Int32 + ) -> ProcessActionDecision { + let process = item.process + if process.pid == currentProcessID { + return .protected(reason: "DevScope cannot terminate itself") + } + if process.pid == 0 || process.pid == 1 || process.executableName == "launchd" { + return .protected(reason: "macOS launch infrastructure is protected") + } + if protectedExecutables.contains(process.executableName) { + return .protected(reason: "Critical macOS system infrastructure is protected") + } + return .allowed + } +} diff --git a/Sources/DevScopeCore/ProcessActionPresentation.swift b/Sources/DevScopeCore/ProcessActionPresentation.swift new file mode 100644 index 0000000..36c5682 --- /dev/null +++ b/Sources/DevScopeCore/ProcessActionPresentation.swift @@ -0,0 +1,76 @@ +public enum ProcessActionControl: CaseIterable, Sendable { + case terminate + case terminateTree + case forceOptions +} + +public struct ProcessActionControlPresentation: Equatable, Sendable { + public let isDisabled: Bool + public let help: String + public let accessibilityHint: String +} + +public enum ProcessActionPresentation { + public static func control( + _ control: ProcessActionControl, + isEnded: Bool, + actionDecision: ProcessActionDecision + ) -> ProcessActionControlPresentation { + if isEnded { + return ProcessActionControlPresentation( + isDisabled: true, + help: "Unavailable: This process has ended.", + accessibilityHint: "Unavailable because the selected process has ended." + ) + } + if let reason = actionDecision.reason { + let guidance = "Unavailable: \(reason)" + return ProcessActionControlPresentation( + isDisabled: true, + help: guidance, + accessibilityHint: guidance + ) + } + + switch control { + case .terminate: + return ProcessActionControlPresentation( + isDisabled: !actionDecision.isAllowed, + help: "Requests confirmation before sending SIGTERM.", + accessibilityHint: "Requests confirmation before sending SIGTERM to the selected process." + ) + case .terminateTree: + return ProcessActionControlPresentation( + isDisabled: !actionDecision.isAllowed, + help: "Requests confirmation before sending SIGTERM to the process tree.", + accessibilityHint: "Requests confirmation before sending SIGTERM to the selected process and its descendants." + ) + case .forceOptions: + return ProcessActionControlPresentation( + isDisabled: !actionDecision.isAllowed, + help: "Force kill options", + accessibilityHint: "Opens destructive SIGKILL options for the selected process." + ) + } + } +} + +public enum ProcessTerminationAction: CaseIterable, Sendable { + case single + case tree + case forceSingle + case forceTree + + public func consequence(pid: Int32, descendantCount: Int) -> String { + switch self { + case .single: + "Sends SIGTERM to PID \(pid). The process may save state before exiting." + case .tree: + "Sends SIGTERM to PID \(pid) after \(descendantCount) descendant process\(descendantCount == 1 ? "" : "es")." + case .forceSingle: + "Sends SIGKILL to PID \(pid). The process cannot save state." + case .forceTree: + "Sends SIGKILL to PID \(pid) and \(descendantCount) descendant process\(descendantCount == 1 ? "" : "es"). None can save state." + } + } +} diff --git a/Sources/DevScopeCore/ProcessCacheIdentity.swift b/Sources/DevScopeCore/ProcessCacheIdentity.swift new file mode 100644 index 0000000..5591b86 --- /dev/null +++ b/Sources/DevScopeCore/ProcessCacheIdentity.swift @@ -0,0 +1,26 @@ +public struct ProcessCacheIdentity: Equatable, Hashable, Sendable { + private enum Lifecycle: Equatable, Hashable, Sendable { + case knownBirth(ProcessBirthToken) + case fallback( + parentPID: Int32, + executable: String, + command: String + ) + } + + public let pid: Int32 + private let lifecycle: Lifecycle + + public init(process: DevProcess) { + pid = process.pid + if let birthToken = process.birthToken { + lifecycle = .knownBirth(birthToken) + } else { + lifecycle = .fallback( + parentPID: process.parentPID, + executable: process.executable, + command: process.command + ) + } + } +} diff --git a/Sources/DevScopeCore/ProcessClassificationCache.swift b/Sources/DevScopeCore/ProcessClassificationCache.swift new file mode 100644 index 0000000..c316efc --- /dev/null +++ b/Sources/DevScopeCore/ProcessClassificationCache.swift @@ -0,0 +1,117 @@ +import Foundation + +public struct ProcessIdentityFingerprint: Hashable, Sendable { + public let parentPID: Int32 + public let executable: String + public let command: String + public let currentDirectory: String? + public let birthToken: ProcessBirthToken? + public let workspaceFacts: WorkspaceFacts? + + public init(process: DevProcess, workspaceFacts: WorkspaceFacts? = nil) { + parentPID = process.parentPID + executable = process.executable + command = process.command + currentDirectory = process.currentDirectory + birthToken = process.birthToken + self.workspaceFacts = workspaceFacts + } +} + +public struct ProcessClassificationCache: Sendable { + private struct Entry: Sendable { + let fingerprint: ProcessIdentityFingerprint + let classification: DevProcessClassification + } + + private var entries: [Int32: Entry] = [:] + + public var cachedProcessIDs: Set { + Set(entries.keys) + } + + public init() {} + + public mutating func invalidateAll() { + entries.removeAll(keepingCapacity: true) + } + + public mutating func classified(_ processes: [DevProcess]) -> [ClassifiedDevProcess] { + classified(processes) { process in + ProcessClassifier.classify(process) + } + } + + public mutating func classified( + _ processes: [DevProcess], + workspaceFactsCache: WorkspaceFactsCache + ) -> [ClassifiedDevProcess] { + classified(processes, workspaceFactsCache: workspaceFactsCache) { process, workspaceFacts in + ProcessClassifier.classify(process, workspaceFacts: workspaceFacts) + } + } + + public mutating func classified( + _ processes: [DevProcess], + workspaceFactsCache: WorkspaceFactsCache, + classifier: (DevProcess, WorkspaceFacts) -> DevProcessClassification? + ) -> [ClassifiedDevProcess] { + workspaceFactsCache.withRevalidationCycle(maximumStaleEntries: 8) { + classified( + processes, + context: { process in + workspaceFactsCache.facts(for: process.currentDirectory) + }, + fingerprint: { process, workspaceFacts in + ProcessIdentityFingerprint(process: process, workspaceFacts: workspaceFacts) + }, + classifier: classifier + ) + } + } + + public mutating func classified( + _ processes: [DevProcess], + classifier: (DevProcess) -> DevProcessClassification? + ) -> [ClassifiedDevProcess] { + classified( + processes, + context: { _ in () }, + fingerprint: { process, _ in + ProcessIdentityFingerprint(process: process) + }, + classifier: { process, _ in + classifier(process) + } + ) + } + + private mutating func classified( + _ processes: [DevProcess], + context: (DevProcess) -> Context, + fingerprint: (DevProcess, Context) -> ProcessIdentityFingerprint, + classifier: (DevProcess, Context) -> DevProcessClassification? + ) -> [ClassifiedDevProcess] { + let activeIDs = Set(processes.map(\.pid)) + entries = entries.filter { activeIDs.contains($0.key) } + + return processes.compactMap { process in + let classificationContext = context(process) + let fingerprint = fingerprint(process, classificationContext) + let classification: DevProcessClassification + + if let cached = entries[process.pid], cached.fingerprint == fingerprint { + classification = cached.classification + } else { + entries[process.pid] = nil + guard let fresh = classifier(process, classificationContext) else { + return nil + } + entries[process.pid] = Entry(fingerprint: fingerprint, classification: fresh) + classification = fresh + } + + return ClassifiedDevProcess(process: process, classification: classification) + } + } +} diff --git a/Sources/DevScopeCore/ProcessClassifier.swift b/Sources/DevScopeCore/ProcessClassifier.swift new file mode 100644 index 0000000..136b894 --- /dev/null +++ b/Sources/DevScopeCore/ProcessClassifier.swift @@ -0,0 +1,954 @@ +import Foundation + +public enum ProcessClassifier { + public static func classify( + _ process: DevProcess, + workspaceFacts: WorkspaceFacts? = nil + ) -> DevProcessClassification? { + let command = process.command.trimmingCharacters(in: .whitespacesAndNewlines) + let tokens = tokenize(command) + let lowerTokens = tokens.map { $0.lowercased() } + let executableName = process.executableName.lowercased() + let haystack = ([executableName] + lowerTokens).joined(separator: " ") + + if isJavaScriptProcess(executableName: executableName, tokens: lowerTokens, haystack: haystack) { + let kind = DevRuntimeKind.javascript + return DevProcessClassification( + kind: kind, + displayName: displayName(for: tokens, lowerTokens: lowerTokens, fallback: process.executableName), + projectHint: projectHint(from: command), + tags: tags(for: kind, executableName: executableName, tokens: lowerTokens, haystack: haystack) + ) + } + + if isPythonProcess(executableName: executableName, tokens: lowerTokens, haystack: haystack) { + let kind = DevRuntimeKind.python + return DevProcessClassification( + kind: kind, + displayName: displayName(for: tokens, lowerTokens: lowerTokens, fallback: process.executableName), + projectHint: projectHint(from: command), + tags: tags(for: kind, executableName: executableName, tokens: lowerTokens, haystack: haystack) + ) + } + + if isNativeBuildProcess( + executableName: executableName, + tokens: lowerTokens, + haystack: haystack, + currentDirectory: process.currentDirectory, + workspaceFacts: workspaceFacts + ) { + let kind = nativeBuildKind(executableName: executableName, tokens: lowerTokens) + return DevProcessClassification( + kind: kind, + displayName: displayName(for: tokens, lowerTokens: lowerTokens, fallback: process.executableName), + projectHint: projectHint(from: process.currentDirectory ?? command), + tags: tags(for: kind, executableName: executableName, tokens: lowerTokens, haystack: haystack) + ) + } + + if isFlutterProcess( + executableName: executableName, + tokens: lowerTokens, + haystack: haystack, + currentDirectory: process.currentDirectory, + workspaceFacts: workspaceFacts + ) { + let kind = DevRuntimeKind.flutter + return DevProcessClassification( + kind: kind, + displayName: flutterDisplayName(tokens: tokens, lowerTokens: lowerTokens, fallback: process.executableName), + projectHint: projectHint(from: process.currentDirectory ?? command), + tags: tags(for: kind, executableName: executableName, tokens: lowerTokens, haystack: haystack) + ) + } + + if isDatabaseProcess(executableName: executableName, tokens: lowerTokens, haystack: haystack) { + let kind = DevRuntimeKind.database + return DevProcessClassification( + kind: kind, + displayName: displayName(for: tokens, lowerTokens: lowerTokens, fallback: process.executableName), + projectHint: projectHint(from: process.currentDirectory ?? command), + tags: tags(for: kind, executableName: executableName, tokens: lowerTokens, haystack: haystack) + ) + } + + if isContainerProcess(executableName: executableName, tokens: lowerTokens, haystack: haystack) { + let kind = DevRuntimeKind.container + return DevProcessClassification( + kind: kind, + displayName: displayName(for: tokens, lowerTokens: lowerTokens, fallback: process.executableName), + projectHint: projectHint(from: process.currentDirectory ?? command), + tags: tags(for: kind, executableName: executableName, tokens: lowerTokens, haystack: haystack) + ) + } + + if isWebServerProcess(executableName: executableName, tokens: lowerTokens, haystack: haystack) { + let kind = DevRuntimeKind.webServer + return DevProcessClassification( + kind: kind, + displayName: displayName(for: tokens, lowerTokens: lowerTokens, fallback: process.executableName), + projectHint: projectHint(from: process.currentDirectory ?? command), + tags: tags(for: kind, executableName: executableName, tokens: lowerTokens, haystack: haystack) + ) + } + + if isAIProcess(executableName: executableName, tokens: lowerTokens, haystack: haystack) { + let kind = DevRuntimeKind.ai + return DevProcessClassification( + kind: kind, + displayName: aiDisplayName(executableName: executableName, tokens: lowerTokens, haystack: haystack, fallback: process.executableName), + projectHint: projectHint(from: process.currentDirectory ?? command), + tags: tags(for: kind, executableName: executableName, tokens: lowerTokens, haystack: haystack) + ) + } + + if isMCPProcess(tokens: lowerTokens, haystack: haystack) { + let kind = DevRuntimeKind.mcp + return DevProcessClassification( + kind: kind, + displayName: displayName(for: tokens, lowerTokens: lowerTokens, fallback: process.executableName), + projectHint: projectHint(from: process.currentDirectory ?? command), + tags: tags(for: kind, executableName: executableName, tokens: lowerTokens, haystack: haystack) + ) + } + + if isDevShellProcess(executableName: executableName, currentDirectory: process.currentDirectory) { + let kind = DevRuntimeKind.shell + return DevProcessClassification( + kind: kind, + displayName: shellDisplayName(executableName, tokens: tokens, lowerTokens: lowerTokens), + projectHint: projectHint(from: process.currentDirectory ?? command), + tags: tags(for: kind, executableName: executableName, tokens: lowerTokens, haystack: haystack) + ) + } + + if isDevWorkspace(process.currentDirectory) { + let kind = DevRuntimeKind.other + return DevProcessClassification( + kind: kind, + displayName: displayName(for: tokens, lowerTokens: lowerTokens, fallback: process.executableName), + projectHint: projectHint(from: process.currentDirectory ?? command), + tags: tags(for: kind, executableName: executableName, tokens: lowerTokens, haystack: haystack) + ) + } + + return generalActivityClassification( + process: process, + executableName: executableName, + tokens: tokens, + lowerTokens: lowerTokens, + haystack: haystack + ) + } + + public static func classified(_ processes: [DevProcess]) -> [ClassifiedDevProcess] { + processes.compactMap { process in + guard let classification = classify(process) else { + return nil + } + return ClassifiedDevProcess(process: process, classification: classification) + } + } + + private static func isJavaScriptProcess(executableName: String, tokens: [String], haystack: String) -> Bool { + let executableMatches = ["node", "npm", "pnpm", "yarn", "bun", "deno", "tsx", "ts-node", "next", "vite", "electron"].contains(executableName) + let commandMatches = tokens.contains("next") || tokens.contains("vite") || tokens.contains("tsx") || tokens.contains("ts-node") || haystack.contains("/.bin/next") || haystack.contains("/.bin/vite") + let appMatches = haystack.contains("visual studio code.app/contents/macos/electron") || + haystack.contains("visual studio code.app/contents/macos/code") || + haystack.contains("cursor.app/contents/macos/cursor") || + haystack.contains("zed.app/contents/macos/zed") + return executableMatches || commandMatches || appMatches + } + + private static func isPythonProcess(executableName: String, tokens: [String], haystack: String) -> Bool { + let executableMatches = [ + "python", + "pytest", + "uvicorn", + "gunicorn", + "jupyter", + "ipykernel", + "torchrun", + "accelerate", + "streamlit", + "gradio", + "mlflow", + "tensorboard" + ].contains(executableName) || executableName.hasPrefix("python3") || executableName.contains("python") + let commandMatches = tokens.contains("pytest") || + tokens.contains("uvicorn") || + tokens.contains("gunicorn") || + tokens.contains("jupyter") || + tokens.contains("torchrun") || + tokens.contains("accelerate") || + tokens.contains("streamlit") || + tokens.contains("gradio") || + tokens.contains("mlflow") || + tokens.contains("tensorboard") || + haystack.contains("ipykernel_launcher") || + haystack.contains(".py") + return executableMatches || commandMatches + } + + private static func isNativeBuildProcess( + executableName: String, + tokens: [String], + haystack: String, + currentDirectory: String?, + workspaceFacts: WorkspaceFacts? + ) -> Bool { + let trustedExecutableMatches = ["swift", "swift-frontend", "sourcekit-lsp", "xcodebuild", "cargo", "rustc", "rustup"].contains(executableName) + let workspaceExecutableMatches = ["go", "air"].contains(executableName) && isDevWorkspace(currentDirectory) + let trustedCommandMatches = tokenIndex(matching: "swift", in: tokens) != nil || tokenIndex(matching: "xcodebuild", in: tokens) != nil || tokenIndex(matching: "cargo", in: tokens) != nil || tokenIndex(matching: "rustc", in: tokens) != nil || haystack.contains("cargo watch") + let appMatches = !isFlutterWorkspace(currentDirectory, workspaceFacts: workspaceFacts) && + ( + haystack.contains("xcode.app") || + haystack.contains("android studio.app") + ) + let workspaceCommandMatches = (isDirectToolInvocation("go", in: tokens) || isDirectToolInvocation("air", in: tokens) || ((tokenIndex(matching: "go", in: tokens) != nil || tokenIndex(matching: "air", in: tokens) != nil) && isDevWorkspace(currentDirectory))) + let executableMatches = trustedExecutableMatches || workspaceExecutableMatches + let commandMatches = trustedCommandMatches || workspaceCommandMatches + return executableMatches || commandMatches || appMatches + } + + private static func nativeBuildKind(executableName: String, tokens: [String]) -> DevRuntimeKind { + if executableName == "swift" || executableName == "swift-frontend" || executableName == "sourcekit-lsp" || executableName == "xcodebuild" || executableName == "xcode" || tokenIndex(matching: "swift", in: tokens) != nil || tokenIndex(matching: "xcodebuild", in: tokens) != nil { + return .swift + } + if executableName == "cargo" || executableName == "rustc" || executableName == "rustup" || tokenIndex(matching: "cargo", in: tokens) != nil || tokenIndex(matching: "rustc", in: tokens) != nil { + return .rust + } + if executableName == "go" || executableName == "air" || tokenIndex(matching: "go", in: tokens) != nil || tokenIndex(matching: "air", in: tokens) != nil { + return .go + } + return .other + } + + private static func isFlutterProcess( + executableName: String, + tokens: [String], + haystack: String, + currentDirectory: String?, + workspaceFacts: WorkspaceFacts? + ) -> Bool { + let executableMatches = ["flutter", "dart", "dartvm"].contains(executableName) + let commandMatches = tokenIndex(matching: "flutter", in: tokens) != nil || + tokenIndex(matching: "dart", in: tokens) != nil || + haystack.contains("flutter_tools.snapshot") || + haystack.contains("frontend_server.dart.snapshot") || + haystack.contains("dartdevc") || + haystack.contains(".dart_tool/") || + haystack.contains("flutter") || + haystack.contains("dart") + let workspaceMatches = isFlutterWorkspace(currentDirectory, workspaceFacts: workspaceFacts) + return executableMatches || commandMatches || workspaceMatches + } + + private static func flutterDisplayName(tokens: [String], lowerTokens: [String], fallback: String) -> String { + if let flutterIndex = tokenIndex(matching: "flutter", in: lowerTokens) { + let command = lowerTokens.dropFirst(flutterIndex + 1).first { !$0.hasPrefix("-") } + return command.map { "flutter \($0)" } ?? "flutter" + } + if let dartIndex = tokenIndex(matching: "dart", in: lowerTokens) { + let command = lowerTokens.dropFirst(dartIndex + 1).first { !$0.hasPrefix("-") } + return command.map { "dart \($0)" } ?? "dart" + } + if let appName = macOSAppDisplayName(in: tokens) { + return appName + } + return fallback + } + + private static func isDatabaseProcess(executableName: String, tokens: [String], haystack: String) -> Bool { + ["postgres", "postmaster", "redis-server", "mongod", "mysql", "mysqld", "mariadbd", "clickhouse", "influxd"].contains(executableName) || tokens.contains("postgres") || tokens.contains("redis-server") || haystack.contains("/postgres") || haystack.contains("/redis") + } + + private static func isContainerProcess(executableName: String, tokens: [String], haystack: String) -> Bool { + ["docker", "dockerd", "containerd", "colima", "podman", "kubectl"].contains(executableName) || tokens.contains("docker") || tokens.contains("kubectl") || haystack.contains("/docker") || haystack.contains("docker.app") || haystack.contains("orbstack.app") + } + + private static func isWebServerProcess(executableName: String, tokens: [String], haystack: String) -> Bool { + ["nginx", "caddy", "httpd", "apache2"].contains(executableName) || tokens.contains("nginx") || tokens.contains("caddy") || haystack.contains("apache") + } + + private static func isAIProcess(executableName: String, tokens: [String], haystack: String) -> Bool { + ["ollama", "llama-server", "lmstudio"].contains(executableName) || tokens.contains("ollama") || haystack.contains("llama") || haystack.contains("lm studio.app") || haystack.contains("ollama.app") + } + + private static func isMCPProcess(tokens: [String], haystack: String) -> Bool { + (tokens.contains("mcp") || tokens.contains("--stdio") || tokens.contains("stdio") || haystack.contains("/mcp/")) && + ( + tokenIndex(matching: "basic-memory", in: tokens) != nil || + tokenIndex(matching: "gk", in: tokens) != nil || + tokenIndex(matching: "node", in: tokens) != nil || + tokenIndex(matching: "npm", in: tokens) != nil || + haystack.contains("skycomputeruseclient") || + haystack.contains("computer use.app") || + haystack.contains("/.codex/") || + haystack.contains("/mcp/") + ) + } + + private static func generalActivityClassification( + process: DevProcess, + executableName: String, + tokens: [String], + lowerTokens: [String], + haystack: String + ) -> DevProcessClassification { + let kind: DevRuntimeKind + if isBrowserProcess(executableName: executableName, haystack: haystack) { + kind = .browser + } else if isMacOSAppProcess(tokens: tokens, haystack: haystack) { + kind = .macApp + } else if isBackgroundAgentProcess(executableName: executableName, haystack: haystack) { + kind = .backgroundAgent + } else if isSystemServiceProcess(haystack: haystack) { + kind = .systemService + } else { + kind = .other + } + + return DevProcessClassification( + kind: kind, + displayName: activityDisplayName( + executableName: executableName, + command: process.command, + tokens: tokens, + lowerTokens: lowerTokens, + fallback: process.executableName + ), + projectHint: isDevWorkspace(process.currentDirectory) ? projectHint(from: process.currentDirectory ?? process.command) : nil, + tags: tags(for: kind, executableName: executableName, tokens: lowerTokens, haystack: haystack) + ) + } + + private static func isBrowserProcess(executableName: String, haystack: String) -> Bool { + let browsers = [ + "safari", + "google chrome", + "google chrome helper", + "firefox", + "firefox helper", + "arc", + "arc helper", + "brave browser", + "brave browser helper", + "microsoft edge", + "microsoft edge helper" + ] + + return browsers.contains(executableName) || + haystack.contains("safari.app") || + haystack.contains("google chrome.app") || + haystack.contains("google chrome helper.app") || + haystack.contains("firefox.app") || + haystack.contains("firefox helper.app") || + haystack.contains("arc.app") || + haystack.contains("arc helper.app") || + haystack.contains("brave browser.app") || + haystack.contains("brave browser helper.app") || + haystack.contains("microsoft edge.app") || + haystack.contains("microsoft edge helper.app") + } + + private static func isMacOSAppProcess(tokens: [String], haystack: String) -> Bool { + macOSAppDisplayName(in: tokens) != nil || haystack.contains(".app/contents/macos/") + } + + private static func isBackgroundAgentProcess(executableName: String, haystack: String) -> Bool { + executableName.contains("agent") || + executableName.contains("helper") || + haystack.contains("/launchagents/") || + haystack.contains("/launchdaemons/") || + haystack.contains("daemon.framework") || + haystack.contains("agent.app") || + haystack.contains("helper.app") + } + + private static func isSystemServiceProcess(haystack: String) -> Bool { + haystack.contains("/system/library/") || + haystack.contains("/usr/libexec/") || + haystack.contains("/usr/sbin/") || + haystack.contains("/sbin/") + } + + private static func activityDisplayName( + executableName: String, + command: String, + tokens: [String], + lowerTokens: [String], + fallback: String + ) -> String { + if isBrowserProcess(executableName: executableName, haystack: ([executableName] + lowerTokens).joined(separator: " ")), + let appName = macOSAppDisplayName(inCommand: command, preferLast: true) ?? lastMacOSAppDisplayName(in: tokens) { + return appName + } + if let appName = macOSAppDisplayName(inCommand: command) ?? macOSAppDisplayName(in: tokens) { + return appName + } + + let commandDisplayName = commandExecutableDisplayName(tokens: tokens) + let fallbackDisplayName = URL(fileURLWithPath: fallback).lastPathComponent + + if let commandDisplayName, + shouldPreferCommandDisplayName(commandDisplayName, over: fallbackDisplayName) { + return commandDisplayName + } + + if !fallbackDisplayName.isEmpty && !isGenericExecutableName(fallbackDisplayName) { + return fallbackDisplayName + } + + if let commandDisplayName { + return commandDisplayName + } + + let executableDisplayName = URL(fileURLWithPath: executableName).lastPathComponent + if !executableDisplayName.isEmpty && !isGenericExecutableName(executableDisplayName) { + return executableDisplayName + } + + return fallbackDisplayName.isEmpty ? fallback : fallbackDisplayName + } + + private static func commandExecutableDisplayName(tokens: [String]) -> String? { + for token in tokens.prefix(4) { + let candidate = URL(fileURLWithPath: token).lastPathComponent + guard !candidate.isEmpty, + !candidate.hasPrefix("-"), + !isGenericExecutableName(candidate) else { + continue + } + return candidate + } + + return nil + } + + private static func shouldPreferCommandDisplayName(_ commandDisplayName: String, over fallbackDisplayName: String) -> Bool { + let fallback = fallbackDisplayName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !fallback.isEmpty else { + return true + } + + let command = commandDisplayName.trimmingCharacters(in: .whitespacesAndNewlines) + let lowerFallback = fallback.lowercased() + let lowerCommand = command.lowercased() + + if lowerFallback == lowerCommand { + return false + } + + if isGenericExecutableName(fallback) { + return true + } + + if fallback.count <= 3 && command.count > fallback.count { + return true + } + + return lowerCommand.hasPrefix(lowerFallback) && command.count > fallback.count + } + + private static func isGenericExecutableName(_ name: String) -> Bool { + let normalized = name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return [ + "a", + "bin", + "contents", + "coreservices", + "frameworks", + "helpers", + "library", + "macos", + "privateframeworks", + "resources", + "support", + "versions", + "xpcservices" + ].contains(normalized) + } + + private static func aiDisplayName(executableName: String, tokens: [String], haystack: String, fallback: String) -> String { + if executableName == "ollama" || tokens.contains("ollama") || haystack.contains("ollama.app") || haystack.contains("/ollama") { + if tokens.contains("serve") { + return "Ollama server" + } + return "Ollama" + } + if executableName == "lmstudio" || tokens.contains("lmstudio") || haystack.contains("lm studio") { + return "LM Studio" + } + if executableName == "llama-server" || tokens.contains("llama-server") { + return "Llama server" + } + return fallback + } + + private static func tags( + for kind: DevRuntimeKind, + executableName: String, + tokens: [String], + haystack: String + ) -> [DevProcessTag] { + var result: [DevProcessTag] = [] + + func add(_ tag: DevProcessTag) { + if !result.contains(tag) { + result.append(tag) + } + } + + if tokens.contains("jupyter") || tokens.contains("ipykernel") || haystack.contains("ipykernel") || haystack.contains("notebook") { + add(.notebook) + } + if tokens.contains("torchrun") || tokens.contains("accelerate") || haystack.contains("train") || haystack.contains("trainer") || haystack.contains("finetune") || haystack.contains("fine-tune") { + add(.training) + } + if haystack.contains("llm") || haystack.contains("llama") || haystack.contains("mistral") || haystack.contains("qwen") || haystack.contains("gemma") || haystack.contains("transformers") || haystack.contains("vllm") || haystack.contains("ollama") || haystack.contains("mlx") { + add(.llm) + } + if haystack.contains("vllm") || haystack.contains("llama") || haystack.contains("ollama") || haystack.contains("mlx") || haystack.contains("transformers") { + add(.inference) + } + if haystack.contains("vllm") || haystack.contains("ollama") || haystack.contains("llama-server") || haystack.contains("llama.cpp") { + add(.llmServer) + } + if haystack.contains("qdrant") || haystack.contains("chroma") || haystack.contains("milvus") || haystack.contains("weaviate") || haystack.contains("faiss") { + add(.vectorDB) + } + if tokens.contains("uvicorn") || tokens.contains("gunicorn") || haystack.contains("fastapi") || haystack.contains("flask") { + add(.api) + } + if tokens.contains("mcp") || haystack.contains("/mcp/") { + add(.mcp) + } + if haystack.contains("mlflow") || haystack.contains("wandb") || haystack.contains("tensorboard") || haystack.contains("optuna") { + add(.experiment) + } + if haystack.contains("streamlit") || haystack.contains("gradio") || haystack.contains("dash") { + add(.dataApp) + } + if kind == .ai && result.isEmpty { + add(.inference) + } + + return result + } + + private static func isDevShellProcess(executableName: String, currentDirectory: String?) -> Bool { + let shellNames = ["zsh", "bash", "fish", "sh", "tmux"] + guard shellNames.contains(executableName), let currentDirectory else { + return false + } + + return isDevWorkspace(currentDirectory) + } + + private static func isDevWorkspace(_ currentDirectory: String?) -> Bool { + guard let currentDirectory else { + return false + } + + let home = NSHomeDirectory() + guard currentDirectory.hasPrefix(home) else { + return false + } + + let workspaceMarkers = [ + "\(home)/dev/", + "\(home)/Developer/", + "\(home)/code/", + "\(home)/Code/", + "\(home)/src/", + "\(home)/workspace/", + "\(home)/.codex/" + ] + return currentDirectory == "\(home)/.codex" || workspaceMarkers.contains { currentDirectory.hasPrefix($0) } + } + + private static func isFlutterWorkspace( + _ currentDirectory: String?, + workspaceFacts: WorkspaceFacts? + ) -> Bool { + if let workspaceFacts { + return workspaceFacts.isFlutterWorkspace + } + + guard let currentDirectory else { + return false + } + + let components = URL(fileURLWithPath: currentDirectory).pathComponents.map { $0.lowercased() } + if components.contains("flutter") || components.contains(".dart_tool") { + return isDevWorkspace(currentDirectory) + } + + return FileManager.default.fileExists(atPath: "\(currentDirectory)/pubspec.yaml") || + FileManager.default.fileExists(atPath: "\(currentDirectory)/.dart_tool/package_config.json") + } + + private static func displayName(for tokens: [String], lowerTokens: [String], fallback: String) -> String { + if let mcpName = mcpDisplayName(tokens: tokens, lowerTokens: lowerTokens) { + return mcpName + } + if let nextIndex = tokenIndex(matching: "next", in: lowerTokens), lowerTokens.dropFirst(nextIndex + 1).contains("dev") { + return "next dev" + } + if lowerTokens.contains("vite") { + return "vite" + } + if let uvicornIndex = tokenIndex(matching: "uvicorn", in: lowerTokens) { + return serverDisplayName(prefix: "uvicorn", tokens: tokens, after: uvicornIndex) + } + if let gunicornIndex = tokenIndex(matching: "gunicorn", in: lowerTokens) { + return serverDisplayName(prefix: "gunicorn", tokens: tokens, after: gunicornIndex) + } + if lowerTokens.contains("pytest") { + return "pytest" + } + if lowerTokens.contains("torchrun") { + return toolScriptDisplayName(prefix: "torchrun", tokens: tokens, lowerTokens: lowerTokens) + } + if lowerTokens.contains("accelerate") { + return toolScriptDisplayName(prefix: "accelerate", tokens: tokens, lowerTokens: lowerTokens) + } + if lowerTokens.contains("streamlit") { + return toolScriptDisplayName(prefix: "streamlit", tokens: tokens, lowerTokens: lowerTokens) + } + if lowerTokens.contains("gradio") { + return toolScriptDisplayName(prefix: "gradio", tokens: tokens, lowerTokens: lowerTokens) + } + if lowerTokens.contains("jupyter") { + return "jupyter" + } + if lowerTokens.contains("mlflow") { + return "mlflow" + } + if lowerTokens.contains("tensorboard") { + return "tensorboard" + } + if lowerTokens.first == "npm", lowerTokens.dropFirst().prefix(2) == ["run", "dev"] { + return "npm run dev" + } + if lowerTokens.first == "pnpm", lowerTokens.contains("dev") { + return "pnpm dev" + } + if lowerTokens.first == "yarn", lowerTokens.contains("dev") { + return "yarn dev" + } + if let moduleName = pythonModuleName(in: tokens, lowerTokens: lowerTokens) { + return "python module \(moduleName)" + } + if isPythonCommand(tokens: lowerTokens) { + return "python command" + } + if let scriptName = pythonScriptName(in: tokens) { + return "python \(scriptName)" + } + if let nodeName = nodeScriptName(in: tokens, lowerTokens: lowerTokens) { + return "node \(nodeName)" + } + if let appName = macOSAppDisplayName(in: tokens) { + return appName + } + if lowerTokens.first == "swift" { + return lowerTokens.dropFirst().first.map { "swift \($0)" } ?? "swift" + } + if lowerTokens.first == "cargo" { + return lowerTokens.dropFirst().first.map { "cargo \($0)" } ?? "cargo" + } + if lowerTokens.first == "go" { + return lowerTokens.dropFirst().first.map { "go \($0)" } ?? "go" + } + return fallback + } + + private static func serverDisplayName(prefix: String, tokens: [String], after index: Int) -> String { + let appToken = tokens.dropFirst(index + 1).first { token in + !token.hasPrefix("-") && !token.contains("/") + } + + guard let appToken else { + return prefix + } + + let appName = appToken + .split(separator: ":") + .first + .map(String.init)? + .split(separator: ".") + .first + .map(String.init) + + return appName.map { "\(prefix) \($0)" } ?? prefix + } + + private static func toolScriptDisplayName(prefix: String, tokens: [String], lowerTokens: [String]) -> String { + guard let toolIndex = tokenIndex(matching: prefix, in: lowerTokens) else { + return prefix + } + + let scriptToken = tokens.dropFirst(toolIndex + 1).first { token in + !token.hasPrefix("-") && + token != "run" && + (token.hasSuffix(".py") || token.hasSuffix(".ipynb") || token.contains("/")) + } + + guard let scriptToken else { + return prefix + } + + let scriptName = URL(fileURLWithPath: scriptToken).deletingPathExtension().lastPathComponent + return scriptName.isEmpty ? prefix : "\(prefix) \(scriptName)" + } + + private static func pythonScriptName(in tokens: [String]) -> String? { + tokens.dropFirst().first { token in + token.hasSuffix(".py") + } + .map { token in + URL(fileURLWithPath: token).deletingPathExtension().lastPathComponent + } + } + + private static func nodeScriptName(in tokens: [String], lowerTokens: [String]) -> String? { + guard let first = lowerTokens.first, + first == "node" || first.hasSuffix("/node") else { + return nil + } + + return tokens.dropFirst().first { token in + !token.hasPrefix("-") + } + .map { token in + URL(fileURLWithPath: token).deletingPathExtension().lastPathComponent + } + } + + private static func mcpDisplayName(tokens: [String], lowerTokens: [String]) -> String? { + let hasMCPSignal = lowerTokens.contains("mcp") || + lowerTokens.contains("--stdio") || + lowerTokens.contains("stdio") || + lowerTokens.contains { $0.contains("/mcp/") } + guard hasMCPSignal else { + return nil + } + + if lowerTokens.first == "npm", + let execIndex = lowerTokens.firstIndex(of: "exec"), + execIndex + 1 < tokens.count { + let packageName = packageName(from: tokens[execIndex + 1]) + return mcpToolName(packageName) + } + + if let basicMemoryIndex = tokenIndex(matching: "basic-memory", in: lowerTokens), + lowerTokens.dropFirst(basicMemoryIndex + 1).contains("mcp") { + return "basic-memory MCP" + } + + if let toolToken = tokens.dropFirst().first(where: { token in + let name = URL(fileURLWithPath: token).lastPathComponent.lowercased() + return name == "xcodebuildmcp" || name == "shadcn" || name == "basic-memory" || name == "gk" || name == "skycomputeruseclient" + }) { + return mcpToolName(URL(fileURLWithPath: toolToken).lastPathComponent) + } + + if lowerTokens.contains(where: { token in + token.contains("/mcp/server") || token == "server.cjs" || token == "server.mjs" || token == "server.bundle.mjs" + }) { + return "MCP server" + } + + return nil + } + + private static func packageName(from token: String) -> String { + let rawName = URL(fileURLWithPath: token).lastPathComponent + if rawName.hasPrefix("@") { + let components = rawName.split(separator: "@", omittingEmptySubsequences: false) + guard components.count > 2 else { + return rawName + } + return components.dropLast().joined(separator: "@") + } + + return rawName.split(separator: "@").first.map(String.init) ?? rawName + } + + private static func mcpToolName(_ packageName: String) -> String { + switch packageName.lowercased() { + case "xcodebuildmcp": + return "xcodebuildmcp" + case "shadcn": + return "shadcn MCP" + case "basic-memory": + return "basic-memory MCP" + case "gk": + return "GitKraken MCP" + case "skycomputeruseclient": + return "Computer Use MCP" + default: + return packageName.lowercased().contains("mcp") ? packageName : "\(packageName) MCP" + } + } + + private static func pythonModuleName(in tokens: [String], lowerTokens: [String]) -> String? { + guard let moduleIndex = lowerTokens.firstIndex(of: "-m"), + moduleIndex + 1 < tokens.count else { + return nil + } + + let moduleName = tokens[moduleIndex + 1] + guard moduleName.range(of: #"^[A-Za-z_][A-Za-z0-9_.-]*$"#, options: .regularExpression) != nil else { + return nil + } + + return moduleName + } + + private static func isPythonCommand(tokens: [String]) -> Bool { + guard tokens.contains("-c") else { + return false + } + + return tokens.contains("python") || + tokens.contains("python3") || + tokens.contains(where: { token in + token.hasSuffix("/python") || + token.hasSuffix("/python3") || + token.contains("/python3.") || + token.hasPrefix("python3.") + }) + } + + private static func macOSAppDisplayName(in tokens: [String]) -> String? { + for token in tokens where token.contains(".app/") || token.hasSuffix(".app") { + let components = URL(fileURLWithPath: token).pathComponents + if let appComponent = components.first(where: { $0.hasSuffix(".app") }) { + let appName = String(appComponent.dropLast(4)) + if !appName.isEmpty { + return appName + } + } + } + + return nil + } + + private static func macOSAppDisplayName(inCommand command: String, preferLast: Bool = false) -> String? { + let appNames = command.split(separator: "/").compactMap { component -> String? in + guard component.hasSuffix(".app") else { + return nil + } + + let appName = component.dropLast(4) + return appName.isEmpty ? nil : String(appName) + } + + return preferLast ? appNames.last : appNames.first + } + + private static func lastMacOSAppDisplayName(in tokens: [String]) -> String? { + for token in tokens.reversed() where token.contains(".app/") || token.hasSuffix(".app") { + let components = URL(fileURLWithPath: token).pathComponents + if let appComponent = components.last(where: { $0.hasSuffix(".app") }) { + let appName = String(appComponent.dropLast(4)) + if !appName.isEmpty { + return appName + } + } + } + + return nil + } + + private static func shellDisplayName( + _ executableName: String, + tokens: [String], + lowerTokens: [String] + ) -> String { + if lowerTokens.contains("python") || lowerTokens.contains("python3") || lowerTokens.contains(where: { $0.hasPrefix("python3.") }) { + if isPythonCommand(tokens: lowerTokens) { + return "python command" + } + if let scriptName = pythonScriptName(in: tokens) { + return "python \(scriptName)" + } + return "python" + } + + let baseName = executableName.hasPrefix("-") ? String(executableName.dropFirst()) : executableName + return URL(fileURLWithPath: baseName).lastPathComponent + } + + private static func tokenIndex(matching needle: String, in tokens: [String]) -> Int? { + tokens.firstIndex { token in + token == needle || token.hasSuffix("/\(needle)") || token.hasSuffix("/.bin/\(needle)") + } + } + + private static func isDirectToolInvocation(_ needle: String, in tokens: [String]) -> Bool { + guard let first = tokens.first else { + return false + } + + return first == needle || first.hasSuffix("/\(needle)") || first.hasSuffix("/.bin/\(needle)") + } + + private static func projectHint(from command: String) -> String? { + let home = NSHomeDirectory() + guard let homeRange = command.range(of: home) else { + return nil + } + + let path = String(command[homeRange.lowerBound...]) + let components = URL(fileURLWithPath: path).pathComponents + if let codexPluginName = codexPluginName(from: components) { + return codexPluginName + } + let markers = ["dev", "Developer", "code", "Code", "src", "workspace"] + + for marker in markers { + guard let index = components.firstIndex(of: marker), + index + 1 < components.count else { + continue + } + let remaining = components[(index + 1)...].filter { !$0.isEmpty && $0 != "/" } + guard let first = remaining.first else { + continue + } + if remaining.count >= 2, ["work", "personal", "github", "projects", "flutter"].contains(first.lowercased()) { + return remaining.dropFirst().first + } + return first + } + + return components.last { !$0.isEmpty && $0 != "/" && !$0.hasPrefix(".") } + } + + private static func codexPluginName(from components: [String]) -> String? { + guard let codexIndex = components.firstIndex(of: ".codex") else { + return nil + } + + if let cacheIndex = components[codexIndex...].firstIndex(of: "cache") { + let pluginComponents = components.dropFirst(cacheIndex + 1).filter { !$0.isEmpty && $0 != "/" } + if pluginComponents.count >= 2, + pluginComponents[0].hasPrefix("openai-") { + return String(pluginComponents[1]) + } + return pluginComponents.first.map { String($0) } + } + + return "Codex" + } + + private static func tokenize(_ command: String) -> [String] { + command + .split(whereSeparator: { $0 == " " || $0 == "\t" || $0 == "\n" }) + .map(String.init) + } +} diff --git a/Sources/DevScopeCore/ProcessIntelligence.swift b/Sources/DevScopeCore/ProcessIntelligence.swift new file mode 100644 index 0000000..717732c --- /dev/null +++ b/Sources/DevScopeCore/ProcessIntelligence.swift @@ -0,0 +1,1020 @@ +import Foundation + +public enum DevWorkflowKind: String, CaseIterable, Sendable { + case aiMLLab = "AI / ML Lab" + case localLLMStack = "Local LLM Stack" + case notebookSession = "Notebook Session" + case trainingRun = "Training Run" + case apiService = "API Service" + case dataApp = "Data App" + case vectorDatabase = "Vector Database" + case buildWorkspace = "Build Workspace" + case webWorkspace = "Web Workspace" + case projectWorkspace = "Project Workspace" + case runtimeGroup = "Runtime Group" + + public var symbolName: String { + switch self { + case .aiMLLab: + "brain" + case .localLLMStack: + "text.bubble" + case .notebookSession: + "book" + case .trainingRun: + "chart.line.uptrend.xyaxis" + case .apiService: + "network" + case .dataApp: + "chart.bar" + case .vectorDatabase: + "square.stack.3d.up" + case .buildWorkspace: + "hammer" + case .webWorkspace: + "curlybraces" + case .projectWorkspace: + "folder" + case .runtimeGroup: + "square.grid.2x2" + } + } +} + +public enum DevWorkflowRisk: String, Comparable, Sendable { + case normal = "Normal" + case busy = "Busy" + case heavy = "Heavy" + + public static func < (lhs: DevWorkflowRisk, rhs: DevWorkflowRisk) -> Bool { + lhs.rank < rhs.rank + } + + private var rank: Int { + switch self { + case .normal: + 0 + case .busy: + 1 + case .heavy: + 2 + } + } +} + +public struct DevWorkflow: Identifiable, Equatable, Sendable { + public let id: String + public let title: String + public let subtitle: String + public let kind: DevWorkflowKind + public let processIDs: [Int32] + public let primaryProject: String? + public let tags: [DevProcessTag] + public let totalCPU: Double + public let totalMemoryBytes: Int64 + public let risk: DevWorkflowRisk + public let confidence: Double + public let summary: String + public let suggestedAction: String + + public var processCount: Int { + processIDs.count + } + + public init( + id: String, + title: String, + subtitle: String, + kind: DevWorkflowKind, + processIDs: [Int32], + primaryProject: String?, + tags: [DevProcessTag], + totalCPU: Double, + totalMemoryBytes: Int64, + risk: DevWorkflowRisk, + confidence: Double, + summary: String, + suggestedAction: String + ) { + self.id = id + self.title = title + self.subtitle = subtitle + self.kind = kind + self.processIDs = processIDs + self.primaryProject = primaryProject + self.tags = tags + self.totalCPU = totalCPU + self.totalMemoryBytes = totalMemoryBytes + self.risk = risk + self.confidence = confidence + self.summary = summary + self.suggestedAction = suggestedAction + } +} + +public struct DevProcessInsight: Equatable, Sendable { + public let title: String + public let role: String + public let resourceBehavior: String + public let workflowContext: String + public let safeAction: String + public let confidence: Double +} + +public enum ProcessIntelligence { + public static let workflowIDPrefix = "workflow:" + private static let maximumWorkflowTitleLength = 72 + private static let maximumWorkflowCount = 12 + + private struct WorkflowProject: Hashable { + let id: String + let title: String + } + + public static func workflows(for items: [ClassifiedDevProcess]) -> [DevWorkflow] { + workflows(for: items, workspaceOwnerComponents: configuredWorkspaceOwnerComponents) + } + + static func uncappedWorkflows(for items: [ClassifiedDevProcess]) -> [DevWorkflow] { + uncappedWorkflows(for: items, workspaceOwnerComponents: configuredWorkspaceOwnerComponents) + } + + static func workflows( + for items: [ClassifiedDevProcess], + workspaceOwnerComponents: Set + ) -> [DevWorkflow] { + presentedWorkflows( + uncappedWorkflows(for: items, workspaceOwnerComponents: workspaceOwnerComponents) + ) + } + + private static func uncappedWorkflows( + for items: [ClassifiedDevProcess], + workspaceOwnerComponents: Set + ) -> [DevWorkflow] { + let aiItems = items.filter(isAIMLRelated) + var workflows: [DevWorkflow] = [] + + if !aiItems.isEmpty { + workflows.append(makeWorkflow( + id: "\(workflowIDPrefix)ai-ml-lab", + title: "AI / ML Lab", + kind: .aiMLLab, + items: aiItems, + primaryProject: primaryProject( + for: aiItems, + workspaceOwnerComponents: workspaceOwnerComponents + ), + summary: "Notebook kernels, training jobs, LLM services, vector stores, APIs, and data apps detected in the current local development session.", + suggestedAction: "Use this view to inspect related AI/ML processes before terminating model servers or kernels." + )) + } + + let llmItems = items.filter { item in + item.classification.kind == .ai || + item.classification.tags.contains(.llmServer) || + item.classification.tags.contains(.vectorDB) + } + if !llmItems.isEmpty { + workflows.append(makeWorkflow( + id: "\(workflowIDPrefix)local-llm-stack", + title: "Local LLM Stack", + kind: .localLLMStack, + items: llmItems, + primaryProject: primaryProject( + for: llmItems, + workspaceOwnerComponents: workspaceOwnerComponents + ), + summary: "Local model servers, inference runtimes, or vector database processes are active.", + suggestedAction: "Check memory pressure before closing model servers; they can be expensive to restart." + )) + } + + let projectGroups = Dictionary(grouping: items) { item in + canonicalProject( + for: item, + workspaceOwnerComponents: workspaceOwnerComponents + ) ?? WorkflowProject(id: "unassigned", title: "Unassigned") + } + + for (project, groupedItems) in projectGroups where shouldCreateProjectWorkflow(project: project, items: groupedItems) { + let kind = workflowKind(for: groupedItems) + workflows.append(makeWorkflow( + id: "\(workflowIDPrefix)project-\(project.id)-\(kind.rawValue.lowercased().replacingOccurrences(of: " ", with: "-"))", + title: workflowTitle(project: project.title, kind: kind), + kind: kind, + items: groupedItems, + primaryProject: project.id == "unassigned" ? nil : project.title, + summary: workflowSummary(project: project.title, kind: kind, items: groupedItems), + suggestedAction: "Inspect the process tree before terminating this workflow; grouped rows may depend on each other." + )) + } + + return unique(workflows).filter { + let title = $0.title.trimmingCharacters(in: .whitespacesAndNewlines) + return !title.isEmpty && title.count <= maximumWorkflowTitleLength + } + } + + static func presentedWorkflows(_ workflows: [DevWorkflow]) -> [DevWorkflow] { + let sortedWorkflows = workflows + .sorted { lhs, rhs in + if lhs.risk != rhs.risk { + return lhs.risk > rhs.risk + } + let lhsKindPriority = workflowKindPriority(lhs.kind) + let rhsKindPriority = workflowKindPriority(rhs.kind) + if lhsKindPriority != rhsKindPriority { + return lhsKindPriority < rhsKindPriority + } + if lhs.primaryProject != rhs.primaryProject { + let projectComparison = (lhs.primaryProject ?? "") + .localizedStandardCompare(rhs.primaryProject ?? "") + if projectComparison != .orderedSame { + return projectComparison == .orderedAscending + } + } + let titleComparison = lhs.title.localizedStandardCompare(rhs.title) + if titleComparison != .orderedSame { + return titleComparison == .orderedAscending + } + return lhs.id < rhs.id + } + + guard sortedWorkflows.count > maximumWorkflowCount else { + return sortedWorkflows + } + + let aggregateWorkflows = sortedWorkflows.filter { + $0.kind == .aiMLLab || $0.kind == .localLLMStack + } + let projectWorkflows = sortedWorkflows.filter { + $0.kind != .aiMLLab && $0.kind != .localLLMStack + } + let retainedIDs = Set( + (aggregateWorkflows + projectWorkflows) + .prefix(maximumWorkflowCount) + .map(\.id) + ) + return sortedWorkflows.filter { retainedIDs.contains($0.id) } + } + + public static func workflow(containing item: ClassifiedDevProcess, in workflows: [DevWorkflow]) -> DevWorkflow? { + workflows.first { $0.processIDs.contains(item.process.pid) } + } + + static func rehydratedWorkflows( + _ workflows: [DevWorkflow], + for items: [ClassifiedDevProcess] + ) -> [DevWorkflow] { + let itemsByPID = Dictionary( + items.map { ($0.process.pid, $0) }, + uniquingKeysWith: { _, latest in latest } + ) + + return workflows.map { workflow in + let workflowItems = workflow.processIDs.compactMap { itemsByPID[$0] } + let totalCPU = workflowItems.reduce(0) { + $0 + ($1.process.resourceUsage?.cpuPercent ?? 0) + } + let totalMemory = workflowItems.reduce(Int64(0)) { + $0 + ($1.process.resourceUsage?.residentMemoryBytes ?? 0) + } + return DevWorkflow( + id: workflow.id, + title: workflow.title, + subtitle: workflowSubtitle( + count: workflow.processIDs.count, + cpu: totalCPU, + memory: totalMemory + ), + kind: workflow.kind, + processIDs: workflow.processIDs, + primaryProject: workflow.primaryProject, + tags: workflow.tags, + totalCPU: totalCPU, + totalMemoryBytes: totalMemory, + risk: workflowRisk(totalCPU: totalCPU, totalMemoryBytes: totalMemory), + confidence: workflow.confidence, + summary: workflow.summary, + suggestedAction: workflow.suggestedAction + ) + } + } + + public static func insight( + for item: ClassifiedDevProcess, + workflow: DevWorkflow?, + familySummary: ProcessFamilySummary?, + metricHistory: [DevProcessMetricSample], + actionDecision: ProcessActionDecision = .allowed + ) -> DevProcessInsight { + let tags = item.classification.tags + let role = processRole(for: item) + let behavior = resourceBehavior(for: item, metricHistory: metricHistory) + let workflowContext = workflow.map { + "\($0.title) includes \($0.processCount) related process\($0.processCount == 1 ? "" : "es")." + } ?? "No larger workflow was detected for this process yet." + let safeAction: String + if let reason = actionDecision.reason { + safeAction = "Protected: \(reason)." + } else if let familySummary, familySummary.descendantCount > 0 { + safeAction = "Prefer TERM Tree only when you intend to stop this entire user workflow; \(familySummary.descendantCount) descendants were detected." + } else if tags.contains(.llmServer) || item.classification.kind == .ai { + safeAction = "Model servers can hold large memory allocations; confirm no clients are active before stopping it." + } else { + safeAction = "TERM is the safest first signal. Use force only when the process ignores TERM." + } + + return DevProcessInsight( + title: processInsightTitle(for: item), + role: role, + resourceBehavior: behavior, + workflowContext: workflowContext, + safeAction: safeAction, + confidence: tags.isEmpty ? 0.72 : 0.88 + ) + } + + private static func isAIMLRelated(_ item: ClassifiedDevProcess) -> Bool { + item.classification.kind == .ai || !item.classification.tags.isEmpty + } + + private static func makeWorkflow( + id: String, + title: String, + kind: DevWorkflowKind, + items: [ClassifiedDevProcess], + primaryProject: String?, + summary: String, + suggestedAction: String + ) -> DevWorkflow { + let items = orderedAggregationItems(items) + let processIDs = items.map(\.process.pid).sorted() + let totalCPU = items.reduce(0) { $0 + ($1.process.resourceUsage?.cpuPercent ?? 0) } + let totalMemory = items.reduce(Int64(0)) { $0 + ($1.process.resourceUsage?.residentMemoryBytes ?? 0) } + let tags = uniqueTags(items.flatMap(\.classification.tags)) + let risk = workflowRisk(totalCPU: totalCPU, totalMemoryBytes: totalMemory) + + return DevWorkflow( + id: id, + title: title, + subtitle: workflowSubtitle(count: processIDs.count, cpu: totalCPU, memory: totalMemory), + kind: kind, + processIDs: processIDs, + primaryProject: primaryProject, + tags: tags, + totalCPU: totalCPU, + totalMemoryBytes: totalMemory, + risk: risk, + confidence: tags.isEmpty ? 0.78 : 0.9, + summary: summary, + suggestedAction: suggestedAction + ) + } + + private static func orderedAggregationItems( + _ items: [ClassifiedDevProcess] + ) -> [ClassifiedDevProcess] { + items.sorted { lhs, rhs in + let left = lhs.process + let right = rhs.process + if left.pid != right.pid { + return left.pid < right.pid + } + if left.birthToken != right.birthToken { + switch (left.birthToken, right.birthToken) { + case let (.some(leftBirth), .some(rightBirth)): + if leftBirth.seconds != rightBirth.seconds { + return leftBirth.seconds < rightBirth.seconds + } + return leftBirth.microseconds < rightBirth.microseconds + case (.none, .some): + return true + case (.some, .none): + return false + case (.none, .none): + break + } + } + if left.parentPID != right.parentPID { + return left.parentPID < right.parentPID + } + if left.executable != right.executable { + return left.executable < right.executable + } + if left.command != right.command { + return left.command < right.command + } + return (left.currentDirectory ?? "") < (right.currentDirectory ?? "") + } + } + + private static func workflowKind(for items: [ClassifiedDevProcess]) -> DevWorkflowKind { + let tags = Set(items.flatMap(\.classification.tags)) + let kinds = Set(items.map(\.classification.kind)) + + if tags.contains(.training) { + return .trainingRun + } + if tags.contains(.notebook) { + return .notebookSession + } + if tags.contains(.dataApp) { + return .dataApp + } + if tags.contains(.api) { + return .apiService + } + if tags.contains(.vectorDB) { + return .vectorDatabase + } + if kinds.contains(.swift) || kinds.contains(.rust) || kinds.contains(.go) || kinds.contains(.flutter) { + return .buildWorkspace + } + if kinds.contains(.javascript) || kinds.contains(.webServer) { + return .webWorkspace + } + return .projectWorkspace + } + + private static func workflowTitle(project: String, kind: DevWorkflowKind) -> String { + if project == "Unassigned" { + return kind.rawValue + } + + let lowerProject = project.lowercased() + switch kind { + case .trainingRun: + if lowerProject == "training" { + return "Training Run" + } + return "\(project) Training" + case .notebookSession: + if lowerProject == "notebook" { + return "Notebook Session" + } + return "\(project) Notebook" + case .apiService: + if lowerProject == "api" { + return "API Service" + } + return "\(project) API" + case .dataApp: + if lowerProject == "data" || lowerProject == "data app" { + return "Data App" + } + return "\(project) Data App" + case .vectorDatabase: + if lowerProject == "vector" || lowerProject == "vector db" || lowerProject == "vector database" { + return "Vector Database" + } + return "\(project) Vector DB" + case .buildWorkspace: + if lowerProject == "build" { + return "Build Workspace" + } + return "\(project) Build" + case .webWorkspace: + if lowerProject == "web" { + return "Web Workspace" + } + return "\(project) Web" + case .projectWorkspace: + return "\(project) Workspace" + case .aiMLLab, .localLLMStack, .runtimeGroup: + return kind.rawValue + } + } + + private static func workflowSummary(project: String, kind: DevWorkflowKind, items: [ClassifiedDevProcess]) -> String { + let processWord = items.count == 1 ? "process" : "processes" + if project == "Unassigned" { + return "\(items.count) related \(processWord) detected without a stable project folder." + } + + return "\(items.count) \(processWord) in \(project) are grouped as \(kind.rawValue.lowercased())." + } + + private static func workflowSubtitle(count: Int, cpu: Double, memory: Int64) -> String { + let memoryMB = Double(memory) / 1_048_576 + let memoryText = memoryMB >= 1024 + ? String(format: "%.1f GB", memoryMB / 1024) + : String(format: "%.0f MB", memoryMB) + let processWord = count == 1 ? "OS process" : "OS processes" + return "\(count) \(processWord) · \(String(format: "%.0f%%", cpu)) CPU · \(memoryText)" + } + + private static func workflowRisk(totalCPU: Double, totalMemoryBytes: Int64) -> DevWorkflowRisk { + if totalCPU >= 80 || totalMemoryBytes >= 4_294_967_296 { + return .heavy + } + if totalCPU >= 25 || totalMemoryBytes >= 1_073_741_824 { + return .busy + } + return .normal + } + + private static func shouldCreateProjectWorkflow(project: WorkflowProject, items: [ClassifiedDevProcess]) -> Bool { + guard project.id != "unassigned", + !isNoisyProjectComponent(project.title), + items.contains(where: isDevelopmentWorkflowCandidate) else { + return false + } + + if items.count > 1 { + return true + } + + guard let item = items.first else { + return false + } + + let totalCPU = item.process.resourceUsage?.cpuPercent ?? 0 + let totalMemory = item.process.resourceUsage?.residentMemoryBytes ?? 0 + if totalCPU >= 25 || totalMemory >= 1_073_741_824 || !item.classification.tags.isEmpty { + return true + } + + if isDevelopmentWorkflowCandidate(item) { + return hasDevelopmentContext(item) + } + + return false + } + + private static func isDevelopmentWorkflowCandidate(_ item: ClassifiedDevProcess) -> Bool { + switch item.classification.kind { + case .javascript, .python, .swift, .rust, .go, .flutter, .java, + .database, .container, .webServer, .ai, .mcp: + return true + case .browser, .macApp, .backgroundAgent, .systemService, .shell, .other: + return false + } + } + + private static func hasDevelopmentContext(_ item: ClassifiedDevProcess) -> Bool { + if let currentDirectory = item.process.currentDirectory, + canonicalProject(fromPath: currentDirectory) != nil { + return true + } + return canonicalProject(fromCommand: item.process.command) != nil + } + + private static func workflowKindPriority(_ kind: DevWorkflowKind) -> Int { + switch kind { + case .aiMLLab: + 0 + case .localLLMStack: + 1 + case .trainingRun: + 2 + case .notebookSession: + 3 + case .dataApp: + 4 + case .apiService: + 5 + case .vectorDatabase: + 6 + case .buildWorkspace: + 7 + case .webWorkspace: + 8 + case .projectWorkspace: + 9 + case .runtimeGroup: + 10 + } + } + + private static func processRole(for item: ClassifiedDevProcess) -> String { + let tags = item.classification.tags + if tags.contains(.training) { + return "Training or fine-tuning workload" + } + if tags.contains(.notebook) { + return "Notebook kernel or interactive research session" + } + if tags.contains(.llmServer) { + return "Local LLM serving process" + } + if tags.contains(.vectorDB) { + return "Vector storage or retrieval service" + } + if tags.contains(.api) { + return "Local API server" + } + if tags.contains(.dataApp) { + return "Interactive data application" + } + if item.classification.kind == .ai { + return "Local AI runtime" + } + return "\(item.classification.kind.rawValue) development process" + } + + private static func processInsightTitle(for item: ClassifiedDevProcess) -> String { + let tags = item.classification.tags + if tags.contains(.training) { + return "Training workload" + } + if tags.contains(.notebook) { + return "Notebook session" + } + if tags.contains(.llmServer) { + return "LLM server" + } + if tags.contains(.vectorDB) { + return "Vector database" + } + if tags.contains(.api) { + return "API service" + } + return item.classification.displayName + } + + private static func resourceBehavior(for item: ClassifiedDevProcess, metricHistory: [DevProcessMetricSample]) -> String { + let cpu = item.process.resourceUsage?.cpuPercent ?? 0 + let memory = item.process.resourceUsage?.residentMemoryBytes ?? 0 + if cpu >= 80 { + return "High CPU now; this process is actively consuming compute." + } + if memory >= 4_294_967_296 { + return "High memory footprint; check whether this is a model, notebook, or build cache." + } + if metricHistory.count >= 3, + let first = metricHistory.first, + let last = metricHistory.last, + last.residentMemoryBytes > first.residentMemoryBytes + 536_870_912 { + return "Memory increased by more than 512 MB during the visible history window." + } + return "Resource usage is within the normal range for the current sample." + } + + private static func primaryProject( + for items: [ClassifiedDevProcess], + workspaceOwnerComponents: Set + ) -> String? { + let counts = Dictionary( + grouping: items.compactMap { + canonicalProject( + for: $0, + workspaceOwnerComponents: workspaceOwnerComponents + )?.title + }, + by: { $0 } + ) + .mapValues(\.count) + return counts.sorted { lhs, rhs in + if lhs.value != rhs.value { + return lhs.value > rhs.value + } + return lhs.key.localizedStandardCompare(rhs.key) == .orderedAscending + } + .first? + .key + } + + private static func canonicalProject( + for item: ClassifiedDevProcess, + workspaceOwnerComponents: Set = configuredWorkspaceOwnerComponents + ) -> WorkflowProject? { + if let currentDirectory = item.process.currentDirectory, + let project = canonicalProject( + fromPath: currentDirectory, + workspaceOwnerComponents: workspaceOwnerComponents + ) { + return project + } + + if let project = canonicalProject( + fromCommand: item.process.command, + workspaceOwnerComponents: workspaceOwnerComponents + ) { + return project + } + + return nil + } + + private static func commandPathCandidates(_ command: String) -> [String] { + let home = NSHomeDirectory() + return commandTokens(command) + .filter { token in + token.hasPrefix(home + "/") && + !token.contains("=") && + !token.contains(".app/Contents/") + } + } + + private static func commandTokens(_ command: String) -> [String] { + var tokens: [String] = [] + var token = "" + var quote: Character? + var requiresBoundary = false + + for character in command { + if let activeQuote = quote { + if character == activeQuote { + quote = nil + requiresBoundary = true + } else { + token.append(character) + } + } else if requiresBoundary { + guard character.isWhitespace else { + return [] + } + appendCommandToken(&token, to: &tokens) + requiresBoundary = false + } else if character == "\"" || character == "'" { + guard token.isEmpty else { + return [] + } + quote = character + } else if character.isWhitespace { + appendCommandToken(&token, to: &tokens) + } else { + token.append(character) + } + } + + guard quote == nil else { + return [] + } + appendCommandToken(&token, to: &tokens) + return tokens + } + + private static func appendCommandToken(_ token: inout String, to tokens: inout [String]) { + guard !token.isEmpty else { + return + } + tokens.append(token) + token.removeAll(keepingCapacity: true) + } + + private static func canonicalProject( + fromCommand command: String, + workspaceOwnerComponents: Set = configuredWorkspaceOwnerComponents + ) -> WorkflowProject? { + commandPathCandidates(command).lazy.compactMap { + canonicalProject( + fromPath: $0, + workspaceOwnerComponents: workspaceOwnerComponents + ) + }.first + } + + static func lexicallyNormalizedAbsolutePath( + _ rawValue: String, + constrainedTo root: String? = nil + ) -> String? { + guard rawValue.hasPrefix("/") else { + return nil + } + + let requiredComponents: [Substring] + if let root { + guard let normalizedRoot = lexicallyNormalizedAbsolutePath(root) else { + return nil + } + requiredComponents = normalizedRoot.split(separator: "/") + } else { + requiredComponents = [] + } + + var components: [Substring] = [] + for component in rawValue.split(separator: "/", omittingEmptySubsequences: true) { + switch component { + case ".": + continue + case "..": + guard components.count > requiredComponents.count else { + return nil + } + components.removeLast() + default: + components.append(component) + let componentIndex = components.count - 1 + if componentIndex < requiredComponents.count, + component != requiredComponents[componentIndex] { + return nil + } + } + } + + guard components.count >= requiredComponents.count else { + return nil + } + guard !components.isEmpty else { + return "/" + } + return "/" + components.joined(separator: "/") + } + + private static func canonicalProject( + fromPath rawValue: String, + workspaceOwnerComponents: Set = configuredWorkspaceOwnerComponents + ) -> WorkflowProject? { + guard let home = lexicallyNormalizedAbsolutePath(NSHomeDirectory()), + let path = lexicallyNormalizedAbsolutePath(rawValue, constrainedTo: home) else { + return nil + } + guard path.hasPrefix(home + "/") else { + return nil + } + + let relativePath = String(path.dropFirst(home.count + 1)) + let components = relativePath.split(separator: "/").map(String.init) + guard !components.isEmpty else { + return nil + } + + let lowerComponents = components.map { $0.lowercased() } + guard !lowerComponents.contains(where: { $0.hasSuffix(".app") }) else { + return nil + } + + let root = lowerComponents[0] + if root == ".codex" { + return canonicalCodexProject(from: components) + } + + guard devWorkspaceMarkers.contains(root) else { + return nil + } + let remaining = Array(components.dropFirst()) + if let component = stableWorkspaceComponent( + from: remaining, + workspaceOwnerComponents: workspaceOwnerComponents + ) { + return WorkflowProject(id: stableID(component), title: readableProjectTitle(component)) + } + + return nil + } + + private static func canonicalCodexProject(from components: [String]) -> WorkflowProject { + if let cacheIndex = components.firstIndex(of: "cache") { + let remaining = Array(components.dropFirst(cacheIndex + 1)) + .filter { !isGenericProjectComponent($0) && !isNoisyProjectComponent($0) } + let pluginComponent = remaining.first { !$0.hasPrefix("openai-") } ?? "codex" + return WorkflowProject(id: "codex-\(stableID(pluginComponent))", title: readableProjectTitle(pluginComponent)) + } + + if components.contains(".agents") { + return WorkflowProject(id: "codex-agents", title: "Codex Agents") + } + + return WorkflowProject(id: "codex", title: "Codex") + } + + private static func stableWorkspaceComponent( + from components: S, + workspaceOwnerComponents: Set + ) -> String? where S.Element == String { + let values = Array(components) + let ownerComponents = Set(workspaceOwnerComponents.map { $0.lowercased() }) + .union(workspaceBucketComponents) + for (index, component) in values.enumerated() { + let lowered = component.lowercased() + if (ownerComponents.contains(lowered) || devWorkspaceMarkers.contains(lowered)), + index + 1 < values.count { + continue + } + if let stableComponent = stableProjectComponent(component) { + return stableComponent + } + } + return nil + } + + private static var devWorkspaceMarkers: Set { + ["apps", "code", "dev", "developer", "projects", "source", "src", "workspace", "workspaces"] + } + + private static var workspaceBucketComponents: Set { + ["example", "github", "personal", "repos", "team", "work"] + } + + private static func stableProjectComponent(_ value: String) -> String? { + guard !isGenericProjectComponent(value), !isNoisyProjectComponent(value) else { + return nil + } + return value + } + + private static var configuredWorkspaceOwnerComponents: Set { + let configured = ProcessInfo.processInfo.environment["DEVSCOPE_WORKSPACE_OWNER_COMPONENTS"] ?? "" + return Set( + configured + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } + .filter { !$0.isEmpty } + ) + } + + private static func isGenericProjectComponent(_ value: String) -> Bool { + let lowered = value.lowercased() + return [ + "cache", "plugins", "skills", "runtime", "runtimes", "node_modules", + "dist", "build", ".build", "target", "tmp", "temp", "logs", "lib", + "library", "application support", "contents", "resources" + ].contains(lowered) + } + + private static func isNoisyProjectComponent(_ value: String) -> Bool { + let lowered = value + .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "()[]{}")) + .lowercased() + if lowered.range(of: #"^\d+\.\d+(\.\d+)?([.-][a-z0-9]+)?$"#, options: .regularExpression) != nil { + return true + } + if lowered.range(of: #"^(python|python3|node|ruby|perl|java|swift|bash|zsh|sh)(\d+(\.\d+)*)?$"#, options: .regularExpression) != nil { + return true + } + if lowered.range(of: #"^[a-f0-9]{7,40}$"#, options: .regularExpression) != nil { + return true + } + if lowered.range(of: #"^kernel-[a-f0-9-]{12,}$"#, options: .regularExpression) != nil { + return true + } + if lowered.count >= 22, lowered.range(of: #"^[a-z0-9_-]+$"#, options: .regularExpression) != nil { + return true + } + return false + } + + private static func readableProjectTitle(_ value: String) -> String { + let words = value + .replacingOccurrences(of: #"[_-]+"#, with: " ", options: .regularExpression) + .split(separator: " ") + .map(String.init) + + guard !words.isEmpty else { + return value + } + + return words.map { word in + let lower = word.lowercased() + switch lower { + case "ai", "ml", "llm", "mcp", "api", "ios", "ui", "gpu", "cpu": + return lower.uppercased() + case "xcodebuildmcp": + return "XcodeBuildMCP" + case "devscope": + return "DevScope" + case "codex": + return "Codex" + default: + return lower.prefix(1).uppercased() + String(lower.dropFirst()) + } + } + .joined(separator: " ") + } + + private static func stableID(_ value: String) -> String { + let canonical = value + .precomposedStringWithCanonicalMapping + .lowercased() + let slug = canonical + .replacingOccurrences(of: #"[^a-z0-9]+"#, with: "-", options: .regularExpression) + .trimmingCharacters(in: CharacterSet(charactersIn: "-")) + + guard slug != canonical else { + return slug + } + + let escaped = canonical.utf8 + .map { String(format: "%02x", $0) } + .joined() + return "\(slug.isEmpty ? "project" : slug)--\(escaped)" + } + + private static func unique(_ workflows: [DevWorkflow]) -> [DevWorkflow] { + var seen = Set() + return workflows.filter { workflow in + seen.insert(workflow.id).inserted + } + } + + private static func uniqueTags(_ tags: [DevProcessTag]) -> [DevProcessTag] { + var seen = Set() + return tags + .sorted { lhs, rhs in + if lhs.id != rhs.id { + return lhs.id < rhs.id + } + if lhs.title != rhs.title { + return lhs.title < rhs.title + } + return lhs.symbolName < rhs.symbolName + } + .filter { tag in + seen.insert(tag.id).inserted + } + } +} diff --git a/Sources/DevScopeCore/ProcessKiller.swift b/Sources/DevScopeCore/ProcessKiller.swift new file mode 100644 index 0000000..318be6b --- /dev/null +++ b/Sources/DevScopeCore/ProcessKiller.swift @@ -0,0 +1,322 @@ +import Darwin +import Foundation + +public enum ProcessTree { + public static func descendants(of rootPID: Int32, in processes: [DevProcess]) -> [DevProcess] { + var childrenByParent: [Int32: [DevProcess]] = [:] + for process in processes { + childrenByParent[process.parentPID, default: []].append(process) + } + + var result: [DevProcess] = [] + var queue = childrenByParent[rootPID] ?? [] + var cursor = 0 + var visited = Set([rootPID]) + + while cursor < queue.count { + let next = queue[cursor] + cursor += 1 + guard visited.insert(next.pid).inserted else { + continue + } + result.append(next) + queue.append(contentsOf: childrenByParent[next.pid] ?? []) + } + + return result + } +} + +public struct ProcessSignalTarget: Equatable, Sendable { + public let expectedIdentity: ProcessIdentity + public let classifiedProcess: ClassifiedDevProcess + + init(classifiedProcess: ClassifiedDevProcess) { + expectedIdentity = ProcessIdentity(process: classifiedProcess.process) + self.classifiedProcess = classifiedProcess + } + + init(expectedProcess: DevProcess, classifiedProcess: ClassifiedDevProcess) { + expectedIdentity = ProcessIdentity(process: expectedProcess) + self.classifiedProcess = classifiedProcess + } +} + +public struct ProcessSignalPlan: Equatable, Sendable { + public let targets: [ProcessSignalTarget] + + public static func single( + _ item: ClassifiedDevProcess, + currentProcessID: Int32 + ) throws -> ProcessSignalPlan { + try validated( + targets: [ProcessSignalTarget(classifiedProcess: item)], + currentProcessID: currentProcessID + ) + } + + public static func tree( + root: ClassifiedDevProcess, + processes: [DevProcess], + classifiedProcesses: [ClassifiedDevProcess], + currentProcessID: Int32 + ) throws -> ProcessSignalPlan { + let classifiedByPID = Dictionary( + classifiedProcesses.map { ($0.process.pid, $0) }, + uniquingKeysWith: { _, latest in latest } + ) + let descendants = ProcessTree.descendants(of: root.process.pid, in: processes) + let descendantTargets = try descendants.reversed().map { process in + guard let classifiedProcess = classifiedByPID[process.pid] else { + throw ProcessKillError.targetClassificationUnavailable(pid: process.pid) + } + let target = ProcessSignalTarget( + expectedProcess: process, + classifiedProcess: classifiedProcess + ) + if target.expectedIdentity.birthToken != nil, + !target.expectedIdentity.hasSameBirthIdentity( + as: ProcessIdentity(process: classifiedProcess.process) + ) { + throw ProcessKillError.targetClassificationMismatch(pid: process.pid) + } + return target + } + return try validated( + targets: descendantTargets + [ProcessSignalTarget(classifiedProcess: root)], + currentProcessID: currentProcessID + ) + } + + private static func validated( + targets: [ProcessSignalTarget], + currentProcessID: Int32 + ) throws -> ProcessSignalPlan { + for target in targets { + guard target.expectedIdentity.birthToken != nil else { + throw ProcessKillError.expectedIdentityUnavailable( + pid: target.expectedIdentity.pid + ) + } + let decision = ProcessActionPolicy.decision( + for: target.classifiedProcess, + currentProcessID: currentProcessID + ) + if let reason = decision.reason { + throw ProcessKillError.protectedTarget( + pid: target.expectedIdentity.pid, + reason: reason + ) + } + } + return ProcessSignalPlan(targets: targets) + } +} + +public struct ProcessKiller: Sendable { + public typealias IdentityResolver = @Sendable (Int32) -> ProcessLiveIdentityResolution + public typealias SignalSender = @Sendable (Int32, Int32) throws -> Void + + private let identityResolver: IdentityResolver + private let signalSender: SignalSender + + public init() { + identityResolver = Self.resolveLiveIdentity + signalSender = Self.sendDarwinSignal + } + + public init( + identityResolver: @escaping IdentityResolver, + signalSender: @escaping SignalSender + ) { + self.identityResolver = identityResolver + self.signalSender = signalSender + } + + public func terminate( + _ item: ClassifiedDevProcess, + currentProcessID: Int32 + ) throws -> [ProcessIdentity] { + let plan = try ProcessSignalPlan.single( + item, + currentProcessID: currentProcessID + ) + return try execute(plan, signal: SIGTERM) + } + + public func forceTerminate( + _ item: ClassifiedDevProcess, + currentProcessID: Int32 + ) throws -> [ProcessIdentity] { + let plan = try ProcessSignalPlan.single( + item, + currentProcessID: currentProcessID + ) + return try execute(plan, signal: SIGKILL) + } + + public func terminateTree( + root: ClassifiedDevProcess, + processes: [DevProcess], + classifiedProcesses: [ClassifiedDevProcess], + currentProcessID: Int32 + ) throws -> [ProcessIdentity] { + let plan = try ProcessSignalPlan.tree( + root: root, + processes: processes, + classifiedProcesses: classifiedProcesses, + currentProcessID: currentProcessID + ) + return try execute(plan, signal: SIGTERM) + } + + public func forceTerminateTree( + root: ClassifiedDevProcess, + processes: [DevProcess], + classifiedProcesses: [ClassifiedDevProcess], + currentProcessID: Int32 + ) throws -> [ProcessIdentity] { + let plan = try ProcessSignalPlan.tree( + root: root, + processes: processes, + classifiedProcesses: classifiedProcesses, + currentProcessID: currentProcessID + ) + return try execute(plan, signal: SIGKILL) + } + + private func validateLiveIdentity(_ expected: ProcessIdentity) throws { + guard expected.birthToken != nil else { + throw ProcessKillError.expectedIdentityUnavailable(pid: expected.pid) + } + switch identityResolver(expected.pid) { + case let .identity(liveIdentity): + guard liveIdentity.birthToken != nil else { + throw ProcessKillError.liveIdentityUnavailable(pid: expected.pid) + } + guard expected.hasSameBirthIdentity(as: liveIdentity) else { + throw ProcessKillError.identityMismatch(pid: expected.pid) + } + case .notRunning: + throw ProcessKillError.targetNotRunning(pid: expected.pid) + case .unverifiable: + throw ProcessKillError.liveIdentityUnavailable(pid: expected.pid) + } + } + + private func execute(_ plan: ProcessSignalPlan, signal: Int32) throws -> [ProcessIdentity] { + let targets = plan.targets.map(\.expectedIdentity) + for target in targets { + try validateLiveIdentity(target) + } + var signaledIdentities: [ProcessIdentity] = [] + for target in targets { + do { + try validateLiveIdentity(target) + try signalSender(target.pid, signal) + signaledIdentities.append(target) + } catch { + guard !signaledIdentities.isEmpty else { + throw error + } + throw ProcessSignalExecutionFailure( + signaledIdentities: signaledIdentities, + failedTarget: target, + underlyingError: error + ) + } + } + return targets + } + + static func resolveLiveIdentity(pid: Int32) -> ProcessLiveIdentityResolution { + var info = proc_bsdinfo() + let size = MemoryLayout.stride + let result = withUnsafeMutablePointer(to: &info) { pointer in + pointer.withMemoryRebound(to: UInt8.self, capacity: size) { buffer in + proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, buffer, Int32(size)) + } + } + + if result == Int32(size), info.pbi_pid > 0 { + return .identity( + ProcessIdentity( + pid: Int32(bitPattern: info.pbi_pid), + birthToken: ProcessBirthToken( + seconds: info.pbi_start_tvsec, + microseconds: info.pbi_start_tvusec + ) + ) + ) + } + + if kill(pid, 0) == 0 || errno == EPERM { + return .unverifiable + } + return errno == ESRCH ? .notRunning : .unverifiable + } + + private static func sendDarwinSignal(pid: Int32, signal: Int32) throws { + guard kill(pid, signal) == 0 else { + throw ProcessKillError.signalFailed(pid: pid, errnoCode: errno) + } + } +} + +public enum ProcessLiveIdentityResolution: Equatable, Sendable { + case identity(ProcessIdentity) + case notRunning + case unverifiable +} + +public struct ProcessSignalExecutionFailure: LocalizedError { + public let signaledIdentities: [ProcessIdentity] + public let failedTarget: ProcessIdentity + public let underlyingError: any Error + + public init( + signaledIdentities: [ProcessIdentity], + failedTarget: ProcessIdentity, + underlyingError: any Error + ) { + self.signaledIdentities = signaledIdentities + self.failedTarget = failedTarget + self.underlyingError = underlyingError + } + + public var errorDescription: String? { + "Signaled \(signaledIdentities.count) process\(signaledIdentities.count == 1 ? "" : "es") before PID \(failedTarget.pid) failed: \(underlyingError.localizedDescription)" + } +} + +public enum ProcessKillError: LocalizedError, Equatable { + case protectedTarget(pid: Int32, reason: String) + case targetClassificationUnavailable(pid: Int32) + case targetClassificationMismatch(pid: Int32) + case expectedIdentityUnavailable(pid: Int32) + case liveIdentityUnavailable(pid: Int32) + case targetNotRunning(pid: Int32) + case identityMismatch(pid: Int32) + case signalFailed(pid: Int32, errnoCode: Int32) + + public var errorDescription: String? { + switch self { + case let .protectedTarget(pid, reason): + "Refused to signal PID \(pid): \(reason)" + case let .targetClassificationUnavailable(pid): + "Refused to signal process tree because PID \(pid) has no current classification" + case let .targetClassificationMismatch(pid): + "Refused to signal process tree because PID \(pid) has a stale classification identity" + case let .expectedIdentityUnavailable(pid): + "Refused to signal PID \(pid) because its original birth identity is unknown" + case let .liveIdentityUnavailable(pid): + "Refused to signal PID \(pid) because its current birth identity could not be read" + case let .targetNotRunning(pid): + "Refused to signal PID \(pid) because the selected process is no longer running" + case let .identityMismatch(pid): + "PID \(pid) no longer belongs to the selected process" + case let .signalFailed(pid, errnoCode): + "Could not signal PID \(pid): \(String(cString: strerror(errnoCode)))" + } + } +} diff --git a/Sources/DevScopeCore/ProcessMetricHistoryStore.swift b/Sources/DevScopeCore/ProcessMetricHistoryStore.swift new file mode 100644 index 0000000..97c0144 --- /dev/null +++ b/Sources/DevScopeCore/ProcessMetricHistoryStore.swift @@ -0,0 +1,56 @@ +import Foundation + +public struct ProcessMetricHistoryStore: Sendable { + private struct Entry: Sendable { + let identity: ProcessCacheIdentity + var samples: [DevProcessMetricSample] + } + + private let limit: Int + private var entries: [Int32: Entry] = [:] + + public init(limit: Int) { + precondition(limit > 0, "Metric history limit must be positive") + self.limit = limit + } + + public mutating func record( + processes: [DevProcess], + gpuMetric: DevGPUMetric?, + timestamp: Date + ) { + let activeProcessIDs = Set(processes.map(\.pid)) + let inactiveProcessIDs = entries.keys.filter { !activeProcessIDs.contains($0) } + for processID in inactiveProcessIDs { + entries.removeValue(forKey: processID) + } + + for process in processes { + let identity = ProcessCacheIdentity(process: process) + if entries[process.pid]?.identity != identity { + entries[process.pid] = Entry(identity: identity, samples: []) + } + guard let usage = process.resourceUsage else { + continue + } + + let emptyEntry = Entry(identity: identity, samples: []) + entries[process.pid, default: emptyEntry].samples.append( + DevProcessMetricSample( + timestamp: timestamp, + cpuPercent: usage.cpuPercent, + residentMemoryBytes: usage.residentMemoryBytes, + gpuPercent: gpuMetric?.utilizationPercent + ) + ) + let overflow = (entries[process.pid]?.samples.count ?? 0) - limit + if overflow > 0 { + entries[process.pid]?.samples.removeFirst(overflow) + } + } + } + + public func history(for processID: Int32) -> [DevProcessMetricSample] { + entries[processID]?.samples ?? [] + } +} diff --git a/Sources/DevScopeCore/ProcessPresentation.swift b/Sources/DevScopeCore/ProcessPresentation.swift new file mode 100644 index 0000000..3f40d22 --- /dev/null +++ b/Sources/DevScopeCore/ProcessPresentation.swift @@ -0,0 +1,692 @@ +import CryptoKit +import Foundation + +public struct DevProcessCategory: Identifiable, Equatable, Sendable { + public let id: String + public let title: String + public let symbolName: String + public let count: Int + + public static func all(count: Int) -> DevProcessCategory { + DevProcessCategory(id: "all", title: "All", symbolName: "scope", count: count) + } +} + +public struct ProcessPresentationSummary: Equatable, Sendable { + public let totalCount: Int + public let countsByKind: [DevRuntimeKind: Int] + public let projectCounts: [String: Int] + + public var projectCount: Int { + projectCounts.count + } + + public var primaryProject: String? { + projectCounts.sorted { lhs, rhs in + if lhs.value != rhs.value { + return lhs.value > rhs.value + } + return lhs.key.localizedStandardCompare(rhs.key) == .orderedAscending + } + .first? + .key + } + + public func count(for kind: DevRuntimeKind) -> Int { + countsByKind[kind, default: 0] + } +} + +public struct ProcessFamilySummary: Equatable, Sendable { + public let parentPID: Int32 + public let childCount: Int + public let descendantCount: Int + + public init(parentPID: Int32, childCount: Int, descendantCount: Int) { + self.parentPID = parentPID + self.childCount = childCount + self.descendantCount = descendantCount + } +} + +public struct ProcessDashboardStats: Equatable, Sendable { + public let visibleCount: Int + public let totalCount: Int + public let aiMLCount: Int + public let runtimeCounts: [DevRuntimeKind: Int] + public let topCPU: ClassifiedDevProcess? + public let topMemory: ClassifiedDevProcess? + public let latestGPUPercent: Double? + + public init( + visibleCount: Int, + totalCount: Int, + aiMLCount: Int, + runtimeCounts: [DevRuntimeKind: Int], + topCPU: ClassifiedDevProcess?, + topMemory: ClassifiedDevProcess?, + latestGPUPercent: Double? + ) { + self.visibleCount = visibleCount + self.totalCount = totalCount + self.aiMLCount = aiMLCount + self.runtimeCounts = runtimeCounts + self.topCPU = topCPU + self.topMemory = topMemory + self.latestGPUPercent = latestGPUPercent + } +} + +public enum ProcessSortOption: String, CaseIterable, Identifiable, Sendable { + case cpuDescending + case memoryDescending + case processNameAscending + case runtimeAscending + case pidAscending + + public var id: String { rawValue } + + public var title: String { + switch self { + case .cpuDescending: + "CPU" + case .memoryDescending: + "Memory" + case .processNameAscending: + "Name" + case .runtimeAscending: + "Kind" + case .pidAscending: + "PID" + } + } + + public var symbolName: String { + switch self { + case .cpuDescending: + "cpu" + case .memoryDescending: + "memorychip" + case .processNameAscending: + "textformat" + case .runtimeAscending: + "square.grid.2x2" + case .pidAscending: + "number" + } + } +} + +public enum ProcessPresentation { + public static let allCategoryID = "all" + public static let favoritesCategoryID = "favorites" + public static let watchedCategoryID = "watched" + + public static func filtered( + _ items: [ClassifiedDevProcess], + categoryID: String, + searchText: String, + favoriteKeys: Set = [], + watchedKeys: Set = [] + ) -> [ClassifiedDevProcess] { + let terms = searchTerms(from: searchText) + + return items.filter { item in + let categoryMatches = + switch categoryID { + case allCategoryID: + true + case favoritesCategoryID: + isSaved(item, in: favoriteKeys) + case watchedCategoryID: + isSaved(item, in: watchedKeys) + default: + categoryID == item.classification.kind.id + } + + guard categoryMatches else { + return false + } + + guard !terms.isEmpty else { + return true + } + + let searchableText = searchableText(for: item) + return terms.allSatisfy { searchableText.contains($0) } + } + } + + public static func categories( + for items: [ClassifiedDevProcess], + favoriteKeys: Set = [], + watchedKeys: Set = [] + ) -> [DevProcessCategory] { + let grouped = Dictionary(grouping: items, by: \.classification.kind) + let favoriteCount = savedCount(in: items, keys: favoriteKeys) + let watchedCount = savedCount(in: items, keys: watchedKeys) + let savedCategories = [ + DevProcessCategory( + id: favoritesCategoryID, title: "Favorites", symbolName: "star.fill", count: favoriteCount), + DevProcessCategory( + id: watchedCategoryID, title: "Watched", symbolName: "eye.fill", count: watchedCount), + ].filter { $0.count > 0 } + let detected = grouped.map { kind, groupedItems in + DevProcessCategory( + id: kind.id, + title: kind.rawValue, + symbolName: kind.symbolName, + count: groupedItems.count + ) + } + .sorted { lhs, rhs in + if lhs.count != rhs.count { + return lhs.count > rhs.count + } + return lhs.title.localizedStandardCompare(rhs.title) == .orderedAscending + } + + return [.all(count: items.count)] + savedCategories + detected + } + + static func savedCount( + in items: [ClassifiedDevProcess], + keys: Set, + matching: (ClassifiedDevProcess, Set) -> Bool = ProcessPresentation.isSaved + ) -> Int { + guard !keys.isEmpty else { return 0 } + return items.filter { matching($0, keys) }.count + } + + public static func sorted( + _ items: [ClassifiedDevProcess], + by option: ProcessSortOption + ) -> [ClassifiedDevProcess] { + items.sorted { lhs, rhs in + switch option { + case .cpuDescending: + if cpu(lhs) != cpu(rhs) { + return cpu(lhs) > cpu(rhs) + } + case .memoryDescending: + if memory(lhs) != memory(rhs) { + return memory(lhs) > memory(rhs) + } + case .processNameAscending: + let comparison = lhs.classification.displayName.localizedStandardCompare( + rhs.classification.displayName) + if comparison != .orderedSame { + return comparison == .orderedAscending + } + case .runtimeAscending: + let comparison = lhs.classification.kind.rawValue.localizedStandardCompare( + rhs.classification.kind.rawValue) + if comparison != .orderedSame { + return comparison == .orderedAscending + } + case .pidAscending: + if lhs.process.pid != rhs.process.pid { + return lhs.process.pid < rhs.process.pid + } + } + + return stableFallback(lhs, rhs) + } + } + + public static func summary(for items: [ClassifiedDevProcess]) -> ProcessPresentationSummary { + var countsByKind: [DevRuntimeKind: Int] = [:] + var projectCounts: [String: Int] = [:] + + for item in items { + countsByKind[item.classification.kind, default: 0] += 1 + if let projectName = projectName(for: item) { + projectCounts[projectName, default: 0] += 1 + } + } + + return ProcessPresentationSummary( + totalCount: items.count, + countsByKind: countsByKind, + projectCounts: projectCounts + ) + } + + public static func dashboardStats( + visibleItems: [ClassifiedDevProcess], + totalItems: [ClassifiedDevProcess], + dashboardMetricHistory: [DevProcessMetricSample] = [] + ) -> ProcessDashboardStats { + let runtimeCounts = Dictionary(grouping: totalItems, by: \.classification.kind) + .mapValues(\.count) + let aiMLCount = totalItems.filter { item in + item.classification.kind == .ai || !item.classification.tags.isEmpty + } + .count + let topCPU = totalItems.max { lhs, rhs in + cpu(lhs) < cpu(rhs) + } + let topMemory = totalItems.max { lhs, rhs in + memory(lhs) < memory(rhs) + } + let latestGPUPercent = dashboardMetricHistory.last { $0.gpuPercent != nil }?.gpuPercent + + return ProcessDashboardStats( + visibleCount: visibleItems.count, + totalCount: totalItems.count, + aiMLCount: aiMLCount, + runtimeCounts: runtimeCounts, + topCPU: topCPU, + topMemory: topMemory, + latestGPUPercent: latestGPUPercent + ) + } + + public static func familySummary( + for process: DevProcess, + in processes: [DevProcess] + ) -> ProcessFamilySummary { + ProcessFamilySummary( + parentPID: process.parentPID, + childCount: processes.filter { $0.parentPID == process.pid }.count, + descendantCount: ProcessTree.descendants(of: process.pid, in: processes).count + ) + } + + public static func searchableText(for item: ClassifiedDevProcess) -> String { + [ + item.classification.displayName, + item.classification.kind.rawValue, + item.classification.projectHint ?? "", + item.process.executable, + item.process.executableName, + item.process.command, + item.process.currentDirectory ?? "", + "\(item.process.pid)", + "\(item.process.parentPID)", + ] + .joined(separator: " ") + .lowercased() + } + + public static func projectName(for item: ClassifiedDevProcess) -> String? { + if let projectHint = item.classification.projectHint, !projectHint.isEmpty { + return projectHint + } + + guard let currentDirectory = item.process.currentDirectory else { + return nil + } + + let name = URL(fileURLWithPath: currentDirectory).lastPathComponent + return name.isEmpty || name == "/" ? nil : name + } + + public static func contextLabel(for item: ClassifiedDevProcess) -> String { + if let projectName = projectName(for: item) { + return projectName + } + + let executableName = item.process.executableName.trimmingCharacters(in: .whitespacesAndNewlines) + let displayName = item.classification.displayName.trimmingCharacters( + in: .whitespacesAndNewlines) + + if executableName.isEmpty + || shouldHideExecutableContext(executableName, displayName: displayName) + { + return item.classification.kind.rawValue + } + + return executableName + } + + public static func executablePath(for item: ClassifiedDevProcess) -> String { + let executableName = item.process.executableName.trimmingCharacters(in: .whitespacesAndNewlines) + let displayName = item.classification.displayName.trimmingCharacters( + in: .whitespacesAndNewlines) + + if shouldHideExecutableContext(executableName, displayName: displayName), + let commandPath = leadingAbsoluteCommandPath(item.process.command) + { + return commandPath + } + + return item.process.executable + } + + public static func identityKey(for item: ClassifiedDevProcess) -> String { + hashedIdentityKey( + executableName: item.process.executableName, + currentDirectory: item.process.currentDirectory ?? "", + command: item.process.command + ) + } + + public static func identityKeys(for item: ClassifiedDevProcess) -> Set { + let fallback = hashedIdentityKey( + executableName: item.process.executableName, + currentDirectory: "", + command: item.process.command + ) + + return [identityKey(for: item), fallback] + } + + private static func hashedIdentityKey( + executableName: String, + currentDirectory: String, + command: String + ) -> String { + let source = [executableName, currentDirectory, command] + .joined(separator: "\u{1F}") + .lowercased() + return hashedIdentityKey(source: source) + } + + private static func hashedIdentityKey(source: String) -> String { + let digest = SHA256.hash(data: Data(source.utf8)) + .map { String(format: "%02x", $0) } + .joined() + return "v2:\(digest)" + } + + public static func isSaved(_ item: ClassifiedDevProcess, in keys: Set) -> Bool { + isSaved(item, in: keys, identityKeys: identityKeys(for:)) + } + + static func isSaved( + _ item: ClassifiedDevProcess, + in keys: Set, + identityKeys: (ClassifiedDevProcess) -> Set + ) -> Bool { + guard !keys.isEmpty else { return false } + return !identityKeys(item).isDisjoint(with: keys) + } + + public static func sanitizedSavedIdentityKeys(_ keys: Set) -> Set { + Set( + keys.compactMap { key in + if key.hasPrefix("v2:"), key.count == 67, + key.dropFirst(3).allSatisfy({ $0.isHexDigit && !$0.isUppercase }) + { + return key + } + + let legacyComponents = key.split( + separator: "\u{1F}", omittingEmptySubsequences: false) + guard legacyComponents.count == 3 else { + return nil + } + return hashedIdentityKey(source: key.lowercased()) + }) + } + + public static func elapsedSeconds(_ value: String) -> Int64 { + let dayAndTime = value.split(separator: "-", maxSplits: 1, omittingEmptySubsequences: false) + guard dayAndTime.count <= 2 else { + return -1 + } + + let days: Int64 + let time: Substring + if dayAndTime.count == 2 { + guard let parsedDays = Int64(dayAndTime[0]), + parsedDays >= 0, + parsedDays <= Int64.max / 86_400 + else { + return -1 + } + days = parsedDays + time = dayAndTime[1] + } else { + days = 0 + time = dayAndTime[0] + } + + let components = time.split(separator: ":", omittingEmptySubsequences: false) + guard (2...3).contains(components.count), + let seconds = Int64(components[components.count - 1]), + let minutes = Int64(components[components.count - 2]), + seconds >= 0, seconds < 60, + minutes >= 0, minutes < 60 + else { + return -1 + } + let hours = components.count == 3 ? Int64(components[0]) : 0 + guard let hours, hours >= 0, hours < 24 else { + return -1 + } + return days * 86_400 + hours * 3_600 + minutes * 60 + seconds + } + + public static func redactedCommand(_ command: String) -> String { + let commandWithRedactedHeaders = redactingSensitiveHeaderValues(in: command) + let sensitiveReplacements = [ + ( + #"(?i)(?" + ), + ( + #"(?i)(?" + ), + ( + #"(?i)\b(bearer)(\s+)([A-Za-z0-9._~+/\-=]+)"#, + "$1$2" + ), + ( + #"([A-Za-z][A-Za-z0-9+.-]*://)([^/\s:@]+):([^/\s@]+)@"#, + "$1@" + ), + ( + #"(?i)(\bredis-cli\b[^\r\n]*?\s-a)(\s+).*?(?=[\r\n]|$)"#, + "$1$2" + ), + (#"(?i)(\bmysql\b[^\r\n]*?\s-p).*?(?=[\r\n]|$)"#, "$1"), + ( + #"(?i)(\bcurl\b[^\r\n]*?\s--(?:user|proxy-user))(=|\s+).*?(?=[\r\n]|$)"#, + "$1$2" + ), + ( + #"(?i)(\bcurl\b[^\r\n]*?\s-u)(\s+).*?(?=[\r\n]|$)"#, + "$1$2" + ), + ] + + return sensitiveReplacements.reduce(commandWithRedactedHeaders) { partial, replacement in + partial.replacingOccurrences( + of: replacement.0, + with: replacement.1, + options: .regularExpression + ) + } + } + + private static func redactingSensitiveHeaderValues(in command: String) -> String { + let headerPattern = #"(?i)\b((?:proxy-)?authorization|cookie|set-cookie|[A-Za-z0-9_-]*(?:token|api[-_]?key|secret|credential)[A-Za-z0-9_-]*)(\s*:\s*)((?:basic|bearer|digest|negotiate|aws4-hmac-sha256)\s+)?"# + guard let regex = try? NSRegularExpression(pattern: headerPattern) else { + return command + } + + let redacted = NSMutableString(string: command) + let replacement = "" + var searchLocation = 0 + + while searchLocation < redacted.length { + let current = redacted as String + let searchRange = NSRange(location: searchLocation, length: redacted.length - searchLocation) + guard let match = regex.firstMatch(in: current, range: searchRange) else { + break + } + + let valueStart = NSMaxRange(match.range) + let enclosingQuote = activeShellQuote(at: match.range.location, in: redacted) + let valueEnd = sensitiveHeaderValueEnd( + from: valueStart, + enclosingQuote: enclosingQuote, + in: redacted + ) + redacted.replaceCharacters( + in: NSRange(location: valueStart, length: valueEnd - valueStart), + with: replacement + ) + searchLocation = valueStart + replacement.utf16.count + if enclosingQuote != nil, searchLocation < redacted.length { + searchLocation += 1 + } + } + + return redacted as String + } + + private static func activeShellQuote(at location: Int, in value: NSString) -> unichar? { + var quote: unichar? + var index = 0 + + while index < location { + let character = value.character(at: index) + if quote == 0x27 { + if character == 0x27 { + quote = nil + } + index += 1 + continue + } + if character == 0x5C { + index += 2 + continue + } + if character == 0x22 { + quote = quote == 0x22 ? nil : 0x22 + } else if character == 0x27, quote == nil { + quote = 0x27 + } + index += 1 + } + + return quote + } + + private static func sensitiveHeaderValueEnd( + from start: Int, + enclosingQuote: unichar?, + in value: NSString + ) -> Int { + var index = start + while index < value.length { + let character = value.character(at: index) + if let enclosingQuote { + if enclosingQuote == 0x22, character == 0x5C { + index += 2 + continue + } + if character == enclosingQuote { + return index + } + } else { + if character == 0x5C { + index += 2 + continue + } + if character == 0x09 || character == 0x0A || character == 0x0D || character == 0x20 { + return index + } + } + index += 1 + } + return value.length + } + + public static func exportRows(_ items: [ClassifiedDevProcess]) -> String { + let header = "pid\tppid\tkind\tcpu\tmemory\tproject\tprocess\tcommand" + let rows = items.map { item in + [ + "\(item.process.pid)", + "\(item.process.parentPID)", + item.classification.kind.rawValue, + String(format: "%.1f", item.process.resourceUsage?.cpuPercent ?? 0), + "\(item.process.resourceUsage?.residentMemoryBytes ?? 0)", + exportCell(projectName(for: item) ?? ""), + exportCell(item.classification.displayName), + exportCell(redactedCommand(item.process.command)), + ].joined(separator: "\t") + } + + return ([header] + rows).joined(separator: "\n") + } + + private static func exportCell(_ value: String) -> String { + let singleLine = + value + .replacingOccurrences(of: "\t", with: " ") + .replacingOccurrences(of: "\r", with: " ") + .replacingOccurrences(of: "\n", with: " ") + guard let first = singleLine.first, "=+-@".contains(first) else { + return singleLine + } + return "'" + singleLine + } + + private static func shouldHideExecutableContext(_ executableName: String, displayName: String) + -> Bool + { + let executable = executableName.lowercased() + let display = displayName.lowercased() + + if [ + "a", + "bin", + "contents", + "coreservices", + "frameworks", + "helpers", + "library", + "macos", + "privateframeworks", + "resources", + "support", + "versions", + "xpcservices", + ].contains(executable) { + return true + } + + return executable.count <= 3 && display.hasPrefix(executable) + && display.count > executable.count + } + + private static func leadingAbsoluteCommandPath(_ command: String) -> String? { + let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix("/") else { + return nil + } + + return trimmed.split(whereSeparator: { $0.isWhitespace }).first.map(String.init) + } + + private static func searchTerms(from searchText: String) -> [String] { + searchText + .split(whereSeparator: { $0.isWhitespace }) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } + .filter { !$0.isEmpty } + } + + private static func cpu(_ item: ClassifiedDevProcess) -> Double { + item.process.resourceUsage?.cpuPercent ?? 0 + } + + private static func memory(_ item: ClassifiedDevProcess) -> Int64 { + item.process.resourceUsage?.residentMemoryBytes ?? 0 + } + + private static func stableFallback(_ lhs: ClassifiedDevProcess, _ rhs: ClassifiedDevProcess) + -> Bool + { + let nameComparison = lhs.classification.displayName.localizedStandardCompare( + rhs.classification.displayName) + if nameComparison != .orderedSame { + return nameComparison == .orderedAscending + } + return lhs.process.pid < rhs.process.pid + } +} diff --git a/Sources/DevScopeCore/ProcessScanner.swift b/Sources/DevScopeCore/ProcessScanner.swift new file mode 100644 index 0000000..467e35c --- /dev/null +++ b/Sources/DevScopeCore/ProcessScanner.swift @@ -0,0 +1,733 @@ +import Darwin +import Foundation + +public protocol ProcessProviding: Sendable { + func snapshot(includeCurrentDirectories: Bool) throws -> [DevProcess] +} + +protocol NativeProcessMetadataProviding: Sendable { + func metadata(for processID: Int32) -> DevProcess? +} + +protocol ProcessArgumentVectorProviding: Sendable { + func argumentVector(for processID: Int32) -> [String]? +} + +protocol ProcessBirthTokenProviding: Sendable { + func birthToken(for processID: Int32) -> ProcessBirthToken? +} + +struct KernelProcessBirthTokenProvider: ProcessBirthTokenProviding { + func birthToken(for processID: Int32) -> ProcessBirthToken? { + guard processID > 0 else { return nil } + var info = proc_bsdinfo() + let size = MemoryLayout.stride + let result = withUnsafeMutablePointer(to: &info) { pointer in + pointer.withMemoryRebound(to: UInt8.self, capacity: size) { buffer in + proc_pidinfo(processID, PROC_PIDTBSDINFO, 0, buffer, Int32(size)) + } + } + guard result == Int32(size), info.pbi_pid > 0 else { return nil } + return ProcessBirthToken( + seconds: info.pbi_start_tvsec, + microseconds: info.pbi_start_tvusec + ) + } +} + +struct KernelProcessArgumentVectorProvider: ProcessArgumentVectorProviding { + private static let maximumBufferSize = 1_048_576 + private static let maximumArgumentCount: Int32 = 4_096 + + func argumentVector(for processID: Int32) -> [String]? { + guard processID > 0 else { return nil } + var mib = [Int32(CTL_KERN), Int32(KERN_PROCARGS2), processID] + var requiredSize = 0 + guard sysctl(&mib, u_int(mib.count), nil, &requiredSize, nil, 0) == 0, + requiredSize >= MemoryLayout.size, + requiredSize <= Self.maximumBufferSize else { return nil } + var buffer = [UInt8](repeating: 0, count: requiredSize) + let status = buffer.withUnsafeMutableBytes { rawBuffer in + sysctl(&mib, u_int(mib.count), rawBuffer.baseAddress, &requiredSize, nil, 0) + } + guard status == 0, requiredSize <= buffer.count else { return nil } + return Self.parse(buffer: Array(buffer.prefix(requiredSize))) + } + + static func parse(buffer: [UInt8]) -> [String]? { + guard buffer.count >= MemoryLayout.size, + buffer.count <= maximumBufferSize else { return nil } + var argumentCount: Int32 = 0 + withUnsafeMutableBytes(of: &argumentCount) { destination in + buffer.prefix(MemoryLayout.size).withUnsafeBytes { source in + destination.copyBytes(from: source) + } + } + guard argumentCount > 0, argumentCount <= maximumArgumentCount else { return nil } + + var cursor = MemoryLayout.size + guard let executableEnd = buffer[cursor...].firstIndex(of: 0), + executableEnd > cursor, + let executable = String(bytes: buffer[cursor.. Bool { + let bytes = Array(value.utf8) + guard let equals = bytes.firstIndex(of: 61), equals > 0 else { return false } + let name = bytes[.. String? +} + +struct FoundationProcessBundleIdentityProvider: ProcessBundleIdentityProviding { + func bundleIdentifier(forExecutableURL executableURL: URL, processID _: Int32) -> String? { + let executable = executableURL.resolvingSymlinksInPath().standardizedFileURL + var candidate = executable.deletingLastPathComponent() + while candidate.path != "/" { + if candidate.pathExtension.caseInsensitiveCompare("app") == .orderedSame { + if let bundle = Bundle(url: candidate), + let bundleExecutable = bundle.executableURL?.resolvingSymlinksInPath().standardizedFileURL, + bundleExecutable.path == executable.path, + let identifier = bundle.bundleIdentifier, + !identifier.isEmpty { + return identifier + } + } + candidate.deleteLastPathComponent() + } + return nil + } +} + +public enum ProcessScanner { + static func bundleIdentifier( + forExecutableURL executableURL: URL, + processID: Int32, + provider: any ProcessBundleIdentityProviding + ) -> String? { + provider.bundleIdentifier(forExecutableURL: executableURL, processID: processID) + } + + public static func parsePSLine(_ line: String) -> DevProcess? { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return nil + } + + let parts = trimmed.split(separator: " ", maxSplits: 6, omittingEmptySubsequences: true) + guard parts.count >= 3, + let pid = Int32(parts[0]), + let parentPID = Int32(parts[1]) else { + return nil + } + + if parts.count >= 6, + let cpuPercent = Double(parts[2]), + let residentMemoryKilobytes = Int64(parts[3]) { + let elapsedTime = String(parts[4]) + let executable = String(parts[5]) + let command = parts.count == 7 ? String(parts[6]) : executable + return DevProcess( + pid: pid, + parentPID: parentPID, + executable: executable, + command: command, + resourceUsage: DevProcessResourceUsage( + cpuPercent: cpuPercent, + residentMemoryBytes: residentMemoryKilobytes * 1024, + elapsedTime: elapsedTime + ) + ) + } + + let legacyParts = trimmed.split(separator: " ", maxSplits: 3, omittingEmptySubsequences: true) + guard legacyParts.count >= 3 else { + return nil + } + + let executable = String(legacyParts[2]) + let command = legacyParts.count == 4 ? String(legacyParts[3]) : executable + return DevProcess(pid: pid, parentPID: parentPID, executable: executable, command: command) + } + + public static func parseLsofCurrentDirectories(_ output: String) -> [Int32: String] { + var currentPID: Int32? + var result: [Int32: String] = [:] + + for line in output.split(separator: "\n", omittingEmptySubsequences: true) { + if line.hasPrefix("p") { + currentPID = Int32(line.dropFirst()) + } else if line.hasPrefix("n"), let currentPID { + result[currentPID] = String(line.dropFirst()) + } + } + + return result + } + + static func excludingScannerHelper( + from processes: [DevProcess], + helperPID: Int32 + ) -> [DevProcess] { + processes.filter { $0.pid != helperPID } + } +} + +public final class SystemProcessScanner: @unchecked Sendable, ProcessProviding { + private struct NativeMetadataIdentity: Equatable { + let parentPID: Int32 + let executable: String + let command: String + let birthToken: ProcessBirthToken? + } + + private struct NativeMetadataCacheEntry { + let identity: NativeMetadataIdentity + let process: DevProcess? + let attemptedAt: Date + } + + private static let negativeMetadataRetryInterval: TimeInterval = 120 + private static let negativeMetadataRetryBudget = 8 + private let fallbackScanner: NativeProcessScanner + private let nativeMetadataProvider: any NativeProcessMetadataProviding + private let commandRunner: any SystemCommandRunning + private let lock = NSLock() + private var cachedNativeMetadata: [Int32: NativeMetadataCacheEntry] = [:] + + public init() { + let nativeScanner = NativeProcessScanner() + fallbackScanner = nativeScanner + nativeMetadataProvider = nativeScanner + commandRunner = BoundedSystemCommandRunner() + } + + init( + nativeMetadataProvider: any NativeProcessMetadataProviding, + commandRunner: any SystemCommandRunning = BoundedSystemCommandRunner() + ) { + fallbackScanner = NativeProcessScanner() + self.nativeMetadataProvider = nativeMetadataProvider + self.commandRunner = commandRunner + } + + public func snapshot(includeCurrentDirectories: Bool = true) throws -> [DevProcess] { + let result: BoundedSystemCommandResult + do { + result = try commandRunner.run( + executableURL: URL(fileURLWithPath: "/bin/ps"), + arguments: ["-axo", "pid=,ppid=,%cpu=,rss=,etime=,comm=,args="] + ) + } catch { + return try fallbackScanner.snapshot(includeCurrentDirectories: false) + } + let output = String(decoding: result.standardOutput, as: UTF8.self) + let errorOutput = String(decoding: result.standardError, as: UTF8.self) + + guard result.status == 0 else { + if errorOutput.localizedCaseInsensitiveContains("operation not permitted") { + return try fallbackScanner.snapshot(includeCurrentDirectories: false) + } + + throw ProcessScannerError.psFailed(status: result.status, output: errorOutput) + } + + let psProcesses = ProcessScanner.excludingScannerHelper( + from: output + .split(separator: "\n", omittingEmptySubsequences: true) + .compactMap { ProcessScanner.parsePSLine(String($0)) }, + helperPID: result.processIdentifier ?? -1 + ) + guard !psProcesses.isEmpty else { + return try fallbackScanner.snapshot(includeCurrentDirectories: false) + } + + let processIDs = Set(psProcesses.map(\.pid)) + let birthTokens = fallbackScanner.birthTokens(for: processIDs) + let nativeByPID = nativeMetadata(for: psProcesses, birthTokens: birthTokens) + let currentDirectories = includeCurrentDirectories ? currentDirectoryMap() : [:] + + return psProcesses.map { process in + ProcessScanner.mergedProcess( + psProcess: process, + nativeProcess: nativeByPID[process.pid], + currentDirectory: currentDirectories[process.pid], + birthToken: birthTokens[process.pid] + ) + } + } + + func nativeMetadata( + for processes: [DevProcess], + birthTokens: [Int32: ProcessBirthToken], + now: Date = Date() + ) -> [Int32: DevProcess] { + lock.withLock { + let activeProcessIDs = Set(processes.map(\.pid)) + let inactiveProcessIDs = cachedNativeMetadata.keys.filter { !activeProcessIDs.contains($0) } + for processID in inactiveProcessIDs { + cachedNativeMetadata.removeValue(forKey: processID) + } + + let expiredNegativeRetries = processes.compactMap { process -> (processID: Int32, attemptedAt: Date)? in + let identity = NativeMetadataIdentity( + parentPID: process.parentPID, + executable: process.executable, + command: process.command, + birthToken: birthTokens[process.pid] + ) + guard let cached = cachedNativeMetadata[process.pid], + cached.identity == identity, + cached.process == nil, + now.timeIntervalSince(cached.attemptedAt) >= Self.negativeMetadataRetryInterval else { + return nil + } + return (process.pid, cached.attemptedAt) + } + .sorted { lhs, rhs in + if lhs.attemptedAt != rhs.attemptedAt { + return lhs.attemptedAt < rhs.attemptedAt + } + return lhs.processID < rhs.processID + } + let negativeRetryProcessIDs = Set( + expiredNegativeRetries.prefix(Self.negativeMetadataRetryBudget).map(\.processID) + ) + + var result: [Int32: DevProcess] = [:] + for process in processes { + let identity = NativeMetadataIdentity( + parentPID: process.parentPID, + executable: process.executable, + command: process.command, + birthToken: birthTokens[process.pid] + ) + let metadata: DevProcess? + let cached = cachedNativeMetadata[process.pid] + let hasStableCachedIdentity = cached.map { $0.identity == identity } ?? false + let shouldQuery = !hasStableCachedIdentity || + negativeRetryProcessIDs.contains(process.pid) + if shouldQuery { + let candidate = nativeMetadataProvider.metadata(for: process.pid) + metadata = candidate.flatMap { nativeProcess in + guard nativeProcess.pid == process.pid, + let birthToken = birthTokens[process.pid], + nativeProcess.birthToken == birthToken else { + return nil + } + return nativeProcess + } + cachedNativeMetadata[process.pid] = NativeMetadataCacheEntry( + identity: identity, + process: metadata, + attemptedAt: now + ) + } else { + metadata = cached?.process + } + + if let metadata { + result[process.pid] = metadata + } + } + return result + } + } + + private func currentDirectoryMap() -> [Int32: String] { + let result: BoundedSystemCommandResult + do { + result = try commandRunner.run( + executableURL: URL(fileURLWithPath: "/usr/sbin/lsof"), + arguments: ["-a", "-d", "cwd", "-n", "-w", "-F", "pcn"] + ) + } catch { + return [:] + } + + guard result.status == 0 else { + return [:] + } + + return ProcessScanner.parseLsofCurrentDirectories( + String(decoding: result.standardOutput, as: UTF8.self) + ) + } +} + +extension ProcessScanner { + static func mergedProcess( + psProcess: DevProcess, + nativeProcess: DevProcess?, + currentDirectory: String?, + birthToken: ProcessBirthToken? + ) -> DevProcess { + let nativeProcess = nativeProcess.flatMap { nativeProcess -> DevProcess? in + guard let birthToken, + nativeProcess.birthToken == birthToken else { + return nil + } + return nativeProcess + } + + guard let nativeProcess else { + return DevProcess( + pid: psProcess.pid, + parentPID: psProcess.parentPID, + executable: psProcess.executable, + command: psProcess.command, + argumentVector: nil, + currentDirectory: currentDirectory, + resourceUsage: psProcess.resourceUsage, + birthToken: birthToken, + bundleIdentifier: nil, + launchLabel: nil + ) + } + + let nativeExecutable = nativeProcess.command.contains("/") ? nativeProcess.command : nativeProcess.executable + let useNativeExecutable = shouldPreferNativeExecutable( + psExecutable: psProcess.executable, + nativeExecutable: nativeExecutable + ) + let mergedCommand = shouldPreferNativeCommand( + psCommand: psProcess.command, + psExecutable: psProcess.executable + ) && useNativeExecutable ? nativeExecutable : psProcess.command + + return DevProcess( + pid: psProcess.pid, + parentPID: psProcess.parentPID, + executable: useNativeExecutable ? nativeExecutable : psProcess.executable, + command: mergedCommand, + argumentVector: nativeProcess.argumentVector, + currentDirectory: currentDirectory, + resourceUsage: psProcess.resourceUsage, + birthToken: birthToken, + bundleIdentifier: nativeProcess.bundleIdentifier, + launchLabel: nativeProcess.launchLabel + ) + } + + public static func carriedCurrentDirectory( + from previousProcess: DevProcess, + to currentProcess: DevProcess + ) -> String? { + guard let currentDirectory = previousProcess.currentDirectory, + let previousBirthToken = previousProcess.birthToken, + let currentBirthToken = currentProcess.birthToken, + previousBirthToken == currentBirthToken else { + return nil + } + return currentDirectory + } + + static func shouldPreferNativeExecutable(psExecutable: String, nativeExecutable: String) -> Bool { + let psExecutable = psExecutable.trimmingCharacters(in: .whitespacesAndNewlines) + let nativeExecutable = nativeExecutable.trimmingCharacters(in: .whitespacesAndNewlines) + guard !nativeExecutable.isEmpty else { + return false + } + + let psName = lexicalLastPathComponent(psExecutable) + guard !psName.isEmpty else { + return true + } + + if isGenericExecutableName(psName) { + return true + } + + let lowerPSName = psName.lowercased() + let nativeName = lexicalLastPathComponent(nativeExecutable) + let lowerNativeName = nativeName.lowercased() + return lowerNativeName.hasPrefix(lowerPSName) && nativeName.count > psName.count + } + + private static func shouldPreferNativeCommand(psCommand: String, psExecutable: String) -> Bool { + let command = psCommand.trimmingCharacters(in: .whitespacesAndNewlines) + let executable = psExecutable.trimmingCharacters(in: .whitespacesAndNewlines) + + guard !command.isEmpty else { + return true + } + + if command == executable { + return true + } + + if !command.contains("/"), command.split(whereSeparator: { $0.isWhitespace }).count == 1 { + return true + } + + return false + } + + private static func lexicalLastPathComponent(_ path: String) -> String { + path.split(separator: "/", omittingEmptySubsequences: true).last.map(String.init) ?? "" + } + + private static func isGenericExecutableName(_ name: String) -> Bool { + [ + "a", + "bin", + "contents", + "coreservices", + "frameworks", + "helpers", + "library", + "macos", + "privateframeworks", + "resources", + "support", + "versions", + "xpcservices" + ].contains(name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()) + } +} + +public struct NativeProcessScanner: ProcessProviding, NativeProcessMetadataProviding { + private let pathBufferSize = 4096 + private let bundleIdentityProvider: any ProcessBundleIdentityProviding + private let argumentVectorProvider: any ProcessArgumentVectorProviding + private let finalBirthTokenProvider: any ProcessBirthTokenProviding + + public init() { + bundleIdentityProvider = FoundationProcessBundleIdentityProvider() + argumentVectorProvider = KernelProcessArgumentVectorProvider() + finalBirthTokenProvider = KernelProcessBirthTokenProvider() + } + + init( + bundleIdentityProvider: any ProcessBundleIdentityProviding, + argumentVectorProvider: any ProcessArgumentVectorProviding = KernelProcessArgumentVectorProvider(), + finalBirthTokenProvider: any ProcessBirthTokenProviding = KernelProcessBirthTokenProvider() + ) { + self.bundleIdentityProvider = bundleIdentityProvider + self.argumentVectorProvider = argumentVectorProvider + self.finalBirthTokenProvider = finalBirthTokenProvider + } + + public func snapshot(includeCurrentDirectories: Bool = false) throws -> [DevProcess] { + let pids = allProcessIDs() + let processes = pids.compactMap(process) + + if processes.isEmpty { + throw ProcessScannerError.nativeScannerEmpty + } + + return processes + } + + func birthTokens(for processIDs: Set) -> [Int32: ProcessBirthToken] { + Dictionary( + uniqueKeysWithValues: processIDs.compactMap { pid in + birthToken(for: pid).map { (pid, $0) } + } + ) + } + + func birthToken(for pid: Int32) -> ProcessBirthToken? { + guard let info = bsdInfo(pid: pid) else { + return nil + } + return birthToken(from: info) + } + + func metadata(for processID: Int32) -> DevProcess? { + process(pid: processID) + } + + func argumentVector(for processID: Int32) -> [String]? { + argumentVectorProvider.argumentVector(for: processID) + } + + private func allProcessIDs() -> [pid_t] { + let requiredBytes = proc_listpids(UInt32(PROC_ALL_PIDS), 0, nil, 0) + guard requiredBytes > 0 else { + return [] + } + + let capacity = max(1, Int(requiredBytes) / MemoryLayout.stride) + let pids = UnsafeMutablePointer.allocate(capacity: capacity) + defer { pids.deallocate() } + + let actualBytes = proc_listpids(UInt32(PROC_ALL_PIDS), 0, pids, requiredBytes) + guard actualBytes > 0 else { + return [] + } + + let count = Int(actualBytes) / MemoryLayout.stride + return (0.. 0 ? pid : nil + } + } + + private func process(pid: pid_t) -> DevProcess? { + guard let info = bsdInfo(pid: pid) else { + return nil + } + + let initialBirthToken = birthToken(from: info) + let executablePath = path(pid: pid) + let executable = executableName(from: info) ?? executablePath?.lastPathComponent ?? "pid-\(pid)" + let command = executablePath ?? executable + let argumentVector = argumentVectorProvider.argumentVector(for: Int32(pid)) + let resourceUsage = resourceUsage(for: pid, startTime: TimeInterval(info.pbi_start_tvsec)) + let bundleIdentifier = executablePath.flatMap { + ProcessScanner.bundleIdentifier( + forExecutableURL: URL(fileURLWithPath: $0), + processID: Int32(pid), + provider: bundleIdentityProvider + ) + } + let launchLabel: String? = nil + + // This must remain the last metadata read. A recycled PID must not inherit path, + // argv, bundle, or future trusted launch-label evidence gathered for its predecessor. + guard finalBirthTokenProvider.birthToken(for: Int32(pid)) == initialBirthToken else { + return nil + } + + return DevProcess( + pid: Int32(bitPattern: info.pbi_pid), + parentPID: Int32(bitPattern: info.pbi_ppid), + executable: executable, + command: command, + argumentVector: argumentVector, + currentDirectory: nil, + resourceUsage: resourceUsage, + birthToken: initialBirthToken, + bundleIdentifier: bundleIdentifier, + launchLabel: launchLabel + ) + } + + private func birthToken(from info: proc_bsdinfo) -> ProcessBirthToken { + ProcessBirthToken( + seconds: info.pbi_start_tvsec, + microseconds: info.pbi_start_tvusec + ) + } + + private func bsdInfo(pid: pid_t) -> proc_bsdinfo? { + var info = proc_bsdinfo() + let size = MemoryLayout.stride + let result = withUnsafeMutablePointer(to: &info) { pointer in + pointer.withMemoryRebound(to: UInt8.self, capacity: size) { buffer in + proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, buffer, Int32(size)) + } + } + + guard result == Int32(size), info.pbi_pid > 0 else { + return nil + } + + return info + } + + private func executableName(from info: proc_bsdinfo) -> String? { + withUnsafePointer(to: info.pbi_name) { pointer in + pointer.withMemoryRebound(to: CChar.self, capacity: MemoryLayout.size(ofValue: info.pbi_name)) { buffer in + let value = String(cString: buffer) + return value.isEmpty ? nil : value + } + } + } + + private func path(pid: pid_t) -> String? { + var buffer = [CChar](repeating: 0, count: pathBufferSize) + let result = proc_pidpath(pid, &buffer, UInt32(buffer.count)) + guard result > 0 else { + return nil + } + + let value = buffer.withUnsafeBufferPointer { pointer in + guard let baseAddress = pointer.baseAddress else { + return "" + } + return String(cString: baseAddress) + } + return value.isEmpty ? nil : value + } + + private func resourceUsage(for pid: pid_t, startTime: TimeInterval) -> DevProcessResourceUsage { + DevProcessResourceUsage( + cpuPercent: 0, + residentMemoryBytes: 0, + elapsedTime: elapsedTime(since: startTime) + ) + } + + private func elapsedTime(since startTime: TimeInterval) -> String { + let elapsed = max(0, Int(Date().timeIntervalSince1970 - startTime)) + let hours = elapsed / 3600 + let minutes = (elapsed % 3600) / 60 + let seconds = elapsed % 60 + + if hours >= 24 { + let days = hours / 24 + return String(format: "%d-%02d:%02d:%02d", days, hours % 24, minutes, seconds) + } + + if hours > 0 { + return String(format: "%02d:%02d:%02d", hours, minutes, seconds) + } + + return String(format: "%02d:%02d", minutes, seconds) + } +} + +private extension String { + var lastPathComponent: String { + NSString(string: self).lastPathComponent + } +} + +public enum ProcessScannerError: LocalizedError, Equatable { + case psFailed(status: Int32, output: String) + case nativeScannerEmpty + + public var errorDescription: String? { + switch self { + case let .psFailed(status, output): + "ps failed with status \(status): \(output)" + case .nativeScannerEmpty: + "Native process scanning returned no visible processes" + } + } +} diff --git a/Sources/DevScopeCore/ProcessScopePresentation.swift b/Sources/DevScopeCore/ProcessScopePresentation.swift new file mode 100644 index 0000000..73dfc02 --- /dev/null +++ b/Sources/DevScopeCore/ProcessScopePresentation.swift @@ -0,0 +1,456 @@ +import Foundation + +public enum ProcessActivityScope: String, CaseIterable, Identifiable, Sendable { + case applications + case processes + case hierarchy + case workflows + + public var id: String { rawValue } + + public var title: String { + switch self { + case .applications: + "Applications" + case .processes: + "Processes" + case .hierarchy: + "Hierarchy" + case .workflows: + "Workflows" + } + } + + public var symbolName: String { + switch self { + case .applications: + "macwindow.on.rectangle" + case .processes: + "list.bullet.rectangle" + case .hierarchy: + "point.3.connected.trianglepath.dotted" + case .workflows: + "square.stack.3d.up" + } + } +} +public enum ApplicationProcessRole: String, Equatable, Hashable, Sendable { + case application + case helper + case worker + + public var title: String { + switch self { + case .application: + "App" + case .helper: + "Helper" + case .worker: + "Worker" + } + } + + public var symbolName: String { + switch self { + case .application: + "macwindow" + case .helper: + "gearshape.2" + case .worker: + "hammer" + } + } +} + +public struct ApplicationProcessMember: Identifiable, Equatable, Sendable { + public let item: ClassifiedDevProcess + public let role: ApplicationProcessRole + public let depth: Int + + public var id: Int32 { item.process.pid } + + public init(item: ClassifiedDevProcess, role: ApplicationProcessRole, depth: Int) { + self.item = item + self.role = role + self.depth = depth + } +} + +public struct ProcessApplicationFamily: Identifiable, Equatable, Sendable { + public let bundlePath: String + public let title: String + public let members: [ApplicationProcessMember] + public let applicationCount: Int + public let helperCount: Int + public let workerCount: Int + public let totalCPU: Double + public let totalMemoryBytes: Int64 + + public var id: String { bundlePath } + public var processIDs: [Int32] { members.map(\.id).sorted() } + + public init( + bundlePath: String, + title: String, + members: [ApplicationProcessMember], + applicationCount: Int, + helperCount: Int, + workerCount: Int, + totalCPU: Double, + totalMemoryBytes: Int64 + ) { + self.bundlePath = bundlePath + self.title = title + self.members = members + self.applicationCount = applicationCount + self.helperCount = helperCount + self.workerCount = workerCount + self.totalCPU = totalCPU + self.totalMemoryBytes = totalMemoryBytes + } +} + +public struct ProcessHierarchyNode: Identifiable, Equatable, Sendable { + public let item: ClassifiedDevProcess + public let children: [ProcessHierarchyNode] + + public var id: Int32 { item.process.pid } + public var outlineChildren: [ProcessHierarchyNode]? { + children.isEmpty ? nil : children + } + + public init(item: ClassifiedDevProcess, children: [ProcessHierarchyNode]) { + self.item = item + self.children = children + } +} + +public enum ProcessScopePresentation { + public static func applicationFamilies( + for items: S, + searchText: String = "" + ) -> [ProcessApplicationFamily] where S.Element == ClassifiedDevProcess { + let items = canonicalItems(Array(items)) + let itemsByPID = Dictionary(uniqueKeysWithValues: items.map { ($0.process.pid, $0) }) + let directBundleByPID = Dictionary( + uniqueKeysWithValues: items.compactMap { item in + outerApplicationBundlePath(for: item.process).map { (item.process.pid, $0) } + } + ) + + var groupedItems: [String: [ClassifiedDevProcess]] = [:] + for item in items { + guard + let bundlePath = resolvedApplicationBundlePath( + for: item.process.pid, + itemsByPID: itemsByPID, + directBundleByPID: directBundleByPID + ) + else { + continue + } + groupedItems[bundlePath, default: []].append(item) + } + + let terms = searchTerms(searchText) + return groupedItems.map { bundlePath, familyItems in + makeFamily( + bundlePath: bundlePath, + items: familyItems, + directBundleByPID: directBundleByPID + ) + } + .filter { family in + terms.isEmpty || terms.allSatisfy { familySearchText(family).contains($0) } + } + .sorted { lhs, rhs in + let titleComparison = lhs.title.localizedStandardCompare(rhs.title) + if titleComparison != .orderedSame { + return titleComparison == .orderedAscending + } + return lhs.bundlePath < rhs.bundlePath + } + } + + public static func hierarchy( + for items: S, + searchText: String = "" + ) -> [ProcessHierarchyNode] where S.Element == ClassifiedDevProcess { + let items = canonicalItems(Array(items)) + let itemsByPID = Dictionary(uniqueKeysWithValues: items.map { ($0.process.pid, $0) }) + let processIDs = Set(itemsByPID.keys) + let childrenByParent = Dictionary(grouping: items, by: \.process.parentPID) + .mapValues { $0.map(\.process.pid).sorted() } + let rootIDs = items + .filter { + $0.process.parentPID == $0.process.pid + || !processIDs.contains($0.process.parentPID) + } + .map(\.process.pid) + .sorted() + var visited = Set() + + func makeNode(_ processID: Int32) -> ProcessHierarchyNode? { + guard visited.insert(processID).inserted, let item = itemsByPID[processID] else { + return nil + } + let children = childrenByParent[processID, default: []].compactMap(makeNode) + return ProcessHierarchyNode(item: item, children: children) + } + + var nodes = rootIDs.compactMap(makeNode) + for processID in processIDs.sorted() where !visited.contains(processID) { + if let node = makeNode(processID) { + nodes.append(node) + } + } + + let terms = searchTerms(searchText) + guard !terms.isEmpty else { + return nodes + } + return nodes.compactMap { filteredHierarchyNode($0, terms: terms) } + } + + public static func flattened( + _ nodes: [ProcessHierarchyNode] + ) -> [ProcessHierarchyNode] { + nodes.flatMap { node in + [node] + flattened(node.children) + } + } + + private static func makeFamily( + bundlePath: String, + items: [ClassifiedDevProcess], + directBundleByPID: [Int32: String] + ) -> ProcessApplicationFamily { + let itemsByPID = Dictionary(uniqueKeysWithValues: items.map { ($0.process.pid, $0) }) + let members = orderedMembers( + items: items, + roleForPID: { processID in + guard let item = itemsByPID[processID] else { + return .worker + } + guard directBundleByPID[processID] == bundlePath else { + return .worker + } + return isApplicationInstance(item.process, bundlePath: bundlePath) + ? .application : .helper + } + ) + let roleCounts = Dictionary(grouping: members, by: \.role).mapValues(\.count) + + return ProcessApplicationFamily( + bundlePath: bundlePath, + title: familyTitle(bundlePath: bundlePath, items: items), + members: members, + applicationCount: roleCounts[.application, default: 0], + helperCount: roleCounts[.helper, default: 0], + workerCount: roleCounts[.worker, default: 0], + totalCPU: items.reduce(0) { $0 + ($1.process.resourceUsage?.cpuPercent ?? 0) }, + totalMemoryBytes: items.reduce(0) { + $0 + ($1.process.resourceUsage?.residentMemoryBytes ?? 0) + } + ) + } + + private static func orderedMembers( + items: [ClassifiedDevProcess], + roleForPID: (Int32) -> ApplicationProcessRole + ) -> [ApplicationProcessMember] { + let itemsByPID = Dictionary(uniqueKeysWithValues: items.map { ($0.process.pid, $0) }) + let processIDs = Set(itemsByPID.keys) + let childrenByParent = Dictionary(grouping: items, by: \.process.parentPID) + .mapValues { $0.map(\.process.pid).sorted() } + let roots = items + .filter { !processIDs.contains($0.process.parentPID) } + .map(\.process.pid) + .sorted() + var visited = Set() + var result: [ApplicationProcessMember] = [] + + func visit(_ processID: Int32, depth: Int) { + guard visited.insert(processID).inserted, let item = itemsByPID[processID] else { + return + } + result.append( + ApplicationProcessMember( + item: item, + role: roleForPID(processID), + depth: depth + ) + ) + for childID in childrenByParent[processID, default: []] { + visit(childID, depth: depth + 1) + } + } + + for rootID in roots { + visit(rootID, depth: 0) + } + for processID in processIDs.sorted() where !visited.contains(processID) { + visit(processID, depth: 0) + } + return result + } + + private static func resolvedApplicationBundlePath( + for processID: Int32, + itemsByPID: [Int32: ClassifiedDevProcess], + directBundleByPID: [Int32: String] + ) -> String? { + var currentProcessID = processID + var visited = Set() + + while visited.insert(currentProcessID).inserted { + if let bundlePath = directBundleByPID[currentProcessID] { + return bundlePath + } + guard let item = itemsByPID[currentProcessID] else { + return nil + } + currentProcessID = item.process.parentPID + } + return nil + } + + private static func outerApplicationBundlePath(for process: DevProcess) -> String? { + outerApplicationBundlePath(in: process.executable) + ?? outerApplicationBundlePath(in: process.command) + } + + private static func outerApplicationBundlePath(in rawValue: String) -> String? { + let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "\"'")) + let roots = ["/Applications/", "/System/", "/Users/", "/private/", "/Volumes/"] + let start = roots.compactMap { value.range(of: $0)?.lowerBound }.min() + ?? (value.hasPrefix("/") ? value.startIndex : nil) + guard let start else { + return nil + } + + var searchStart = start + while searchStart < value.endIndex, + let appRange = value.range( + of: ".app", + options: [.caseInsensitive], + range: searchStart.. Bool { + guard process.parentPID == 1 else { + return false + } + let mainExecutablePrefix = bundlePath + "/Contents/MacOS/" + return process.executable.hasPrefix(mainExecutablePrefix) + || process.command.hasPrefix(mainExecutablePrefix) + } + + private static func familyTitle( + bundlePath: String, + items: [ClassifiedDevProcess] + ) -> String { + let baseTitle = URL(fileURLWithPath: bundlePath) + .deletingPathExtension() + .lastPathComponent + if baseTitle.caseInsensitiveCompare("ChatGPT") == .orderedSame, + items.contains(where: { + $0.process.executable.localizedCaseInsensitiveContains("codex") + || $0.process.command.localizedCaseInsensitiveContains("codex") + }) + { + return "ChatGPT / Codex" + } + return baseTitle + } + + private static func canonicalItems( + _ items: [ClassifiedDevProcess] + ) -> [ClassifiedDevProcess] { + Dictionary(grouping: items, by: \.process.pid) + .compactMap { _, candidates in + candidates.sorted(by: stableItemOrder).first + } + .sorted(by: stableItemOrder) + } + + private static func stableItemOrder( + _ lhs: ClassifiedDevProcess, + _ rhs: ClassifiedDevProcess + ) -> Bool { + let left = lhs.process + let right = rhs.process + if left.pid != right.pid { return left.pid < right.pid } + if left.parentPID != right.parentPID { return left.parentPID < right.parentPID } + if left.executable != right.executable { return left.executable < right.executable } + if left.command != right.command { return left.command < right.command } + return (left.currentDirectory ?? "") < (right.currentDirectory ?? "") + } + + private static func searchTerms(_ searchText: String) -> [String] { + searchText + .lowercased() + .split(whereSeparator: \.isWhitespace) + .map(String.init) + } + + private static func familySearchText(_ family: ProcessApplicationFamily) -> String { + ([family.title, family.bundlePath] + + family.members.flatMap { member in + let item = member.item + return [ + String(item.process.pid), + item.classification.displayName, + item.classification.projectHint ?? "", + item.process.executable, + item.process.command, + item.process.currentDirectory ?? "", + ] + }) + .joined(separator: " ") + .lowercased() + } + + private static func filteredHierarchyNode( + _ node: ProcessHierarchyNode, + terms: [String] + ) -> ProcessHierarchyNode? { + let children = node.children.compactMap { filteredHierarchyNode($0, terms: terms) } + guard terms.allSatisfy({ itemSearchText(node.item).contains($0) }) || !children.isEmpty else { + return nil + } + return ProcessHierarchyNode(item: node.item, children: children) + } + + private static func itemSearchText(_ item: ClassifiedDevProcess) -> String { + [ + String(item.process.pid), + String(item.process.parentPID), + item.classification.displayName, + item.classification.projectHint ?? "", + item.process.executable, + item.process.command, + item.process.currentDirectory ?? "", + ] + .joined(separator: " ") + .lowercased() + } +} diff --git a/Sources/DevScopeCore/ProcessSignalObservation.swift b/Sources/DevScopeCore/ProcessSignalObservation.swift new file mode 100644 index 0000000..2ea9670 --- /dev/null +++ b/Sources/DevScopeCore/ProcessSignalObservation.swift @@ -0,0 +1,161 @@ +public struct ProcessIdentity: Equatable, Hashable, Sendable { + public let pid: Int32 + public let executable: String + public let command: String + public let birthToken: ProcessBirthToken? + + public init(process: DevProcess) { + pid = process.pid + executable = process.executable + command = process.command + birthToken = process.birthToken + } + + public init( + pid: Int32, + executable: String = "", + command: String = "", + birthToken: ProcessBirthToken? + ) { + self.pid = pid + self.executable = executable + self.command = command + self.birthToken = birthToken + } + + public func hasSameBirthIdentity(as other: ProcessIdentity) -> Bool { + guard let birthToken, let otherBirthToken = other.birthToken else { + return false + } + return pid == other.pid && birthToken == otherBirthToken + } +} + +public enum ProcessSignalObservation { + public static func observe( + targets: [ProcessIdentity], + in currentProcesses: [DevProcess] + ) -> ProcessSignalObservationSummary { + let currentByPID = Dictionary( + currentProcesses.map { ($0.pid, $0) }, + uniquingKeysWith: { _, latest in latest } + ) + let observations = targets.map { target in + let state: ProcessSignalObservationState + guard let expectedBirthToken = target.birthToken else { + state = .unverifiable + return ProcessSignalTargetObservation(target: target, state: state) + } + guard let current = currentByPID[target.pid] else { + state = .exitedOrReplaced + return ProcessSignalTargetObservation(target: target, state: state) + } + guard let currentBirthToken = current.birthToken else { + state = .unverifiable + return ProcessSignalTargetObservation(target: target, state: state) + } + state = currentBirthToken == expectedBirthToken ? .stillRunning : .exitedOrReplaced + return ProcessSignalTargetObservation(target: target, state: state) + } + return ProcessSignalObservationSummary(observations: observations) + } +} + +public enum ProcessSignalObservationState: Equatable, Sendable { + case stillRunning + case exitedOrReplaced + case unverifiable +} + +public struct ProcessSignalTargetObservation: Equatable, Sendable { + public let target: ProcessIdentity + public let state: ProcessSignalObservationState + + public init(target: ProcessIdentity, state: ProcessSignalObservationState) { + self.target = target + self.state = state + } +} + +public struct ProcessSignalObservationSummary: Equatable, Sendable { + public let observations: [ProcessSignalTargetObservation] + + public init(observations: [ProcessSignalTargetObservation]) { + self.observations = observations + } + + public var verifiesAllTargetsStopped: Bool { + !observations.isEmpty && observations.allSatisfy { $0.state == .exitedOrReplaced } + } + + public var stillRunning: [ProcessIdentity] { + targets(with: .stillRunning) + } + + public var exitedOrReplaced: [ProcessIdentity] { + targets(with: .exitedOrReplaced) + } + + public var unverifiable: [ProcessIdentity] { + targets(with: .unverifiable) + } + + private func targets(with state: ProcessSignalObservationState) -> [ProcessIdentity] { + observations.compactMap { observation in + observation.state == state ? observation.target : nil + } + } +} + +public struct ProcessPartialSignalVerificationPresentation: Equatable, Sendable { + public let title: String + public let statusMessage: String + public let detail: String + public let symbolName: String + + public static func make( + signalName: String, + observation: ProcessSignalObservationSummary, + failedTarget: ProcessIdentity, + reason: String + ) -> ProcessPartialSignalVerificationPresentation { + let stopped = observation.exitedOrReplaced + let stillRunning = observation.stillRunning + let unverifiable = observation.unverifiable + let stoppedText = "\(stopped.count) signaled process\(stopped.count == 1 ? "" : "es") stopped" + let failedText = "PID \(failedTarget.pid) failed: \(reason)" + let unsignaledText = "The failed target and all remaining tree targets were not signaled" + + if stillRunning.isEmpty, unverifiable.isEmpty { + return ProcessPartialSignalVerificationPresentation( + title: "Partial result verified", + statusMessage: "\(signalName) partial result verified", + detail: "\(stoppedText). \(failedText). \(unsignaledText).", + symbolName: "exclamationmark.triangle.fill" + ) + } + + var details: [String] = [] + if !stopped.isEmpty { + details.append(stoppedText) + } + if !stillRunning.isEmpty { + details.append("Still running: \(pidList(stillRunning))") + } + if !unverifiable.isEmpty { + details.append("Could not verify: \(pidList(unverifiable))") + } + details.append(failedText) + details.append(unsignaledText) + return ProcessPartialSignalVerificationPresentation( + title: "Partial result not fully verified", + statusMessage: "\(signalName) partial result remains unresolved", + detail: details.joined(separator: ". ") + ".", + symbolName: "exclamationmark.triangle.fill" + ) + } + + private static func pidList(_ identities: [ProcessIdentity]) -> String { + identities.map(\.pid).sorted().map(String.init).joined(separator: ", ") + } +} diff --git a/Sources/DevScopeCore/ProcessSnapshotBuilder.swift b/Sources/DevScopeCore/ProcessSnapshotBuilder.swift new file mode 100644 index 0000000..123a7d8 --- /dev/null +++ b/Sources/DevScopeCore/ProcessSnapshotBuilder.swift @@ -0,0 +1,67 @@ +import Foundation + +public struct DevProcessSnapshot: Equatable, Sendable { + public let processes: [DevProcess] + public let classified: [ClassifiedDevProcess] + public let workflows: [DevWorkflow] + public let liveProcessIDs: Set + + public init( + processes: [DevProcess], + classified: [ClassifiedDevProcess], + workflows: [DevWorkflow], + liveProcessIDs: Set + ) { + self.processes = processes + self.classified = classified + self.workflows = workflows + self.liveProcessIDs = liveProcessIDs + } +} + +public struct ProcessSnapshotBuilder: Sendable { + private var classificationCache = ProcessClassificationCache() + private var stabilizer = ClassifiedProcessSnapshotStabilizer() + private var workflowCache = ProcessWorkflowCache() + private let workspaceFactsCache: WorkspaceFactsCache + + public init(workspaceFactsCache: WorkspaceFactsCache = WorkspaceFactsCache()) { + self.workspaceFactsCache = workspaceFactsCache + } + + public mutating func invalidateWorkspaceFacts() { + workspaceFactsCache.invalidateAll() + workflowCache.invalidate() + } + + public mutating func build( + processes: [DevProcess], + now: Date, + graceInterval: TimeInterval + ) -> DevProcessSnapshot { + let classified = classificationCache.classified( + processes, + workspaceFactsCache: workspaceFactsCache + ).sorted { lhs, rhs in + let leftProject = lhs.classification.projectHint ?? "" + let rightProject = rhs.classification.projectHint ?? "" + if leftProject != rightProject { + return leftProject < rightProject + } + return lhs.classification.displayName < rhs.classification.displayName + } + let stabilized = stabilizer.merge( + liveItems: classified, + now: now, + graceInterval: graceInterval + ) + let workflows = workflowCache.workflows(for: stabilized, now: now) + + return DevProcessSnapshot( + processes: stabilized.map(\.process), + classified: stabilized, + workflows: workflows, + liveProcessIDs: stabilizer.liveProcessIDs + ) + } +} diff --git a/Sources/DevScopeCore/ProcessSnapshotStabilizer.swift b/Sources/DevScopeCore/ProcessSnapshotStabilizer.swift new file mode 100644 index 0000000..f2ca6dd --- /dev/null +++ b/Sources/DevScopeCore/ProcessSnapshotStabilizer.swift @@ -0,0 +1,44 @@ +import Foundation + +public struct ClassifiedProcessSnapshotStabilizer: Sendable { + private struct Entry: Sendable { + var item: ClassifiedDevProcess + let firstSeen: Date + var lastSeen: Date + } + + private var entriesByPID: [Int32: Entry] = [:] + public private(set) var liveProcessIDs: Set = [] + + public init() {} + + public mutating func merge( + liveItems: [ClassifiedDevProcess], + now: Date, + graceInterval: TimeInterval + ) -> [ClassifiedDevProcess] { + liveProcessIDs = Set(liveItems.map(\.process.pid)) + + for item in liveItems { + let previous = entriesByPID[item.process.pid] + entriesByPID[item.process.pid] = Entry( + item: item, + firstSeen: previous?.firstSeen ?? now, + lastSeen: now + ) + } + + entriesByPID = entriesByPID.filter { pid, entry in + liveProcessIDs.contains(pid) || now.timeIntervalSince(entry.lastSeen) <= graceInterval + } + + return entriesByPID.values + .sorted { lhs, rhs in + if lhs.firstSeen != rhs.firstSeen { + return lhs.firstSeen < rhs.firstSeen + } + return lhs.item.process.pid < rhs.item.process.pid + } + .map(\.item) + } +} diff --git a/Sources/DevScopeCore/ProcessWorkflowCache.swift b/Sources/DevScopeCore/ProcessWorkflowCache.swift new file mode 100644 index 0000000..672f180 --- /dev/null +++ b/Sources/DevScopeCore/ProcessWorkflowCache.swift @@ -0,0 +1,85 @@ +import Foundation + +struct ProcessWorkflowCache: Sendable { + typealias Build = @Sendable ([ClassifiedDevProcess]) -> [DevWorkflow] + + private struct Fingerprint: Equatable, Sendable { + let processes: [Process] + + init(_ items: [ClassifiedDevProcess]) { + processes = items.map(Process.init) + } + } + + private struct Process: Equatable, Sendable { + let pid: Int32 + let birthToken: ProcessBirthToken? + let parentPID: Int32 + let executable: String + let command: String + let currentDirectory: String? + let kind: DevRuntimeKind + let displayName: String + let projectHint: String? + let tags: [DevProcessTag] + + init(_ item: ClassifiedDevProcess) { + pid = item.process.pid + birthToken = item.process.birthToken + parentPID = item.process.parentPID + executable = item.process.executable + command = item.process.command + currentDirectory = item.process.currentDirectory + kind = item.classification.kind + displayName = item.classification.displayName + projectHint = item.classification.projectHint + tags = item.classification.tags + } + } + + private let build: Build + private var cachedFingerprint: Fingerprint? + private var cachedWorkflows: [DevWorkflow] = [] + + init( + build: @escaping Build = { ProcessIntelligence.uncappedWorkflows(for: $0) } + ) { + self.build = build + } + + mutating func workflows( + for items: [ClassifiedDevProcess], + now _: Date + ) -> [DevWorkflow] { + let workflowItems = items.filter(Self.isWorkflowRelevant) + let fingerprint = Fingerprint(workflowItems) + if fingerprint == cachedFingerprint { + let rehydrated = ProcessIntelligence.rehydratedWorkflows(cachedWorkflows, for: items) + return ProcessIntelligence.presentedWorkflows(rehydrated) + } + + let workflows = build(workflowItems) + cachedFingerprint = fingerprint + cachedWorkflows = workflows + return ProcessIntelligence.presentedWorkflows(workflows) + } + + mutating func invalidate() { + cachedFingerprint = nil + cachedWorkflows = [] + } + + private static func isWorkflowRelevant(_ item: ClassifiedDevProcess) -> Bool { + if item.classification.projectHint != nil || !item.classification.tags.isEmpty { + return true + } + + switch item.classification.kind { + case .javascript, .python, .swift, .rust, .go, .flutter, .java, + .database, .container, .webServer, .ai, .mcp: + return true + case .browser, .macApp, .backgroundAgent, .systemService, .shell, .other: + return false + } + } +} diff --git a/Sources/DevScopeCore/WorkspaceFactsCache.swift b/Sources/DevScopeCore/WorkspaceFactsCache.swift new file mode 100644 index 0000000..5f69ea3 --- /dev/null +++ b/Sources/DevScopeCore/WorkspaceFactsCache.swift @@ -0,0 +1,144 @@ +import Foundation + +public struct WorkspaceFacts: Hashable, Sendable { + public let isFlutterWorkspace: Bool + + public init(isFlutterWorkspace: Bool) { + self.isFlutterWorkspace = isFlutterWorkspace + } +} + +public final class WorkspaceFactsCache: @unchecked Sendable { + public typealias FileExists = (String) -> Bool + + private struct Entry { + var facts: WorkspaceFacts + var validatedGeneration: UInt64 + var lastAccessGeneration: UInt64 + } + + private struct RevalidationCycle { + let identifier: UInt64 + let generation: UInt64 + var remainingStaleEntries: Int + var claimedDirectories: Set = [] + } + + private enum Lookup { + case cached(WorkspaceFacts) + case probe(expectedGeneration: UInt64) + } + + private let fileExists: FileExists + private let lock = NSLock() + private var entries: [String: Entry] = [:] + private var generation: UInt64 = 0 + private var nextCycleIdentifier: UInt64 = 0 + private var revalidationCycle: RevalidationCycle? + + public init(fileExists: @escaping FileExists = { FileManager.default.fileExists(atPath: $0) }) { + self.fileExists = fileExists + } + + public func facts(for directory: String?) -> WorkspaceFacts { + guard let directory else { + return WorkspaceFacts(isFlutterWorkspace: false) + } + + while true { + let lookup = lock.withLock { () -> Lookup in + guard var entry = entries[directory] else { + return .probe(expectedGeneration: generation) + } + + entry.lastAccessGeneration = generation + entries[directory] = entry + + guard entry.validatedGeneration != generation else { + return .cached(entry.facts) + } + + guard var cycle = revalidationCycle, cycle.generation == generation else { + return .probe(expectedGeneration: generation) + } + guard !cycle.claimedDirectories.contains(directory) else { + return .cached(entry.facts) + } + guard cycle.remainingStaleEntries > 0 else { + return .cached(entry.facts) + } + + cycle.claimedDirectories.insert(directory) + cycle.remainingStaleEntries -= 1 + revalidationCycle = cycle + return .probe(expectedGeneration: generation) + } + + switch lookup { + case .cached(let facts): + return facts + case .probe(let expectedGeneration): + let components = URL(fileURLWithPath: directory).pathComponents.map { $0.lowercased() } + let pathIdentifiesFlutter = components.contains("flutter") || components.contains(".dart_tool") + let manifestIdentifiesFlutter = fileExists(directory + "/pubspec.yaml") || + fileExists(directory + "/.dart_tool/package_config.json") + let facts = WorkspaceFacts(isFlutterWorkspace: pathIdentifiesFlutter || manifestIdentifiesFlutter) + + let storedFacts = lock.withLock { () -> WorkspaceFacts? in + guard generation == expectedGeneration else { + return nil + } + if let cached = entries[directory], cached.validatedGeneration == generation { + return cached.facts + } + entries[directory] = Entry( + facts: facts, + validatedGeneration: generation, + lastAccessGeneration: generation + ) + return facts + } + + if let storedFacts { + return storedFacts + } + } + } + } + + public func withRevalidationCycle( + maximumStaleEntries: Int, + _ operation: () throws -> Result + ) rethrows -> Result { + let cycleIdentifier = lock.withLock { () -> UInt64 in + nextCycleIdentifier &+= 1 + let identifier = nextCycleIdentifier + revalidationCycle = RevalidationCycle( + identifier: identifier, + generation: generation, + remainingStaleEntries: max(0, maximumStaleEntries) + ) + return identifier + } + defer { + lock.withLock { + if revalidationCycle?.identifier == cycleIdentifier { + revalidationCycle = nil + } + } + } + return try operation() + } + + public func isFlutterWorkspace(_ directory: String?) -> Bool { + facts(for: directory).isFlutterWorkspace + } + + public func invalidateAll() { + lock.withLock { + entries = entries.filter { $0.value.lastAccessGeneration == generation } + generation &+= 1 + revalidationCycle = nil + } + } +} diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..1168e76 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,13 @@ +# Third-Party Notices + +The Swift package currently declares no external package dependencies. + +DevScope uses Apple platform frameworks supplied by macOS and the Xcode SDK. +Those frameworks are not redistributed by this repository and remain subject to +Apple's applicable terms. The release process must regenerate this dependency +statement if an external dependency or redistributed third-party asset is added. + +Repository automation uses GitHub's `actions/checkout` at a pinned revision and +downloads the Gitleaks CLI at an explicit version with its release SHA-256 +checksum verified before execution. They are CI tooling and are not linked into +or redistributed with the DevScope application. diff --git a/TRADEMARKS.md b/TRADEMARKS.md new file mode 100644 index 0000000..1f379ad --- /dev/null +++ b/TRADEMARKS.md @@ -0,0 +1,17 @@ +# Trademarks + +The Apache License 2.0 applies to the source code and documentation, but it does +not grant permission to use project names, logos, or other brand identifiers +except for reasonable and customary use describing the origin of the work. + +You may accurately state that your work is based on DevScope. Do not imply that +an altered build, fork, service, or distribution is official, endorsed, or +maintained by the DevScope project. Forks that could cause confusion should use +a distinct product name and visual identity. + +This policy does not claim registration of any mark and does not limit uses +permitted by applicable law. + +DevScope is maintained publicly by RSI Tech. This statement identifies the +project source and does not claim trademark registration. Contact +`info@rsitech.ai` for brand-use questions. diff --git a/Tests/DevScopeCoreTests/AutomationCapabilityPolicyTests.swift b/Tests/DevScopeCoreTests/AutomationCapabilityPolicyTests.swift new file mode 100644 index 0000000..ebc3ad1 --- /dev/null +++ b/Tests/DevScopeCoreTests/AutomationCapabilityPolicyTests.swift @@ -0,0 +1,237 @@ +import XCTest +@testable import DevScopeCore + +final class AutomationCapabilityPolicyTests: XCTestCase { + func testApprovedPathAllowsVerifiedMissingChildButRejectsPrefixEscapeAndSymlink() { + let root = URL(fileURLWithPath: "/Users/test/Library/LaunchAgents") + let destination = root.appending(path: "new.plist") + + XCTAssertTrue(AutomationPathAuthorization.isApprovedDestination( + destination, + approvedRoot: root, + destinationExists: false, + verifiedMetadataURL: root, + metadataIsSymbolicLink: false + )) + XCTAssertFalse(AutomationPathAuthorization.isApprovedDestination( + URL(fileURLWithPath: "/Users/test/Library/LaunchAgents-escape/new.plist"), + approvedRoot: root, + destinationExists: false, + verifiedMetadataURL: URL(fileURLWithPath: "/Users/test/Library/LaunchAgents-escape"), + metadataIsSymbolicLink: false + )) + XCTAssertFalse(AutomationPathAuthorization.isApprovedDestination( + destination, + approvedRoot: root, + destinationExists: true, + verifiedMetadataURL: destination, + metadataIsSymbolicLink: true + )) + } + + func testCurrentUserLaunchAgentReceivesFullCapabilities() { + let decision = AutomationCapabilityPolicy.decision( + for: Fixtures.userAgent, + context: .fixture(currentUID: 501, canonicalPathIsApproved: true, ownerUID: 501) + ) + + XCTAssertEqual(decision.capabilities, Set(AutomationCapability.allCases)) + XCTAssertNil(decision.reason) + } + + func testLaunchAgentWithUnresolvedRuntimeStateIsInspectionOnly() { + let unresolved = copyRecord( + Fixtures.userAgent, + ownership: .user, + enabledState: .unknown, + loadState: .unknown + ) + let decision = AutomationCapabilityPolicy.decision( + for: unresolved, + context: .fixture(currentUID: 501, canonicalPathIsApproved: true, ownerUID: 501) + ) + + XCTAssertEqual(decision.capabilities, [.exportRecord]) + XCTAssertEqual( + decision.reason, + "DevScope could not verify this LaunchAgent's enabled and loaded state, so management is unavailable." + ) + } + + func testSymlinkEscapeIsReadOnly() { + let decision = AutomationCapabilityPolicy.decision( + for: Fixtures.userAgent, + context: .fixture( + currentUID: 501, + canonicalPathIsApproved: false, + ownerUID: 501, + isSymlink: true + ) + ) + + XCTAssertEqual(decision.capabilities, [.exportRecord]) + XCTAssertEqual(decision.reason, "The source resolves outside your LaunchAgents folder.") + } + + func testManagedAutomationHasExactInspectionOnlyReason() { + let decision = AutomationCapabilityPolicy.decision( + for: copyRecord(Fixtures.userAgent, ownership: .managed), + context: .fixture( + currentUID: 501, + canonicalPathIsApproved: true, + ownerUID: 501, + isManaged: true + ) + ) + + XCTAssertEqual(decision.capabilities, [.exportRecord]) + XCTAssertEqual(decision.reason, "This automation is managed by your organization.") + } + + func testModernCrossAppBackgroundItemIsInspectionOnly() { + let decision = AutomationCapabilityPolicy.decision( + for: Fixtures.backgroundCopyOfUserAgent, + context: .fixture(currentUID: 501, canonicalPathIsApproved: true, ownerUID: 501) + ) + + XCTAssertEqual(decision.capabilities, [.exportRecord]) + XCTAssertEqual( + decision.reason, + "Modern background items must be managed by their owning app or System Settings." + ) + } + + func testWrongOwnerAndSymbolicLinkHaveDistinctReasons() { + let wrongOwner = AutomationCapabilityPolicy.decision( + for: Fixtures.userAgent, + context: .fixture(currentUID: 501, canonicalPathIsApproved: true, ownerUID: 502) + ) + let symbolicLink = AutomationCapabilityPolicy.decision( + for: Fixtures.userAgent, + context: .fixture( + currentUID: 501, + canonicalPathIsApproved: true, + ownerUID: 501, + isSymlink: true + ) + ) + + XCTAssertEqual(wrongOwner.reason, "The automation source is not owned by the current user.") + XCTAssertEqual(symbolicLink.reason, "Symbolic-link automation sources are inspection only.") + } + + func testCronExposesOnlyInstalledCapabilitiesWithSourceSpecificReason() { + let cronRecord = automationRecord( + copying: Fixtures.userAgent, + kind: .cron, + sourceKind: .crontab, + sourceURL: nil + ) + let decision = AutomationCapabilityPolicy.decision( + for: cronRecord, + context: .fixture( + currentUID: 501, + canonicalPathIsApproved: true, + ownerUID: 501, + implementedCapabilities: [.exportRecord] + ) + ) + + XCTAssertEqual(decision.capabilities, [.exportRecord]) + XCTAssertEqual( + decision.reason, + "Current-user crontab management is unavailable until recoverable source access is installed." + ) + } + + func testExportAvailabilityIsGatedConsistentlyAndSurvivesPartialMutationAdapters() { + let protected = AutomationCapabilityPolicy.decision( + for: copyRecord(Fixtures.userAgent, ownership: .managed), + context: .fixture( + currentUID: 501, + canonicalPathIsApproved: true, + ownerUID: 501, + isManaged: true, + implementedCapabilities: [] + ) + ) + XCTAssertFalse(protected.capabilities.contains(.exportRecord)) + + let partialUser = AutomationCapabilityPolicy.decision( + for: Fixtures.userAgent, + context: .fixture( + currentUID: 501, + canonicalPathIsApproved: true, + ownerUID: 501, + implementedCapabilities: [.disable, .exportRecord] + ) + ) + XCTAssertEqual(partialUser.capabilities, [.disable, .exportRecord]) + } +} + +private func copyRecord( + _ record: AutomationRecord, + ownership: AutomationOwnership, + enabledState: AutomationEnabledState? = nil, + loadState: AutomationLoadState? = nil +) -> AutomationRecord { + AutomationRecord( + id: record.id, + kind: record.kind, + sourceKind: record.sourceKind, + label: record.label, + displayName: record.displayName, + providerBundleIdentifier: record.providerBundleIdentifier, + ownerUID: record.ownerUID, + ownership: ownership, + executable: record.executable, + arguments: record.arguments, + commandSignature: record.commandSignature, + environment: record.environment, + workingDirectory: record.workingDirectory, + schedule: record.schedule, + sourceURL: record.sourceURL, + sourceChecksum: record.sourceChecksum, + enabledState: enabledState ?? record.enabledState, + loadState: loadState ?? record.loadState, + approvalState: record.approvalState, + state: record.state, + evidence: record.evidence, + capabilities: record.capabilities, + validationFindings: record.validationFindings + ) +} + +private func automationRecord( + copying record: AutomationRecord, + kind: AutomationKind, + sourceKind: AutomationSourceKind, + sourceURL: URL? +) -> AutomationRecord { + AutomationRecord( + id: record.id, + kind: kind, + sourceKind: sourceKind, + label: record.label, + displayName: record.displayName, + providerBundleIdentifier: record.providerBundleIdentifier, + ownerUID: record.ownerUID, + ownership: record.ownership, + executable: record.executable, + arguments: record.arguments, + commandSignature: record.commandSignature, + environment: record.environment, + workingDirectory: record.workingDirectory, + schedule: record.schedule, + sourceURL: sourceURL, + sourceChecksum: record.sourceChecksum, + enabledState: record.enabledState, + loadState: record.loadState, + approvalState: record.approvalState, + state: record.state, + evidence: record.evidence, + capabilities: record.capabilities, + validationFindings: record.validationFindings + ) +} diff --git a/Tests/DevScopeCoreTests/AutomationEventDetectorTests.swift b/Tests/DevScopeCoreTests/AutomationEventDetectorTests.swift new file mode 100644 index 0000000..c04f59f --- /dev/null +++ b/Tests/DevScopeCoreTests/AutomationEventDetectorTests.swift @@ -0,0 +1,304 @@ +import XCTest +@testable import DevScopeCore + +final class AutomationEventDetectorTests: XCTestCase { + func testLongRunningTransitionEmitsOnceAndNilBirthNeverNotifies() { + var detector = AutomationEventDetector() + let before = presentation(elapsed: "03:59:59") + let boundary = presentation(elapsed: "04:00:00") + let after = presentation(elapsed: "04:00:01") + + XCTAssertEqual(detector.events( + previous: before, current: boundary, now: Date(timeIntervalSince1970: 14_400) + ), [.crossedLongRunningThreshold( + process: eventIdentity(process(elapsed: "04:00:00")), + recordID: expectedRecord.id + )]) + XCTAssertTrue(detector.events( + previous: boundary, current: after, now: Date(timeIntervalSince1970: 14_401) + ).isEmpty) + + let nilBefore = presentation(elapsed: "03:59:59", birthToken: nil) + let nilBoundary = presentation(elapsed: "04:00:00", birthToken: nil) + XCTAssertTrue(detector.events( + previous: nilBefore, current: nilBoundary, now: Date(timeIntervalSince1970: 20_000) + ).isEmpty) + } + + func testUnexpectedExitRequiresTwoCompleteHealthyExpectedRunningAbsences() { + var detector = AutomationEventDetector() + let running = presentation(elapsed: "00:01:00") + let absent = presentation(processes: []) + + XCTAssertTrue(detector.events(previous: nil, current: running, now: date(1_000)).isEmpty) + XCTAssertTrue(detector.events(previous: running, current: absent, now: date(1_001)).isEmpty) + XCTAssertEqual(detector.events(previous: absent, current: absent, now: date(1_002)), [ + .unexpectedExit( + recordID: expectedRecord.id, + process: eventIdentity(process(elapsed: "00:01:00")) + ), + ]) + XCTAssertTrue(detector.events(previous: absent, current: absent, now: date(1_003)).isEmpty) + } + + func testIncompleteOrFailedSourceResetsExitConfirmation() { + var detector = AutomationEventDetector() + let running = presentation(elapsed: "00:01:00") + let incomplete = presentation(complete: false, processes: []) + let absent = presentation(processes: []) + let failed = presentation(sourceHealth: .failed, processes: []) + + XCTAssertTrue(detector.events(previous: running, current: incomplete, now: date(2_000)).isEmpty) + XCTAssertTrue(detector.events(previous: incomplete, current: absent, now: date(2_001)).isEmpty) + XCTAssertTrue(detector.events(previous: absent, current: failed, now: date(2_002)).isEmpty) + XCTAssertTrue(detector.events(previous: failed, current: absent, now: date(2_003)).isEmpty) + XCTAssertTrue(detector.events(previous: absent, current: absent, now: date(2_004)).isEmpty) + } + + func testUnhealthyPreviousSourceCannotSeedExitConfirmation() { + var detector = AutomationEventDetector() + let unhealthyRunning = presentation(elapsed: "00:01:00", sourceHealth: .failed) + let healthyAbsent = presentation(processes: []) + + XCTAssertTrue(detector.events( + previous: unhealthyRunning, current: healthyAbsent, now: date(2_100) + ).isEmpty) + XCTAssertTrue(detector.events( + previous: healthyAbsent, current: healthyAbsent, now: date(2_101) + ).isEmpty) + } + + func testLongRunningPIDRecycleEmitsTransitionForNewBirthIdentity() { + var detector = AutomationEventDetector() + let old = presentation(elapsed: "04:00:00") + let newBirth = ProcessBirthToken(seconds: 30_000, microseconds: 7) + let recycled = presentation(elapsed: "04:00:01", birthToken: newBirth) + + XCTAssertEqual(detector.events( + previous: old, current: recycled, now: date(30_001) + ), [.crossedLongRunningThreshold( + process: ProcessIdentity(pid: Fixtures.runningBackup.pid, birthToken: newBirth), + recordID: expectedRecord.id + )]) + } + + func testLongRunningTransitionDoesNotAttributeAnAmbiguousProcessToOneAutomation() throws { + var detector = AutomationEventDetector() + let before = presentation(elapsed: "03:59:59") + let boundary = presentation(elapsed: "04:00:00") + let identity = try XCTUnwrap(boundary.processIdentitiesByID[Fixtures.runningBackup.pid]) + let primaryLink = try XCTUnwrap(boundary.linksByProcessID[Fixtures.runningBackup.pid]) + let secondLink = AutomationProcessLink( + recordID: AutomationRecord.ID(rawValue: "second-strong-record"), + processIdentity: identity, + strength: .strong, + evidence: [] + ) + let ambiguous = AutomationPresentationSnapshot( + inventory: boundary.inventory, + linksByProcessID: boundary.linksByProcessID, + allLinksByProcessID: [identity.pid: [primaryLink, secondLink]], + longRunningProcessIDs: boundary.longRunningProcessIDs, + longRunningProcessIdentities: boundary.longRunningProcessIdentities, + processIdentitiesByID: boundary.processIdentitiesByID, + isProcessSnapshotComplete: boundary.isProcessSnapshotComplete + ) + + XCTAssertEqual(detector.events( + previous: before, current: ambiguous, now: Date(timeIntervalSince1970: 14_400) + ), [.crossedLongRunningThreshold( + process: eventIdentity(process(elapsed: "04:00:00")), + recordID: nil + )]) + } + + func testUnexpectedExitDoesNotAttributeOneAmbiguousProcessToMultipleAutomations() throws { + var detector = AutomationEventDetector() + let running = presentation(elapsed: "00:01:00") + let identity = try XCTUnwrap(running.processIdentitiesByID[Fixtures.runningBackup.pid]) + let primaryLink = try XCTUnwrap(running.linksByProcessID[Fixtures.runningBackup.pid]) + let secondRecord = copyExpectedRecord( + id: AutomationRecord.ID(rawValue: "second-strong-record") + ) + let secondLink = AutomationProcessLink( + recordID: secondRecord.id, + processIdentity: identity, + strength: .strong, + evidence: [] + ) + let inventory = AutomationInventorySnapshot( + generation: running.inventory.generation, + records: [expectedRecord, secondRecord], + health: running.inventory.health, + refreshedAt: running.inventory.refreshedAt + ) + let ambiguousRunning = AutomationPresentationSnapshot( + inventory: inventory, + linksByProcessID: running.linksByProcessID, + allLinksByProcessID: [identity.pid: [primaryLink, secondLink]], + longRunningProcessIDs: running.longRunningProcessIDs, + longRunningProcessIdentities: running.longRunningProcessIdentities, + processIdentitiesByID: running.processIdentitiesByID, + isProcessSnapshotComplete: true + ) + let ambiguousAbsent = AutomationPresentationSnapshot( + inventory: inventory, + linksByProcessID: [:], + allLinksByProcessID: [:], + longRunningProcessIDs: [], + longRunningProcessIdentities: [], + processIdentitiesByID: [:], + isProcessSnapshotComplete: true + ) + + XCTAssertTrue(detector.events( + previous: ambiguousRunning, current: ambiguousAbsent, now: date(2_200) + ).isEmpty) + XCTAssertTrue(detector.events( + previous: ambiguousAbsent, current: ambiguousAbsent, now: date(2_201) + ).isEmpty) + } + + func testPIDReplacementDoesNotBecomeUnexpectedExit() { + var detector = AutomationEventDetector() + let running = presentation(elapsed: "00:01:00") + let replacement = process( + elapsed: "00:00:01", + birthToken: ProcessBirthToken(seconds: 9_999, microseconds: 9) + ) + let replaced = presentation(processes: [replacement]) + + XCTAssertTrue(detector.events(previous: running, current: replaced, now: date(3_000)).isEmpty) + XCTAssertTrue(detector.events(previous: replaced, current: replaced, now: date(3_001)).isEmpty) + } + + func testThreeVerifiedExitsAtInclusiveTenMinuteBoundaryEmitRepeatedFailureOnce() { + var detector = AutomationEventDetector() + var previous = presentation(elapsed: "00:01:00") + var emitted: [[AutomationEvent]] = [] + + for timestamp in [1_000.0, 1_300.0, 1_600.0] { + let absent = presentation(processes: []) + _ = detector.events(previous: previous, current: absent, now: date(timestamp - 1)) + emitted.append(detector.events(previous: absent, current: absent, now: date(timestamp))) + previous = presentation(elapsed: "00:01:00") + _ = detector.events(previous: absent, current: previous, now: date(timestamp + 0.5)) + } + + XCTAssertEqual(emitted[0].count, 1) + XCTAssertEqual(emitted[1].count, 1) + XCTAssertEqual(emitted[2], [ + .unexpectedExit( + recordID: expectedRecord.id, + process: eventIdentity(process(elapsed: "00:01:00")) + ), + .repeatedFailure(recordID: expectedRecord.id, observedExitCount: 3), + ]) + } + + func testRepeatedFailureIsSuppressedWhileThreeExitsRemainInWindow() { + var detector = AutomationEventDetector() + let outputs = [4_000.0, 4_100.0, 4_200.0, 4_300.0].map { timestamp in + emitVerifiedExit(detector: &detector, timestamp: timestamp) + } + + XCTAssertEqual(outputs.flatMap { $0 }.filter { + if case .repeatedFailure = $0 { return true } + return false + }.count, 1) + XCTAssertEqual(outputs.map(\.count), [1, 1, 2, 1]) + } +} + +private let expectedRecord: AutomationRecord = { + let source = Fixtures.runningUserAgent + return AutomationRecord( + id: source.id, kind: source.kind, sourceKind: source.sourceKind, + label: source.label, displayName: source.displayName, + providerBundleIdentifier: source.providerBundleIdentifier, ownerUID: source.ownerUID, + ownership: .user, executable: source.executable, arguments: source.arguments, + commandSignature: source.commandSignature, environment: source.environment, + workingDirectory: source.workingDirectory, + schedule: AutomationSchedule(triggers: [.keepAlive], summary: "Keep alive"), + sourceURL: source.sourceURL, sourceChecksum: source.sourceChecksum, + enabledState: .enabled, loadState: .loaded, approvalState: source.approvalState, + state: .running, evidence: source.evidence, capabilities: source.capabilities, + validationFindings: source.validationFindings + ) +}() + +private func copyExpectedRecord(id: AutomationRecord.ID) -> AutomationRecord { + AutomationRecord( + id: id, kind: expectedRecord.kind, sourceKind: expectedRecord.sourceKind, + label: "\(expectedRecord.label).second", displayName: expectedRecord.displayName, + providerBundleIdentifier: expectedRecord.providerBundleIdentifier, + ownerUID: expectedRecord.ownerUID, ownership: expectedRecord.ownership, + executable: expectedRecord.executable, arguments: expectedRecord.arguments, + commandSignature: expectedRecord.commandSignature, environment: expectedRecord.environment, + workingDirectory: expectedRecord.workingDirectory, schedule: expectedRecord.schedule, + sourceURL: expectedRecord.sourceURL, sourceChecksum: expectedRecord.sourceChecksum, + enabledState: expectedRecord.enabledState, loadState: expectedRecord.loadState, + approvalState: expectedRecord.approvalState, state: expectedRecord.state, + evidence: expectedRecord.evidence, capabilities: expectedRecord.capabilities, + validationFindings: expectedRecord.validationFindings + ) +} + +private func process( + elapsed: String, + birthToken: ProcessBirthToken? = ProcessBirthToken(seconds: 10_000, microseconds: 42) +) -> DevProcess { + DevProcess( + pid: Fixtures.runningBackup.pid, parentPID: 1, + executable: Fixtures.runningBackup.executable, command: Fixtures.runningBackup.command, + argumentVector: Fixtures.runningBackup.argumentVector, + resourceUsage: DevProcessResourceUsage( + cpuPercent: 0, residentMemoryBytes: 0, elapsedTime: elapsed + ), + birthToken: birthToken + ) +} + +private func presentation( + elapsed: String = "00:00:00", + birthToken: ProcessBirthToken? = ProcessBirthToken(seconds: 10_000, microseconds: 42), + complete: Bool = true, + sourceHealth: AutomationSourceHealthState = .healthy, + processes: [DevProcess]? = nil +) -> AutomationPresentationSnapshot { + let inventory = AutomationInventorySnapshot( + generation: 7, + records: [expectedRecord], + health: [.launchAgent: AutomationSourceHealth( + kind: .launchAgent, state: sourceHealth, message: nil, refreshedAt: date(0) + )], + refreshedAt: date(0) + ) + return AutomationPresentationSnapshot.build( + inventory: inventory, + processes: processes ?? [process(elapsed: elapsed, birthToken: birthToken)], + longRunningThreshold: 14_400, + now: date(20_000), + isProcessSnapshotComplete: complete + ) +} + +private func date(_ seconds: TimeInterval) -> Date { + Date(timeIntervalSince1970: seconds) +} + +private func eventIdentity(_ process: DevProcess) -> ProcessIdentity { + ProcessIdentity(pid: process.pid, birthToken: process.birthToken) +} + +private func emitVerifiedExit( + detector: inout AutomationEventDetector, + timestamp: TimeInterval +) -> [AutomationEvent] { + let running = presentation(elapsed: "00:01:00") + let absent = presentation(processes: []) + _ = detector.events(previous: running, current: absent, now: date(timestamp - 1)) + let events = detector.events(previous: absent, current: absent, now: date(timestamp)) + _ = detector.events(previous: absent, current: running, now: date(timestamp + 0.1)) + return events +} diff --git a/Tests/DevScopeCoreTests/AutomationExecutorsTests.swift b/Tests/DevScopeCoreTests/AutomationExecutorsTests.swift new file mode 100644 index 0000000..a494c5e --- /dev/null +++ b/Tests/DevScopeCoreTests/AutomationExecutorsTests.swift @@ -0,0 +1,1170 @@ +import CryptoKit +import Darwin +import XCTest +@testable import DevScopeCore + +final class AutomationExecutorsTests: XCTestCase { + func testCronPublicCompositionInitializerAcceptsFreshProcessSnapshotProvider() async throws { + let runner = RecordingAutomationCommandRunner() + let record = cronRecord(command: "/usr/bin/true", scheduleExpression: "* * * * *") + let executor = CronAutomationExecutor( + runner: runner, + fileSystem: InMemoryAutomationFileSystem(), + currentUID: 501, + processSnapshot: { [] } + ) + + let confirmation = AutomationRunToCompletionConfirmation( + recordID: record.id, + sourceChecksum: record.sourceChecksum, + exactCommand: "/usr/bin/true" + ) + let result = try await executor.apply(.confirmedRunToCompletion(confirmation), to: record) + + XCTAssertEqual(result.postconditions, [.targetResolved, .runCompleted]) + } + + func testCronChecksumNormalizesLineEndingsAndExactlyOneTerminalNewline() throws { + let zero = Data("0 * * * * /usr/bin/true".utf8) + let one = Data("0 * * * * /usr/bin/true\n".utf8) + let multiple = Data("0 * * * * /usr/bin/true\n\n\n".utf8) + let crlf = Data("0 * * * * /usr/bin/true\r\n".utf8) + + XCTAssertEqual(CronDocumentChecksum.checksum(zero), CronDocumentChecksum.checksum(one)) + XCTAssertEqual(CronDocumentChecksum.checksum(one), CronDocumentChecksum.checksum(multiple)) + XCTAssertEqual(CronDocumentChecksum.checksum(one), CronDocumentChecksum.checksum(crlf)) + XCTAssertNotEqual( + CronDocumentChecksum.checksum(Data("# interior\n\n0 * * * * /usr/bin/true\n".utf8)), + CronDocumentChecksum.checksum(Data("# interior\n0 * * * * /usr/bin/true\n".utf8)) + ) + } + + func testLaunchdStartUsesKickstartWithAnArgumentArray() async throws { + let runner = RecordingAutomationCommandRunner() + let executor = LaunchdAutomationExecutor(runner: runner, guiUID: 501) + + _ = try await executor.apply(.startNow, to: Fixtures.runningUserAgent) + + XCTAssertEqual(runner.invocations, [ + AutomationCommand( + executable: "/bin/launchctl", + arguments: ["kickstart", "gui/501/com.example.backup"] + ), + ]) + } + + func testLaunchdStartBootstrapsCanonicalSourceBeforeKickstartingAnUnloadedService() async throws { + let runner = RecordingAutomationCommandRunner() + let executor = LaunchdAutomationExecutor(runner: runner, guiUID: 501) + + _ = try await executor.apply(.startNow, to: Fixtures.userAgent) + + XCTAssertEqual(runner.invocations.map(\.arguments), [ + ["bootstrap", "gui/501", Fixtures.userAgent.sourceURL!.path], + ["kickstart", "gui/501/com.example.backup"], + ]) + } + + func testLaunchdDisablePreventsFutureLaunchesWithoutClaimingTheProcessStopped() async throws { + let runner = RecordingAutomationCommandRunner() + let executor = LaunchdAutomationExecutor(runner: runner, guiUID: 501) + + let result = try await executor.apply(.disable, to: Fixtures.runningUserAgent) + + XCTAssertEqual(runner.invocations, [ + AutomationCommand( + executable: "/bin/launchctl", + arguments: ["disable", "gui/501/com.example.backup"] + ), + ]) + XCTAssertEqual(result.postconditions, [.futureLaunchesDisabled]) + XCTAssertFalse(result.postconditions.contains(.noLinkedProcess)) + } + + func testLaunchdDisableAndStopDisablesThenBootsOutTheExactService() async throws { + let birth = ProcessBirthToken(seconds: 9_000, microseconds: 7) + let process = classifiedProcess(pid: 44_001, birth: birth) + let runner = RecordingAutomationCommandRunner() + let executor = LaunchdAutomationExecutor(runner: runner, guiUID: 501) + + let result = try await executor.apply( + .disableAndStop, + to: Fixtures.runningUserAgent, + linkedProcesses: [process], + proposedSourceURL: nil + ) + + XCTAssertEqual(runner.invocations.map(\.arguments), [ + ["disable", "gui/501/com.example.backup"], + ["bootout", "gui/501/com.example.backup"], + ]) + XCTAssertEqual(result.postconditions, [.futureLaunchesDisabled, .noLinkedProcess]) + } + + func testLaunchdEnableBootstrapsCanonicalPlistOnlyWhenTargetIsNotLoaded() async throws { + let runner = ScriptedAutomationCommandRunner(results: [ + missingLaunchctlServiceResult(), + successfulCommandResult(), + successfulCommandResult(), + ]) + let executor = LaunchdAutomationExecutor(runner: runner, guiUID: 501) + + let result = try await executor.apply(.enable, to: Fixtures.userAgent) + + XCTAssertEqual(runner.invocations.map(\.arguments), [ + ["print", "gui/501/com.example.backup"], + ["enable", "gui/501/com.example.backup"], + ["bootstrap", "gui/501", Fixtures.userAgent.sourceURL!.path], + ]) + XCTAssertEqual(result.postconditions, [.futureLaunchesEnabled, .targetResolved]) + XCTAssertFalse(result.postconditions.contains(.currentRunStarted)) + } + + func testLaunchdEnableFailsClosedWhenPrintIsPermissionDenied() async { + let runner = ScriptedAutomationCommandRunner(results: [ + AutomationCommandResult( + status: 113, + standardOutput: Data(), + standardError: Data("Operation not permitted\n".utf8) + ), + ]) + let executor = LaunchdAutomationExecutor(runner: runner, guiUID: 501) + + await XCTAssertThrowsErrorAsync { + _ = try await executor.apply(.enable, to: Fixtures.userAgent) + } + + XCTAssertEqual(runner.invocations.map(\.arguments), [ + ["print", "gui/501/com.example.backup"], + ]) + XCTAssertEqual(runner.invocations.first?.environment, ["LC_ALL": "C"]) + } + + func testLaunchctlMissingServiceClassifierRejectsUnexpectedStatusOrDiagnostic() { + let unexpectedDiagnostic = AutomationCommandResult( + status: 113, + standardOutput: Data(), + standardError: Data("Operation not permitted\n".utf8) + ) + let unexpectedStatus = AutomationCommandResult( + status: 5, + standardOutput: Data(), + standardError: missingLaunchctlServiceResult().standardError + ) + + XCTAssertEqual( + LaunchctlServiceTargetClassifier.classify( + unexpectedDiagnostic, + label: "com.example.backup", + guiUID: 501 + ), + .unknown + ) + XCTAssertEqual( + LaunchctlServiceTargetClassifier.classify( + unexpectedStatus, + label: "com.example.backup", + guiUID: 501 + ), + .unknown + ) + } + + func testLaunchdDuplicateExplicitlyDisablesTheDistinctCopyWithoutLoadingIt() async throws { + let label = "com.example.backup.copy" + let destination = URL(fileURLWithPath: "/tmp/devscope-fixtures/\(label).plist") + let data = try PropertyListSerialization.data( + fromPropertyList: [ + "Label": label, + "ProgramArguments": ["/bin/sleep", "14400"], + ], + format: .xml, + options: 0 + ) + let fileSystem = InMemoryAutomationFileSystem(files: [destination: data]) + let runner = RecordingAutomationCommandRunner() + let executor = LaunchdAutomationExecutor( + runner: runner, + guiUID: 501, + fileSystem: fileSystem + ) + let payload = AutomationEditPayload( + label: label, + executable: "/bin/sleep", + arguments: ["14400"], + environment: [:], + workingDirectory: nil, + schedule: Fixtures.userAgent.schedule, + rawRepresentation: data, + destination: destination + ) + + let result = try await executor.apply( + .duplicate(payload), + to: Fixtures.userAgent, + linkedProcesses: [], + proposedSourceURL: destination + ) + + XCTAssertEqual(runner.invocations.map(\.arguments), [ + ["disable", "gui/501/\(label)"], + ]) + XCTAssertTrue(result.postconditions.contains(.futureLaunchesDisabled)) + XCTAssertFalse(result.postconditions.contains(.targetResolved)) + } + + func testLaunchdStopBootsOutTheExactServiceInsteadOfSignallingItsKeepAliveProcess() async throws { + let birth = ProcessBirthToken(seconds: 9_000, microseconds: 7) + let process = classifiedProcess(pid: 44_001, birth: birth) + let runner = RecordingAutomationCommandRunner() + let executor = LaunchdAutomationExecutor(runner: runner, guiUID: 501) + + let result = try await executor.apply( + .stopCurrentRun, + to: Fixtures.runningUserAgent, + linkedProcesses: [process], + proposedSourceURL: nil + ) + + XCTAssertEqual(runner.invocations.map(\.arguments), [ + ["bootout", "gui/501/com.example.backup"], + ]) + XCTAssertEqual(result.postconditions, [.noLinkedProcess]) + } + + func testLaunchdStopFailsClosedWhenBootoutFails() async { + let birth = ProcessBirthToken(seconds: 9_000, microseconds: 7) + let process = classifiedProcess(pid: 44_002, birth: birth) + let runner = ScriptedAutomationCommandRunner(statuses: [5]) + let executor = LaunchdAutomationExecutor(runner: runner, guiUID: 501) + + do { + _ = try await executor.apply( + .stopCurrentRun, + to: Fixtures.runningUserAgent, + linkedProcesses: [process], + proposedSourceURL: nil + ) + XCTFail("Expected failed bootout to fail closed") + } catch { + XCTAssertEqual( + error as? AutomationExecutorError, + .commandFailed(executable: "/bin/launchctl", status: 5) + ) + } + XCTAssertEqual(runner.invocations.map(\.arguments), [ + ["bootout", "gui/501/com.example.backup"], + ]) + } + + func testLaunchdRollbackRestoresDisabledButLoadedStateExactly() async throws { + let runner = ScriptedAutomationCommandRunner(results: [ + successfulCommandResult(), + missingLaunchctlServiceResult(), + successfulCommandResult(), + ]) + let executor = LaunchdAutomationExecutor(runner: runner, guiUID: 501) + let state = AutomationPreTransactionState( + enabledState: .disabled, + loadState: .loaded, + linkedProcesses: [] + ) + + let result = try await executor.restorePreTransactionState( + state, + for: Fixtures.runningUserAgent, + recovery: verifiedRecoveryInput( + data: Data(), + url: URL(fileURLWithPath: "/tmp/recovery.backup") + ), + linkedProcesses: [], + expectedAppliedState: nil + ) + + XCTAssertEqual(runner.invocations.map(\.arguments), [ + ["disable", "gui/501/com.example.backup"], + ["print", "gui/501/com.example.backup"], + ["bootstrap", "gui/501", Fixtures.runningUserAgent.sourceURL!.path], + ]) + XCTAssertEqual(result.postconditions, [.preTransactionStateRestored(state)]) + } + + func testLaunchdRollbackFailsClosedWhenPrintStateIsUnknown() async { + let runner = ScriptedAutomationCommandRunner(results: [ + successfulCommandResult(), + AutomationCommandResult( + status: 113, + standardOutput: Data(), + standardError: Data("Operation not permitted\n".utf8) + ), + ]) + let executor = LaunchdAutomationExecutor(runner: runner, guiUID: 501) + let state = AutomationPreTransactionState( + enabledState: .disabled, + loadState: .loaded, + linkedProcesses: [] + ) + + await XCTAssertThrowsErrorAsync { + _ = try await executor.restorePreTransactionState( + state, + for: Fixtures.runningUserAgent, + recovery: verifiedRecoveryInput( + data: Data(), + url: URL(fileURLWithPath: "/tmp/recovery.backup") + ), + linkedProcesses: [], + expectedAppliedState: nil + ) + } + + XCTAssertEqual(runner.invocations.map(\.arguments), [ + ["disable", "gui/501/com.example.backup"], + ["print", "gui/501/com.example.backup"], + ]) + } + + func testLaunchdRemoveUsesStillExistingTrashPathAndProvesTargetUnresolved() async throws { + let runner = ScriptedAutomationCommandRunner(results: [ + missingLaunchctlServiceResult(), + successfulCommandResult(), + missingLaunchctlServiceResult(), + ]) + let executor = LaunchdAutomationExecutor(runner: runner, guiUID: 501) + let trashURL = URL(fileURLWithPath: "/Users/test/.Trash/backup.plist") + + let result = try await executor.apply( + .remove, + to: Fixtures.userAgent, + linkedProcesses: [], + proposedSourceURL: trashURL + ) + + XCTAssertEqual(runner.invocations.map(\.arguments), [ + ["print", "gui/501/com.example.backup"], + ["bootout", "gui/501", trashURL.path], + ["print", "gui/501/com.example.backup"], + ]) + XCTAssertEqual(result.postconditions, [.sourceRemoved, .targetUnresolved]) + } + + func testLaunchdRemoveNeverClaimsSuccessWhenFallbackFailsOrTargetStillResolves() async { + let trashURL = URL(fileURLWithPath: "/Users/test/.Trash/backup.plist") + for statuses in [[113, 5], [0, 0, 0]] as [[Int32]] { + let runner = ScriptedAutomationCommandRunner(statuses: statuses) + let executor = LaunchdAutomationExecutor(runner: runner, guiUID: 501) + await XCTAssertThrowsErrorAsync { + _ = try await executor.apply( + .remove, + to: Fixtures.userAgent, + linkedProcesses: [], + proposedSourceURL: trashURL + ) + } + } + } + + func testLaunchdRemoveDoesNotClaimUnresolvedForUnexpectedFinalPrintFailure() async { + let trashURL = URL(fileURLWithPath: "/Users/test/.Trash/backup.plist") + let runner = ScriptedAutomationCommandRunner(results: [ + missingLaunchctlServiceResult(), + successfulCommandResult(), + AutomationCommandResult( + status: 113, + standardOutput: Data(), + standardError: Data("Operation not permitted\n".utf8) + ), + ]) + let executor = LaunchdAutomationExecutor(runner: runner, guiUID: 501) + + await XCTAssertThrowsErrorAsync { + _ = try await executor.apply( + .remove, + to: Fixtures.userAgent, + linkedProcesses: [], + proposedSourceURL: trashURL + ) + } + + XCTAssertEqual(runner.invocations.map(\.arguments), [ + ["print", "gui/501/com.example.backup"], + ["bootout", "gui/501", trashURL.path], + ["print", "gui/501/com.example.backup"], + ]) + } + + func testCommandFailureReportsOnlyExecutableAndStatus() async { + let secret = "TOKEN=do-not-log" + let runner = ScriptedAutomationCommandRunner(results: [ + AutomationCommandResult( + status: 78, + standardOutput: Data(secret.utf8), + standardError: Data(secret.utf8) + ), + ]) + let executor = LaunchdAutomationExecutor(runner: runner, guiUID: 501) + + do { + _ = try await executor.apply(.startNow, to: Fixtures.userAgent) + XCTFail("Expected the nonzero command to fail") + } catch { + XCTAssertEqual( + error as? AutomationExecutorError, + .commandFailed(executable: "/bin/launchctl", status: 78) + ) + XCTAssertFalse(String(describing: error).contains(secret)) + } + } + + func testCronRunNowRequiresTypedConfirmationAndPassesOneExactCommandArgument() async throws { + let runner = RecordingAutomationCommandRunner() + let record = cronRecord( + command: "printf '%s' \"$TOKEN\" | /usr/bin/logger", + environment: ["SHELL": "/bin/zsh"] + ) + let executor = CronAutomationExecutor(runner: runner, fileSystem: InMemoryAutomationFileSystem(), currentUID: 501) + let confirmation = AutomationRunToCompletionConfirmation( + recordID: record.id, + sourceChecksum: record.sourceChecksum, + exactCommand: record.commandSignature! + ) + + let result = try await executor.apply(.confirmedRunToCompletion(confirmation), to: record) + + XCTAssertEqual(runner.invocations, [ + AutomationCommand( + executable: "/bin/zsh", + arguments: ["-c", "printf '%s' \"$TOKEN\" | /usr/bin/logger"], + environment: record.environment + ), + ]) + XCTAssertEqual(result.postconditions, [.targetResolved, .runCompleted]) + } + + func testCronUnconfirmedStartNowFailsBeforeCommandInvocation() async { + let runner = RecordingAutomationCommandRunner() + let executor = CronAutomationExecutor(runner: runner, fileSystem: InMemoryAutomationFileSystem(), currentUID: 501) + + await XCTAssertThrowsErrorAsync { + _ = try await executor.apply(.startNow, to: cronRecord(command: "/usr/bin/true")) + } + + XCTAssertTrue(runner.invocations.isEmpty) + } + + func testCronEditInstallsAndVerifiesTheCompleteLosslessDocumentThroughA0600File() async throws { + let sourceURL = URL(fileURLWithPath: "/tmp/devscope-fixtures/current-user.crontab") + let document = "SHELL=/bin/zsh\n# keep this comment\n0 * * * * /usr/bin/true\n" + let fileSystem = InMemoryAutomationFileSystem(files: [sourceURL: Data(document.utf8)]) + let runner = ScriptedAutomationCommandRunner(results: [ + AutomationCommandResult(status: 0, standardOutput: Data(), standardError: Data()), + AutomationCommandResult(status: 0, standardOutput: Data(document.utf8), standardError: Data()), + ]) + let executor = CronAutomationExecutor(runner: runner, fileSystem: fileSystem, currentUID: 501) + + let result = try await executor.apply( + .edit(AutomationEditPayload( + label: "Cron entry 1", + executable: "/usr/bin/true", + arguments: [], + environment: ["SHELL": "/bin/zsh"], + workingDirectory: nil, + schedule: AutomationSchedule(triggers: [.cron("0 * * * *")], summary: "Hourly"), + rawRepresentation: Data(document.utf8) + )), + to: cronRecord(command: "/usr/bin/true"), + linkedProcesses: [], + proposedSourceURL: sourceURL + ) + + guard case .writeTemporary(let temporaryURL, permissions: 0o600) = fileSystem.recordedOperations.first else { + return XCTFail("Expected an owner-only complete-document staging file") + } + XCTAssertEqual(runner.invocations, [ + AutomationCommand(executable: "/usr/bin/crontab", arguments: [temporaryURL.path]), + AutomationCommand(executable: "/usr/bin/crontab", arguments: ["-l"], environment: ["LC_ALL": "C"]), + ]) + let checksum = SHA256.hash(data: Data(document.utf8)).map { String(format: "%02x", $0) }.joined() + XCTAssertTrue(result.postconditions.contains(.sourceInstalled)) + XCTAssertTrue(result.postconditions.contains(.sourceChecksum(checksum))) + } + + func testCronInstallDoesNotClaimPostconditionsWhenRelistedDocumentDiffers() async { + let sourceURL = URL(fileURLWithPath: "/tmp/devscope-fixtures/current-user.crontab") + let intended = Data("0 * * * * /usr/bin/true\n".utf8) + let stale = Data("0 * * * * /usr/bin/false\n".utf8) + let fileSystem = InMemoryAutomationFileSystem(files: [sourceURL: intended]) + let runner = ScriptedAutomationCommandRunner(results: [ + AutomationCommandResult(status: 0, standardOutput: Data(), standardError: Data()), + AutomationCommandResult(status: 0, standardOutput: stale, standardError: Data()), + ]) + let executor = CronAutomationExecutor(runner: runner, fileSystem: fileSystem, currentUID: 501) + + do { + _ = try await executor.apply( + .edit(AutomationEditPayload( + label: "Cron entry 1", + executable: "/usr/bin/true", + arguments: [], + environment: [:], + workingDirectory: nil, + schedule: AutomationSchedule(triggers: [.cron("0 * * * *")], summary: "Hourly"), + rawRepresentation: intended + )), + to: cronRecord(command: "/usr/bin/true"), + linkedProcesses: [], + proposedSourceURL: sourceURL + ) + XCTFail("Expected live generation mismatch") + } catch let failure as AutomationExecutorMutationFailure { + XCTAssertEqual( + failure.appliedState, + .cronLiveDocument(normalizedChecksum: CronDocumentChecksum.checksum(intended)!) + ) + } catch { + XCTFail("Expected typed applied-state failure, got \(error)") + } + } + + func testCronRollbackRefusesToOverwriteExternallyEditedLiveDocument() async { + let backupURL = URL(fileURLWithPath: "/tmp/devscope-fixtures/cron.backup") + let backup = Data("0 * * * * /usr/bin/original\n".utf8) + let owned = Data("0 * * * * /usr/bin/devscope-change\n".utf8) + let external = Data("0 * * * * /usr/bin/external-edit\n".utf8) + let fileSystem = InMemoryAutomationFileSystem(files: [backupURL: backup]) + let runner = InspectingAutomationCommandRunner { command in + XCTAssertEqual(command.arguments, ["-l"]) + XCTAssertTrue(fileSystem.recordedOperations.contains { + if case .writeTemporary = $0 { return true } + return false + }, "The authenticated backup must be staged before the final live-generation proof.") + return AutomationCommandResult(status: 0, standardOutput: external, standardError: Data()) + } + let executor = CronAutomationExecutor(runner: runner, fileSystem: fileSystem, currentUID: 501) + let state = AutomationPreTransactionState( + enabledState: .enabled, + loadState: .unknown, + linkedProcesses: [] + ) + let backupEntry = try! XCTUnwrap( + CronParser.parse(String(decoding: backup, as: UTF8.self)).entries.first + ) + let record = cronRecord( + command: "/usr/bin/original", + recordID: CronParser.recordID(for: backupEntry, ownerUID: 501) + ) + + await XCTAssertThrowsErrorAsync { + _ = try await executor.restorePreTransactionState( + state, + for: record, + recovery: verifiedRecoveryInput(data: backup, url: backupURL), + linkedProcesses: [], + expectedAppliedState: .cronLiveDocument( + normalizedChecksum: CronDocumentChecksum.checksum(owned)! + ) + ) + } + + XCTAssertEqual(runner.invocations, [ + AutomationCommand(executable: "/usr/bin/crontab", arguments: ["-l"], environment: ["LC_ALL": "C"]), + ]) + XCTAssertFalse(runner.invocations.contains { $0.arguments != ["-l"] }) + } + + func testCronRecoveryConsumesVerifiedBytesWhenBackupPathChangesAfterAuthentication() async throws { + let backupURL = URL(fileURLWithPath: "/tmp/devscope-fixtures/cron.backup") + let verified = Data("0 * * * * /usr/bin/true\n5 * * * * /usr/bin/keep\n".utf8) + let altered = Data("0 * * * * /usr/bin/true\n10 * * * * /usr/bin/unrelated-change\n".utf8) + let owned = Data("# devscope-disabled: 0 * * * * /usr/bin/true\n5 * * * * /usr/bin/keep\n".utf8) + let fileSystem = InMemoryAutomationFileSystem(files: [backupURL: verified]) + let recovery = verifiedRecoveryInput(data: verified, url: backupURL) + fileSystem.setStoredData(altered, at: backupURL) + let callIndex = LockedInvocationIndex() + let runner = InspectingAutomationCommandRunner { command in + switch callIndex.next() { + case 1: + return AutomationCommandResult(status: 0, standardOutput: owned, standardError: Data()) + case 2: + XCTAssertEqual(fileSystem.storedData(at: URL(fileURLWithPath: command.arguments[0])), verified) + return AutomationCommandResult(status: 0, standardOutput: Data(), standardError: Data()) + default: + return AutomationCommandResult(status: 0, standardOutput: verified, standardError: Data()) + } + } + let entry = try XCTUnwrap(CronParser.parse(String(decoding: verified, as: UTF8.self)).entries.first) + let record = cronRecord( + command: entry.command, + recordID: CronParser.recordID(for: entry, ownerUID: 501) + ) + let state = AutomationPreTransactionState( + enabledState: .enabled, + loadState: .unknown, + linkedProcesses: [] + ) + let executor = CronAutomationExecutor(runner: runner, fileSystem: fileSystem, currentUID: 501) + + let result = try await executor.restorePreTransactionState( + state, + for: record, + recovery: recovery, + linkedProcesses: [], + expectedAppliedState: .cronLiveDocument( + normalizedChecksum: try XCTUnwrap(CronDocumentChecksum.checksum(owned)) + ) + ) + + XCTAssertTrue(result.postconditions.contains(.preTransactionStateRestored(state))) + XCTAssertEqual(fileSystem.storedData(at: backupURL), altered) + } + + func testCronDisableAndStopPreservesOwnedGenerationForEveryPostInstallFailure() async { + let sourceURL = URL(fileURLWithPath: "/tmp/devscope-fixtures/current-user.crontab") + let intended = Data("# devscope-disabled: * * * * * /usr/bin/true\n".utf8) + let checksum = try! XCTUnwrap(CronDocumentChecksum.checksum(intended)) + let (record, classified, snapshot) = cronProcessFixture() + + let scenarios: [(String, CronAutomationExecutor.ProcessSnapshot, ProcessKiller.IdentityResolver)] = [ + ("snapshot", { throw AutomationExecutorError.processControlUnavailable }, { _ in .notRunning }), + ("poll", { snapshot }, { _ in .identity(ProcessIdentity(process: classified.process)) }), + ] + + for (name, processSnapshot, resolver) in scenarios { + let fileSystem = InMemoryAutomationFileSystem(files: [sourceURL: intended]) + let runner = ScriptedAutomationCommandRunner(results: [ + AutomationCommandResult(status: 0, standardOutput: Data(), standardError: Data()), + AutomationCommandResult(status: 0, standardOutput: intended, standardError: Data()), + ]) + let executor = CronAutomationExecutor( + runner: runner, + fileSystem: fileSystem, + currentUID: 501, + processKiller: ProcessKiller( + identityResolver: resolver, + signalSender: { _, _ in } + ), + currentProcessID: 99_999, + processIdentityResolver: resolver, + processSnapshot: processSnapshot, + now: { Date(timeIntervalSince1970: 3_600) }, + terminationVerificationPolicy: .init(maximumAttempts: 1, interval: .zero), + verificationSleep: { _ in } + ) + + do { + _ = try await executor.apply( + .disableAndStop, + to: record, + linkedProcesses: [classified], + proposedSourceURL: sourceURL + ) + XCTFail("Expected \(name) failure") + } catch let failure as AutomationExecutorMutationFailure { + XCTAssertEqual( + failure.appliedState, + .cronLiveDocument(normalizedChecksum: checksum), + name + ) + } catch { + XCTFail("Expected owned-generation mutation failure for \(name), got \(error)") + } + } + } + + func testCronStopRejectsWrongAncestryAndWrongScheduleMinuteBeforeSignal() async { + let birth = ProcessBirthToken(seconds: 3_600, microseconds: 0) + let carrier = DevProcess( + pid: 55_001, + parentPID: 55_000, + executable: "/bin/sh", + command: "/bin/sh -c /usr/bin/true", + argumentVector: ["/bin/sh", "-c", "/usr/bin/true"], + resourceUsage: DevProcessResourceUsage( + cpuPercent: 0, + residentMemoryBytes: 1, + elapsedTime: "00:00:00" + ), + birthToken: birth + ) + let classified = ClassifiedDevProcess( + process: carrier, + classification: DevProcessClassification(kind: .shell, displayName: "cron", projectHint: nil) + ) + let cronDaemon = DevProcess( + pid: 55_000, + parentPID: 1, + executable: "/usr/sbin/cron", + command: "/usr/sbin/cron", + argumentVector: ["/usr/sbin/cron"], + birthToken: ProcessBirthToken(seconds: 1, microseconds: 0) + ) + let signals = RecordedSignals() + let killer = ProcessKiller( + identityResolver: { _ in .identity(ProcessIdentity(process: carrier)) }, + signalSender: signals.send + ) + let cases: [(AutomationRecord, [DevProcess])] = [ + (cronRecord(command: "/usr/bin/true", scheduleExpression: "* * * * *"), [carrier]), + (cronRecord(command: "/usr/bin/true", scheduleExpression: "30 * * * *"), [cronDaemon, carrier]), + ] + + for (record, snapshot) in cases { + let executor = CronAutomationExecutor( + runner: RecordingAutomationCommandRunner(), + fileSystem: InMemoryAutomationFileSystem(), + currentUID: 501, + processKiller: killer, + currentProcessID: 99_999, + processIdentityResolver: { _ in .notRunning }, + processSnapshot: { snapshot }, + now: { Date(timeIntervalSince1970: 3_600) } + ) + await XCTAssertThrowsErrorAsync { + _ = try await executor.apply( + .stopCurrentRun, + to: record, + linkedProcesses: [classified], + proposedSourceURL: nil + ) + } + } + + XCTAssertTrue(signals.values.isEmpty) + } + + func testLegacyLoginItemEnableUsesAFixedProgramAndPassesThePathAsAnArgument() async throws { + let runner = RecordingAutomationCommandRunner() + let record = legacyLoginItemRecord() + let listing = StaticLegacyLoginItemListing(items: [ + LegacyLoginItemDescriptor(name: record.label, path: record.executable!, isHidden: false), + ]) + let executor = LegacyLoginItemAutomationExecutor( + runner: runner, + listing: listing, + currentUID: 501 + ) + + let result = try await executor.apply(.enable, to: record) + + let invocation = try XCTUnwrap(runner.invocations.first) + XCTAssertEqual(invocation.executable, "/usr/bin/osascript") + XCTAssertEqual( + Array(invocation.arguments.suffix(4)), + ["--", "add", "/Applications/Backup.app", "false"] + ) + XCTAssertFalse(invocation.arguments[1].contains("/Applications/Backup.app")) + XCTAssertEqual(result.postconditions, [.futureLaunchesEnabled, .sourceInstalled]) + } + + func testLegacyExecutorRejectsModernBackgroundItemsBeforeAnyCommand() async { + let runner = RecordingAutomationCommandRunner() + let executor = LegacyLoginItemAutomationExecutor( + runner: runner, + listing: StaticLegacyLoginItemListing(items: []), + currentUID: 501 + ) + + await XCTAssertThrowsErrorAsync { + _ = try await executor.apply(.disable, to: Fixtures.backgroundCopyOfUserAgent) + } + + XCTAssertTrue(runner.invocations.isEmpty) + } + + func testLegacyRollbackRestoresTypedHiddenStateForTrueAndFalse() async throws { + let backupURL = URL(fileURLWithPath: "/tmp/devscope-fixtures/legacy.backup") + let record = legacyLoginItemRecord() + let state = AutomationPreTransactionState( + enabledState: .enabled, + loadState: .unknown, + linkedProcesses: [] + ) + + for isHidden in [true, false] { + let descriptor = LegacyLoginItemDescriptor( + name: record.label, + path: record.executable!, + isHidden: isHidden + ) + let descriptorData = try LegacyLoginItemRecoveryDocument.encode( + selectedRecord: record, + descriptor: descriptor, + currentUID: 501 + ) + let fileSystem = InMemoryAutomationFileSystem(files: [backupURL: descriptorData]) + let runner = RecordingAutomationCommandRunner() + let executor = LegacyLoginItemAutomationExecutor( + runner: runner, + listing: StaticLegacyLoginItemListing(items: [ + LegacyLoginItemDescriptor( + name: record.label, + path: record.executable!, + isHidden: isHidden + ), + ]), + currentUID: 501 + ) + let recovery = verifiedRecoveryInput(data: descriptorData, url: backupURL) + fileSystem.setStoredData( + legacyRecoveryDescriptorData(record: record, isHidden: !isHidden), + at: backupURL + ) + + let result = try await executor.restorePreTransactionState( + state, + for: record, + recovery: recovery, + linkedProcesses: [], + expectedAppliedState: nil + ) + + let invocation = try XCTUnwrap(runner.invocations.first) + XCTAssertEqual( + Array(invocation.arguments.suffix(4)), + ["--", "add", record.executable!, isHidden ? "true" : "false"] + ) + XCTAssertEqual(result.postconditions, [.preTransactionStateRestored(state)]) + } + } + + func testLegacyRecoveryDocumentFactoryRejectsUIDAndPathMismatch() throws { + let record = legacyLoginItemRecord() + let matching = LegacyLoginItemDescriptor( + name: record.label, + path: record.executable!, + isHidden: true + ) + XCTAssertThrowsError(try LegacyLoginItemRecoveryDocument.encode( + selectedRecord: record, + descriptor: matching, + currentUID: 502 + )) + XCTAssertThrowsError(try LegacyLoginItemRecoveryDocument.encode( + selectedRecord: record, + descriptor: LegacyLoginItemDescriptor( + name: record.label, + path: "/Applications/Other.app", + isHidden: true + ), + currentUID: 501 + )) + } + + func testLegacyRollbackRejectsMalformedOrMismatchedRecoveryDescriptorBeforeCommand() async { + let backupURL = URL(fileURLWithPath: "/tmp/devscope-fixtures/legacy.backup") + let record = legacyLoginItemRecord() + let state = AutomationPreTransactionState( + enabledState: .enabled, + loadState: .unknown, + linkedProcesses: [] + ) + let invalidDescriptors = [ + Data("{".utf8), + legacyRecoveryDescriptorData(record: record, isHidden: true, ownerUID: 502), + legacyRecoveryDescriptorData(record: record, isHidden: true, recordID: "other-record"), + legacyRecoveryDescriptorData( + record: record, + isHidden: true, + path: "/Applications/Other.app" + ), + ] + + for data in invalidDescriptors { + let runner = RecordingAutomationCommandRunner() + let executor = LegacyLoginItemAutomationExecutor( + runner: runner, + listing: StaticLegacyLoginItemListing(items: []), + currentUID: 501 + ) + await XCTAssertThrowsErrorAsync { + _ = try await executor.restorePreTransactionState( + state, + for: record, + recovery: verifiedRecoveryInput(data: data, url: backupURL), + linkedProcesses: [], + expectedAppliedState: nil + ) + } + XCTAssertTrue(runner.invocations.isEmpty) + } + } +} + +private final class ScriptedAutomationCommandRunner: AutomationCommandRunning, @unchecked Sendable { + private let lock = NSLock() + private var statuses: [Int32] + private var commands: [AutomationCommand] = [] + + private var results: [AutomationCommandResult]? + + init(statuses: [Int32]) { + self.statuses = statuses + results = nil + } + + init(results: [AutomationCommandResult]) { + statuses = [] + self.results = results + } + + var invocations: [AutomationCommand] { + lock.withLock { commands } + } + + func run(_ command: AutomationCommand) async throws -> AutomationCommandResult { + lock.withLock { + commands.append(command) + if var results { + let result = results.isEmpty + ? AutomationCommandResult(status: 0, standardOutput: Data(), standardError: Data()) + : results.removeFirst() + self.results = results + return result + } + let status = statuses.isEmpty ? 0 : statuses.removeFirst() + return AutomationCommandResult( + status: status, + standardOutput: Data("sensitive stdout".utf8), + standardError: Data("sensitive stderr".utf8) + ) + } + } +} + +private func successfulCommandResult() -> AutomationCommandResult { + AutomationCommandResult(status: 0, standardOutput: Data(), standardError: Data()) +} + +private func missingLaunchctlServiceResult( + label: String = "com.example.backup", + guiUID: uid_t = 501 +) -> AutomationCommandResult { + AutomationCommandResult( + status: 113, + standardOutput: Data(), + standardError: Data( + "Bad request.\nCould not find service \"\(label)\" in domain for user gui: \(guiUID)\n".utf8 + ) + ) +} + +private final class InspectingAutomationCommandRunner: AutomationCommandRunning, @unchecked Sendable { + private let lock = NSLock() + private let handler: @Sendable (AutomationCommand) -> AutomationCommandResult + private var commands: [AutomationCommand] = [] + + init(handler: @escaping @Sendable (AutomationCommand) -> AutomationCommandResult) { + self.handler = handler + } + + var invocations: [AutomationCommand] { lock.withLock { commands } } + + func run(_ command: AutomationCommand) async throws -> AutomationCommandResult { + lock.withLock { commands.append(command) } + return handler(command) + } +} + +private final class LockedInvocationIndex: @unchecked Sendable { + private let lock = NSLock() + private var value = 0 + + func next() -> Int { + lock.withLock { + value += 1 + return value + } + } +} + +private func cronRecord( + command: String, + environment: [String: String] = [:], + enabledState: AutomationEnabledState = .enabled, + sourceChecksum: String = "cron-checksum", + scheduleExpression: String = "0 * * * *", + recordID: AutomationRecord.ID = AutomationRecord.ID(rawValue: "cron-record") +) -> AutomationRecord { + return AutomationRecord( + id: recordID, + kind: .cron, + sourceKind: .crontab, + label: "Cron entry 1", + displayName: "Cron entry 1", + providerBundleIdentifier: nil, + ownerUID: 501, + ownership: .user, + executable: nil, + arguments: [], + commandSignature: command, + environment: environment, + workingDirectory: nil, + schedule: AutomationSchedule(triggers: [.cron(scheduleExpression)], summary: "Cron"), + sourceURL: nil, + sourceChecksum: sourceChecksum, + enabledState: enabledState, + loadState: .unknown, + approvalState: .notApplicable, + state: enabledState == .enabled ? .idle : .disabled, + evidence: [], + capabilities: [.startNow], + validationFindings: [] + ) +} + +private func classifiedProcess(pid: Int32, birth: ProcessBirthToken) -> ClassifiedDevProcess { + ClassifiedDevProcess( + process: DevProcess( + pid: pid, + parentPID: 1, + executable: "/bin/sleep", + command: "/bin/sleep 14400", + argumentVector: ["/bin/sleep", "14400"], + birthToken: birth, + launchLabel: Fixtures.userAgent.label + ), + classification: DevProcessClassification( + kind: .backgroundAgent, + displayName: "Backup", + projectHint: nil + ) + ) +} + +private func cronProcessFixture() -> ( + record: AutomationRecord, + classified: ClassifiedDevProcess, + snapshot: [DevProcess] +) { + let carrier = DevProcess( + pid: 55_101, + parentPID: 55_100, + executable: "/bin/sh", + command: "/bin/sh -c /usr/bin/true", + argumentVector: ["/bin/sh", "-c", "/usr/bin/true"], + resourceUsage: DevProcessResourceUsage( + cpuPercent: 0, + residentMemoryBytes: 1, + elapsedTime: "00:00:00" + ), + birthToken: ProcessBirthToken(seconds: 3_600, microseconds: 1) + ) + let daemon = DevProcess( + pid: 55_100, + parentPID: 1, + executable: "/usr/sbin/cron", + command: "/usr/sbin/cron", + argumentVector: ["/usr/sbin/cron"], + birthToken: ProcessBirthToken(seconds: 1, microseconds: 0) + ) + return ( + cronRecord(command: "/usr/bin/true", scheduleExpression: "* * * * *"), + ClassifiedDevProcess( + process: carrier, + classification: DevProcessClassification(kind: .shell, displayName: "cron", projectHint: nil) + ), + [daemon, carrier] + ) +} + +private func legacyLoginItemRecord() -> AutomationRecord { + let name = "Backup" + let path = "/Applications/Backup.app" + return AutomationRecord( + id: AutomationRecord.ID( + source: .legacyLoginItem, + ownerUID: 501, + label: name, + sourcePath: path + ), + kind: .loginItem, + sourceKind: .legacyLoginItem, + label: name, + displayName: name, + providerBundleIdentifier: nil, + ownerUID: 501, + ownership: .user, + executable: path, + arguments: [], + environment: [:], + workingDirectory: nil, + schedule: AutomationSchedule(triggers: [.atLogin], summary: "At login"), + sourceURL: URL(fileURLWithPath: "/Applications/Backup.app"), + sourceChecksum: nil, + enabledState: .disabled, + loadState: .unknown, + approvalState: .notApplicable, + state: .disabled, + evidence: [], + capabilities: [.enable, .disable], + validationFindings: [] + ) +} + +private func legacyRecoveryDescriptorData( + record: AutomationRecord, + isHidden: Bool, + ownerUID: uid_t = 501, + path: String? = nil, + recordID: String? = nil +) -> Data { + try! JSONSerialization.data(withJSONObject: [ + "version": 1, + "recordID": recordID ?? record.id.rawValue, + "ownerUID": Int(ownerUID), + "name": record.label, + "path": path ?? record.executable!, + "isHidden": isHidden, + ], options: [.sortedKeys]) +} + +private func verifiedRecoveryInput( + data: Data, + url: URL +) -> AutomationVerifiedRecoveryInput { + AutomationVerifiedRecoveryInput( + backupID: AutomationBackup.ID(rawValue: UUID()), + backupURL: url, + data: data, + checksum: SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + ) +} + +private struct StaticLegacyLoginItemListing: LegacyLoginItemListing { + let items: [LegacyLoginItemDescriptor] + + func currentUserLoginItems() async throws -> [LegacyLoginItemDescriptor] { items } +} + +private final class SequencedLiveIdentityResolver: @unchecked Sendable { + private let lock = NSLock() + private var values: [ProcessLiveIdentityResolution] + + init(values: [ProcessLiveIdentityResolution]) { self.values = values } + + func resolve(_ processID: Int32) -> ProcessLiveIdentityResolution { + lock.withLock { values.isEmpty ? .notRunning : values.removeFirst() } + } +} + +private final class RecordedSignals: @unchecked Sendable { + private let lock = NSLock() + private var recorded: [(Int32, Int32)] = [] + + var values: [(Int32, Int32)] { lock.withLock { recorded } } + + func send(_ processID: Int32, _ signal: Int32) { + lock.withLock { recorded.append((processID, signal)) } + } +} + +private func XCTAssertThrowsErrorAsync( + _ expression: () async throws -> Void, + file: StaticString = #filePath, + line: UInt = #line +) async { + do { + try await expression() + XCTFail("Expected an error", file: file, line: line) + } catch {} +} diff --git a/Tests/DevScopeCoreTests/AutomationInventoryServiceTests.swift b/Tests/DevScopeCoreTests/AutomationInventoryServiceTests.swift new file mode 100644 index 0000000..f975f21 --- /dev/null +++ b/Tests/DevScopeCoreTests/AutomationInventoryServiceTests.swift @@ -0,0 +1,785 @@ +import XCTest +@testable import DevScopeCore + +final class AutomationInventoryServiceTests: XCTestCase { + func testTimedOutSourceFailsIndependentlyAndPreservesHealthyRecords() async { + let hanging = CancellationAwareHangingAutomationSource(kind: .serviceManagement) + let healthy = FakeAutomationSource(snapshot: .healthy( + kind: .launchAgent, + records: [Fixtures.userAgent] + )) + let service = AutomationInventoryService( + sources: [hanging, healthy], + sourceTimeout: .milliseconds(20) + ) + + let snapshot = await service.refresh(force: true) + + XCTAssertEqual(snapshot.records.map(\.label), ["com.example.backup"]) + XCTAssertEqual(snapshot.health[.serviceManagement]?.state, .failed) + XCTAssertEqual( + snapshot.health[.serviceManagement]?.message, + "Automation source refresh exceeded its configured time limit." + ) + let observedCancellation = await hanging.waitForCancellation() + XCTAssertTrue(observedCancellation) + } + + func testTimedOutSourceReturnsEvenWhenSourceIgnoresCancellation() async { + let hanging = CancellationIgnoringAutomationSource(kind: .serviceManagement) + let healthy = FakeAutomationSource(snapshot: .healthy( + kind: .launchAgent, + records: [Fixtures.userAgent] + )) + let service = AutomationInventoryService( + sources: [hanging, healthy], + sourceTimeout: .milliseconds(20) + ) + let started = ContinuousClock.now + + let snapshot = await service.refresh(force: true) + let elapsed = ContinuousClock.now - started + await hanging.release() + + XCTAssertLessThan(elapsed, .seconds(1)) + XCTAssertEqual(snapshot.records.map(\.label), ["com.example.backup"]) + XCTAssertEqual(snapshot.health[.serviceManagement]?.state, .failed) + XCTAssertEqual( + snapshot.health[.serviceManagement]?.message, + "Automation source refresh exceeded its configured time limit." + ) + } + + func testForcedRefreshQuarantinesCancellationIgnoringSourceUntilItCompletes() async { + let hanging = CancellationIgnoringAutomationSource(kind: .serviceManagement) + let service = AutomationInventoryService( + sources: [hanging], + sourceTimeout: .milliseconds(20) + ) + + let timedOut = await service.refresh(force: true) + XCTAssertEqual( + timedOut.health[.serviceManagement]?.message, + "Automation source refresh exceeded its configured time limit." + ) + + for _ in 0..<4 { + let quarantined = await service.refresh(force: true) + XCTAssertEqual( + quarantined.health[.serviceManagement]?.message, + "A previous timed-out automation source refresh is still completing; a new call was not started." + ) + } + let blockedMetrics = await hanging.metrics() + XCTAssertEqual(blockedMetrics.invocations, 1) + XCTAssertEqual(blockedMetrics.active, 1) + XCTAssertEqual(blockedMetrics.maximumActive, 1) + + await hanging.release() + let becameIdle = await hanging.waitUntilIdle() + XCTAssertTrue(becameIdle) + + let recovered = await service.refresh(force: true) + let recoveredMetrics = await hanging.metrics() + XCTAssertEqual(recovered.health[.serviceManagement]?.state, .healthy) + XCTAssertEqual(recoveredMetrics.invocations, 2) + XCTAssertEqual(recoveredMetrics.maximumActive, 1) + } + + func testFailedBackgroundSourceDoesNotEraseHealthyLaunchdRecords() async { + let launchd = FakeAutomationSource(snapshot: .healthy( + kind: .launchAgent, + records: [Fixtures.userAgent] + )) + let background = FakeAutomationSource(snapshot: .failed( + kind: .serviceManagement, + message: "Background Task Management output unavailable" + )) + let service = AutomationInventoryService(sources: [launchd, background]) + + let snapshot = await service.refresh(force: true) + + XCTAssertEqual(snapshot.records.map(\.label), ["com.example.backup"]) + XCTAssertEqual(snapshot.health[.serviceManagement]?.state, .failed) + } + + func testExactPlistAndBackgroundIdentityDeduplicateToOneRecord() async { + let backgroundIdentity = copyRecord( + Fixtures.backgroundCopyOfUserAgent, + sourceURL: .some(nil) + ) + let service = AutomationInventoryService(sources: [ + FakeAutomationSource(snapshot: .healthy( + kind: .launchAgent, + records: [Fixtures.userAgent] + )), + FakeAutomationSource(snapshot: .healthy( + kind: .serviceManagement, + records: [backgroundIdentity] + )), + ]) + + let snapshot = await service.refresh(force: true) + + XCTAssertEqual(snapshot.records.count, 1) + XCTAssertEqual(snapshot.records[0].evidence.count, 2) + } + + func testBoundedRefreshIntervalReturnsCachedGenerationUntilBoundary() async { + let clock = ControllableAutomationClock(Date(timeIntervalSince1970: 10_000)) + let source = CountingAutomationSource(snapshot: .healthy( + kind: .launchAgent, + records: [Fixtures.userAgent] + )) + let service = AutomationInventoryService( + sources: [source], + minimumRefreshInterval: 60, + now: clock.now + ) + + let first = await service.refresh() + clock.advance(by: 59) + let cached = await service.refresh() + clock.advance(by: 1) + let refreshed = await service.refresh() + let invocationCount = await source.invocationCount() + + XCTAssertEqual(first.generation, 1) + XCTAssertEqual(cached, first) + XCTAssertEqual(refreshed.generation, 2) + XCTAssertEqual(invocationCount, 2) + } + + func testConcurrentRefreshesShareOneRealRefreshAndGeneration() async { + let source = CountingAutomationSource( + snapshot: .healthy(kind: .launchAgent, records: [Fixtures.userAgent]), + delayNanoseconds: 50_000_000 + ) + let service = AutomationInventoryService(sources: [source]) + + async let first = service.refresh(force: true) + async let second = service.refresh(force: true) + let snapshots = await [first, second] + let invocationCount = await source.invocationCount() + + XCTAssertEqual(snapshots.map(\.generation), [1, 1]) + XCTAssertEqual(snapshots[0], snapshots[1]) + XCTAssertEqual(invocationCount, 1) + } + + func testRefreshAfterCurrentStartsOneNewGenerationAfterPreexistingScan() async { + let source = GatedAutomationSource(snapshot: .healthy( + kind: .launchAgent, + records: [Fixtures.userAgent] + )) + let service = AutomationInventoryService(sources: [source]) + + let preexisting = Task { await service.refresh(force: true) } + await source.waitUntilInvocation(1) + let afterCurrent = Task { await service.refreshAfterCurrent() } + await Task.yield() + let countWhileBlocked = await source.invocationCount() + XCTAssertEqual(countWhileBlocked, 1) + + await source.releaseInvocation(1) + await source.waitUntilInvocation(2) + let preexistingSnapshot = await preexisting.value + XCTAssertEqual(preexistingSnapshot.generation, 1) + await source.releaseInvocation(2) + + let afterCurrentSnapshot = await afterCurrent.value + let finalCount = await source.invocationCount() + XCTAssertEqual(afterCurrentSnapshot.generation, 2) + XCTAssertEqual(finalCount, 2) + } + + func testDuplicateSourceKindsPreserveWorstHealthDeterministically() async { + let failed = AutomationSourceSnapshot.failed( + kind: .serviceManagement, + message: "Generic failure", + refreshedAt: Date(timeIntervalSince1970: 100) + ) + let healthy = AutomationSourceSnapshot.healthy( + kind: .serviceManagement, + records: [], + refreshedAt: Date(timeIntervalSince1970: 200) + ) + let service = AutomationInventoryService(sources: [ + FakeAutomationSource(snapshot: failed), + FakeAutomationSource(snapshot: healthy), + ]) + + let snapshot = await service.refresh(force: true) + + XCTAssertEqual(snapshot.health[.serviceManagement], failed.health) + } + + func testDisplayNameResemblanceNeverDeduplicatesDistinctRecords() async { + let lookalike = copyRecord( + Fixtures.backgroundCopyOfUserAgent, + label: "com.example.unrelated", + displayName: Fixtures.userAgent.displayName, + providerBundleIdentifier: "com.example.different-owner", + executable: "/bin/date", + sourceURL: URL(fileURLWithPath: "/tmp/devscope-fixtures/unrelated.plist") + ) + let service = AutomationInventoryService(sources: [ + FakeAutomationSource(snapshot: .healthy( + kind: .launchAgent, + records: [Fixtures.userAgent] + )), + FakeAutomationSource(snapshot: .healthy( + kind: .serviceManagement, + records: [lookalike] + )), + ]) + + let snapshot = await service.refresh(force: true) + + XCTAssertEqual(snapshot.records.count, 2) + } + + func testEqualRankRecordsWithDifferentCapabilitySetsMergeIdenticallyWhenReversed() async { + let editPrimary = copyRecord( + Fixtures.backgroundCopyOfUserAgent, + capabilities: [.edit], + evidence: [AutomationEvidence( + strength: .strong, + source: "edit evidence", + detail: "Exact synthetic identity" + )] + ) + let disablePrimary = copyRecord( + Fixtures.backgroundCopyOfUserAgent, + capabilities: [.disable], + evidence: [AutomationEvidence( + strength: .strong, + source: "disable evidence", + detail: "Exact synthetic identity" + )] + ) + let forward = AutomationInventoryService(sources: [ + FakeAutomationSource(snapshot: .healthy( + kind: .serviceManagement, + records: [editPrimary] + )), + FakeAutomationSource(snapshot: .healthy( + kind: .serviceManagement, + records: [disablePrimary] + )), + ]) + let reverse = AutomationInventoryService(sources: [ + FakeAutomationSource(snapshot: .healthy( + kind: .serviceManagement, + records: [disablePrimary] + )), + FakeAutomationSource(snapshot: .healthy( + kind: .serviceManagement, + records: [editPrimary] + )), + ]) + + let forwardRecords = await forward.refresh(force: true).records + let reverseRecords = await reverse.refresh(force: true).records + + XCTAssertEqual(forwardRecords, reverseRecords) + XCTAssertEqual(forwardRecords.count, 1) + XCTAssertEqual(forwardRecords[0].capabilities, [.disable]) + XCTAssertEqual( + forwardRecords[0].evidence.map(\.source), + ["disable evidence", "edit evidence"] + ) + } + + func testEqualComparatorKeysAttachAmbiguousBridgeDeterministicallyWhenReversed() async { + let tiedID = AutomationRecord.ID(rawValue: "synthetic-component-tie") + let firstAnchor = copyRecord( + Fixtures.backgroundCopyOfUserAgent, + id: tiedID, + label: "com.example.component-tie", + executable: .some(nil), + sourceURL: URL(fileURLWithPath: "/tmp/devscope-fixtures/component-a"), + capabilities: [.exportRecord], + evidence: [AutomationEvidence( + strength: .strong, + source: "first anchor evidence", + detail: "Conflicting synthetic source identity" + )] + ) + let secondAnchor = copyRecord( + Fixtures.backgroundCopyOfUserAgent, + id: tiedID, + label: "com.example.component-tie", + providerBundleIdentifier: .some(nil), + executable: "/tmp/devscope-fixtures/component-bridge", + sourceURL: URL(fileURLWithPath: "/tmp/devscope-fixtures/component-b"), + capabilities: [.exportRecord], + evidence: [AutomationEvidence( + strength: .strong, + source: "second anchor evidence", + detail: "Conflicting synthetic source identity" + )] + ) + let bridge = copyRecord( + Fixtures.backgroundCopyOfUserAgent, + id: tiedID, + label: "com.example.component-tie", + executable: "/tmp/devscope-fixtures/component-bridge", + sourceURL: .some(nil), + capabilities: [.exportRecord], + evidence: [AutomationEvidence( + strength: .strong, + source: "bridge evidence", + detail: "Compatible with either synthetic anchor alone" + )] + ) + let forward = AutomationInventoryService(sources: [ + FakeAutomationSource(snapshot: .healthy( + kind: .serviceManagement, + records: [firstAnchor, secondAnchor] + )), + FakeAutomationSource(snapshot: .healthy( + kind: .serviceManagement, + records: [bridge] + )), + ]) + let reverse = AutomationInventoryService(sources: [ + FakeAutomationSource(snapshot: .healthy( + kind: .serviceManagement, + records: [bridge] + )), + FakeAutomationSource(snapshot: .healthy( + kind: .serviceManagement, + records: [secondAnchor, firstAnchor] + )), + ]) + + let forwardRecords = await forward.refresh(force: true).records + let reverseRecords = await reverse.refresh(force: true).records + + XCTAssertEqual(forwardRecords.count, 2) + XCTAssertEqual(forwardRecords, reverseRecords) + XCTAssertEqual(forwardRecords.filter { record in + record.evidence.contains { $0.source == "bridge evidence" } + }.count, 1) + XCTAssertFalse(forwardRecords.contains { record in + let sources = Set(record.evidence.map(\.source)) + return sources.contains("first anchor evidence") + && sources.contains("second anchor evidence") + }) + } + + func testTransitiveIdentityBridgeRejectsConflictingBundleComponentsInEitherOrder() async { + let bridge = copyRecord( + Fixtures.backgroundCopyOfUserAgent, + label: "com.example.bridge", + executable: "/tmp/devscope-fixtures/bridge", + sourceURL: .some(nil), + ownership: .thirdPartySystem, + capabilities: [.disable], + evidence: [AutomationEvidence( + strength: .strong, + source: "bridge bundle evidence", + detail: "Exact bundle identity" + )] + ) + let pathCopy = copyRecord( + Fixtures.backgroundCopyOfUserAgent, + label: "com.example.bridge", + providerBundleIdentifier: "com.example.other-owner", + executable: "/tmp/devscope-fixtures/folder/../bridge", + sourceURL: .some(nil), + capabilities: [.edit], + evidence: [AutomationEvidence( + strength: .strong, + source: "bridge path evidence", + detail: "Exact label and canonical executable" + )] + ) + let bundleAnchor = copyRecord( + Fixtures.userAgent, + label: "com.example.bridge", + executable: "/tmp/devscope-fixtures/bridge", + sourceURL: .some(nil), + capabilities: [.startNow] + ) + let forward = AutomationInventoryService(sources: [ + FakeAutomationSource(snapshot: .healthy( + kind: .launchAgent, + records: [bundleAnchor] + )), + FakeAutomationSource(snapshot: .healthy( + kind: .serviceManagement, + records: [pathCopy, bridge] + )), + ]) + let reverse = AutomationInventoryService(sources: [ + FakeAutomationSource(snapshot: .healthy( + kind: .serviceManagement, + records: [bridge, pathCopy] + )), + FakeAutomationSource(snapshot: .healthy( + kind: .launchAgent, + records: [bundleAnchor] + )), + ]) + + let forwardRecords = await forward.refresh(force: true).records + let reverseRecords = await reverse.refresh(force: true).records + + XCTAssertEqual(forwardRecords.count, 2) + XCTAssertEqual(forwardRecords, reverseRecords) + let retainedAnchor = forwardRecords.first { + $0.providerBundleIdentifier == "com.example.devscope-fixture-owner" + } + let rejectedConflict = forwardRecords.first { + $0.providerBundleIdentifier == "com.example.other-owner" + } + XCTAssertEqual(retainedAnchor?.id, bundleAnchor.id) + XCTAssertEqual(retainedAnchor?.evidence.count, 2) + XCTAssertEqual(retainedAnchor?.capabilities, [.startNow]) + XCTAssertEqual(rejectedConflict?.id, pathCopy.id) + XCTAssertEqual(rejectedConflict?.evidence.count, 1) + XCTAssertEqual(rejectedConflict?.capabilities, [.edit]) + } + + func testExactBundleEdgeRejectsConflictingLabelExecutableIdentitiesInEitherOrder() async { + let first = copyRecord(Fixtures.userAgent, sourceURL: .some(nil)) + let second = copyRecord( + Fixtures.backgroundCopyOfUserAgent, + label: "com.example.conflicting-label", + executable: "/bin/date", + sourceURL: .some(nil) + ) + let forward = AutomationInventoryService(sources: [ + FakeAutomationSource(snapshot: .healthy( + kind: .serviceManagement, + records: [first, second] + )), + ]) + let reverse = AutomationInventoryService(sources: [ + FakeAutomationSource(snapshot: .healthy( + kind: .serviceManagement, + records: [second, first] + )), + ]) + + let forwardRecords = await forward.refresh(force: true).records + let reverseRecords = await reverse.refresh(force: true).records + + XCTAssertEqual(forwardRecords.count, 2) + XCTAssertEqual(forwardRecords, reverseRecords) + } + + func testExactBundleEdgeRejectsConflictingSourceReferencesInEitherOrder() async { + let first = Fixtures.userAgent + let second = Fixtures.backgroundCopyOfUserAgent + let forward = AutomationInventoryService(sources: [ + FakeAutomationSource(snapshot: .healthy( + kind: .serviceManagement, + records: [first, second] + )), + ]) + let reverse = AutomationInventoryService(sources: [ + FakeAutomationSource(snapshot: .healthy( + kind: .serviceManagement, + records: [second, first] + )), + ]) + + let forwardRecords = await forward.refresh(force: true).records + let reverseRecords = await reverse.refresh(force: true).records + + XCTAssertEqual(forwardRecords.count, 2) + XCTAssertEqual(forwardRecords, reverseRecords) + } + + func testCompatibleOrMissingStrongIdentitiesStillMergeTransitivelyInEitherOrder() async { + let bundleAnchor = copyRecord( + Fixtures.userAgent, + label: "com.example.bundle-anchor", + executable: .some(nil), + sourceURL: .some(nil), + capabilities: [.startNow] + ) + let bridge = copyRecord( + Fixtures.backgroundCopyOfUserAgent, + label: "com.example.compatible-bridge", + executable: "/tmp/devscope-fixtures/compatible-bridge", + sourceURL: .some(nil), + ownership: .thirdPartySystem, + capabilities: [.disable], + evidence: [AutomationEvidence( + strength: .strong, + source: "compatible bundle bridge", + detail: "Exact bundle identity" + )] + ) + let pathClosure = copyRecord( + Fixtures.backgroundCopyOfUserAgent, + label: "com.example.compatible-bridge", + providerBundleIdentifier: .some(nil), + executable: "/tmp/devscope-fixtures/folder/../compatible-bridge", + sourceURL: .some(nil), + capabilities: [.edit], + evidence: [AutomationEvidence( + strength: .strong, + source: "compatible path closure", + detail: "Exact label and canonical executable" + )] + ) + let forward = AutomationInventoryService(sources: [ + FakeAutomationSource(snapshot: .healthy(kind: .launchAgent, records: [bundleAnchor])), + FakeAutomationSource(snapshot: .healthy( + kind: .serviceManagement, + records: [pathClosure, bridge] + )), + ]) + let reverse = AutomationInventoryService(sources: [ + FakeAutomationSource(snapshot: .healthy( + kind: .serviceManagement, + records: [bridge, pathClosure] + )), + FakeAutomationSource(snapshot: .healthy(kind: .launchAgent, records: [bundleAnchor])), + ]) + + let forwardRecords = await forward.refresh(force: true).records + let reverseRecords = await reverse.refresh(force: true).records + + XCTAssertEqual(forwardRecords.count, 1) + XCTAssertEqual(forwardRecords, reverseRecords) + XCTAssertEqual(forwardRecords[0].id, bundleAnchor.id) + XCTAssertEqual(forwardRecords[0].evidence.count, 3) + XCTAssertEqual(forwardRecords[0].capabilities, [.startNow]) + } + + func testExactCanonicalSourceReferenceDeduplicatesIndependentOfInputOrder() async { + let sharedURL = URL(fileURLWithPath: "/tmp/devscope-fixtures/folder/../shared-source") + let first = copyRecord( + Fixtures.userAgent, + label: "com.example.first", + providerBundleIdentifier: .some(nil), + executable: .some(nil), + sourceURL: sharedURL + ) + let second = copyRecord( + Fixtures.backgroundCopyOfUserAgent, + label: "com.example.second", + providerBundleIdentifier: "com.example.second", + executable: .some(nil), + sourceURL: URL(fileURLWithPath: "/tmp/devscope-fixtures/shared-source") + ) + let forward = AutomationInventoryService(sources: [ + FakeAutomationSource(snapshot: .healthy( + kind: .serviceManagement, + records: [first, second] + )), + ]) + let reverse = AutomationInventoryService(sources: [ + FakeAutomationSource(snapshot: .healthy( + kind: .serviceManagement, + records: [second, first] + )), + ]) + + let forwardRecords = await forward.refresh(force: true).records + let reverseRecords = await reverse.refresh(force: true).records + + XCTAssertEqual(forwardRecords.count, 1) + XCTAssertEqual(forwardRecords, reverseRecords) + XCTAssertEqual(forwardRecords[0].id, first.id) + } + + func testForcedRefreshBypassesThrottleAndThenBecomesCachedGeneration() async { + let clock = ControllableAutomationClock(Date(timeIntervalSince1970: 20_000)) + let source = CountingAutomationSource(snapshot: .healthy( + kind: .launchAgent, + records: [Fixtures.userAgent] + )) + let service = AutomationInventoryService( + sources: [source], + minimumRefreshInterval: 60, + now: clock.now + ) + + let first = await service.refresh() + let forced = await service.refresh(force: true) + let cached = await service.refresh() + let invocationCount = await source.invocationCount() + + XCTAssertEqual(first.generation, 1) + XCTAssertEqual(forced.generation, 2) + XCTAssertEqual(cached, forced) + XCTAssertEqual(invocationCount, 2) + } + + func testCommandSignatureParticipatesInDeterministicPrimaryOrdering() async { + let alpha = copyRecord(Fixtures.userAgent, commandSignature: "alpha") + let omega = copyRecord(Fixtures.userAgent, commandSignature: "omega") + let forward = AutomationInventoryService(sources: [ + FakeAutomationSource(snapshot: .healthy(kind: .launchAgent, records: [omega, alpha])), + ]) + let reverse = AutomationInventoryService(sources: [ + FakeAutomationSource(snapshot: .healthy(kind: .launchAgent, records: [alpha, omega])), + ]) + + let forwardRecords = await forward.refresh(force: true).records + let reverseRecords = await reverse.refresh(force: true).records + + XCTAssertEqual(forwardRecords, reverseRecords) + XCTAssertEqual(forwardRecords.first?.commandSignature, "alpha") + } +} + +private actor CancellationAwareHangingAutomationSource: AutomationSource { + nonisolated let kind: AutomationSourceKind + private var wasCancelled = false + + init(kind: AutomationSourceKind) { + self.kind = kind + } + + func snapshot() async -> AutomationSourceSnapshot { + do { + try await Task.sleep(for: .seconds(30)) + } catch is CancellationError { + wasCancelled = true + } catch {} + return .healthy(kind: kind, records: []) + } + + func waitForCancellation() async -> Bool { + for _ in 0..<100 { + if wasCancelled { return true } + try? await Task.sleep(for: .milliseconds(5)) + } + return false + } +} + +private actor CancellationIgnoringAutomationSource: AutomationSource { + nonisolated let kind: AutomationSourceKind + private var isReleased = false + private var invocations = 0 + private var active = 0 + private var maximumActive = 0 + + init(kind: AutomationSourceKind) { + self.kind = kind + } + + func snapshot() async -> AutomationSourceSnapshot { + invocations += 1 + active += 1 + maximumActive = max(maximumActive, active) + defer { active -= 1 } + while !isReleased { + try? await Task.sleep(for: .milliseconds(5)) + await Task.yield() + } + return .healthy(kind: kind, records: []) + } + + func release() { + isReleased = true + } + + func metrics() -> (invocations: Int, active: Int, maximumActive: Int) { + (invocations, active, maximumActive) + } + + func waitUntilIdle() async -> Bool { + for _ in 0..<100 { + if active == 0 { return true } + try? await Task.sleep(for: .milliseconds(5)) + } + return false + } +} + +private actor GatedAutomationSource: AutomationSource { + nonisolated let kind: AutomationSourceKind + private let value: AutomationSourceSnapshot + private var count = 0 + private var invocationWaiters: [(Int, CheckedContinuation)] = [] + private var releaseWaiters: [Int: CheckedContinuation] = [:] + + init(snapshot: AutomationSourceSnapshot) { + kind = snapshot.health.kind + value = snapshot + } + + func snapshot() async -> AutomationSourceSnapshot { + count += 1 + let invocation = count + let ready = invocationWaiters.filter { $0.0 <= count } + invocationWaiters.removeAll { $0.0 <= count } + for (_, waiter) in ready { waiter.resume() } + await withCheckedContinuation { continuation in + releaseWaiters[invocation] = continuation + } + return value + } + + func waitUntilInvocation(_ target: Int) async { + guard count < target else { return } + await withCheckedContinuation { continuation in + invocationWaiters.append((target, continuation)) + } + } + + func releaseInvocation(_ invocation: Int) { + releaseWaiters.removeValue(forKey: invocation)?.resume() + } + + func invocationCount() -> Int { count } +} + +private func copyRecord( + _ record: AutomationRecord, + id: AutomationRecord.ID? = nil, + label: String? = nil, + displayName: String? = nil, + providerBundleIdentifier: String?? = nil, + executable: String?? = nil, + commandSignature: String?? = nil, + sourceURL: URL?? = nil, + ownership: AutomationOwnership? = nil, + capabilities: Set? = nil, + evidence: [AutomationEvidence]? = nil +) -> AutomationRecord { + let label = label ?? record.label + let bundleIdentifier = providerBundleIdentifier ?? record.providerBundleIdentifier + let executable = executable ?? record.executable + let sourceURL = sourceURL ?? record.sourceURL + return AutomationRecord( + id: id ?? AutomationRecord.ID( + source: record.sourceKind, + ownerUID: record.ownerUID ?? 0, + label: label, + sourcePath: sourceURL?.path ?? executable ?? label + ), + kind: record.kind, + sourceKind: record.sourceKind, + label: label, + displayName: displayName ?? record.displayName, + providerBundleIdentifier: bundleIdentifier, + ownerUID: record.ownerUID, + ownership: ownership ?? record.ownership, + executable: executable, + arguments: record.arguments, + commandSignature: commandSignature ?? record.commandSignature, + environment: record.environment, + workingDirectory: record.workingDirectory, + schedule: record.schedule, + sourceURL: sourceURL, + sourceChecksum: record.sourceChecksum, + enabledState: record.enabledState, + loadState: record.loadState, + approvalState: record.approvalState, + state: record.state, + evidence: evidence ?? record.evidence, + capabilities: capabilities ?? record.capabilities, + validationFindings: record.validationFindings + ) +} diff --git a/Tests/DevScopeCoreTests/AutomationManagerTests.swift b/Tests/DevScopeCoreTests/AutomationManagerTests.swift new file mode 100644 index 0000000..fd0de53 --- /dev/null +++ b/Tests/DevScopeCoreTests/AutomationManagerTests.swift @@ -0,0 +1,5092 @@ +import CryptoKit +import XCTest +@testable import DevScopeCore + +final class AutomationManagerTests: XCTestCase { + func testStartNowVerifiesAStrongFreshProcessLinkWithoutInventingInventoryRuntimeState() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let sourceData = validLaunchAgentData() + let checksum = sha256(sourceData) + let record = copyRecord( + Fixtures.userAgent, + sourceChecksum: checksum, + loadState: .loaded, + state: .idle + ) + let process = classifiedAutomationProcess( + pid: 62_001, + birth: ProcessBirthToken(seconds: 20_000, microseconds: 1) + ) + let snapshot = AutomationInventorySnapshot( + generation: 8, + records: [record], + health: [ + .launchAgent: AutomationSourceHealth( + kind: .launchAgent, + state: .healthy, + message: nil, + refreshedAt: Date(timeIntervalSince1970: 20_000) + ), + ], + refreshedAt: Date(timeIntervalSince1970: 20_000) + ) + let manager = makeManager( + fileSystem: InMemoryAutomationFileSystem(files: [sourceURL: sourceData]), + executor: RecordingAutomationMutationExecutor(results: [ + .success(AutomationExecutorResult( + postconditions: [.targetResolved, .currentRunStarted], + evidence: ["fixture started"] + )), + ]), + refresh: { snapshot }, + refreshProcesses: { [process.process] } + ) + + let result = await manager.perform( + .startNow, + record: record, + expectedChecksum: checksum, + linkedProcesses: [] + ) + + XCTAssertEqual(result.status, .succeeded) + XCTAssertTrue(result.verificationEvidence.contains( + "Fresh process truth confirms a strong link to the requested automation." + )) + } + + func testStaleChecksumBlocksBeforeBackupOrApply() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let sourceData = Data("original source".utf8) + let fileSystem = InMemoryAutomationFileSystem(files: [sourceURL: sourceData]) + let executor = RecordingAutomationMutationExecutor() + let manager = makeManager(fileSystem: fileSystem, executor: executor) + + let result = await manager.perform( + .disable, + record: Fixtures.userAgent, + expectedChecksum: "stale-checksum", + linkedProcesses: [] + ) + + XCTAssertEqual(result.status, .rejected("The automation source changed since it was inspected.")) + XCTAssertEqual(fileSystem.recordedOperations, []) + let invocationCount = await executor.invocationCount() + XCTAssertEqual(invocationCount, 0) + } + + func testWrongOwnerBlocksBeforeBackupOrApply() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let sourceData = Data("original source".utf8) + let fileSystem = InMemoryAutomationFileSystem( + files: [sourceURL: sourceData], + metadata: [ + sourceURL: AutomationFileMetadata( + canonicalURL: sourceURL, + ownerUID: 502, + isSymbolicLink: false, + modificationDate: Date(timeIntervalSince1970: 1_000), + resourceIdentifier: "wrong-owner-source" + ), + ] + ) + let executor = RecordingAutomationMutationExecutor() + let manager = makeManager( + fileSystem: fileSystem, + executor: executor, + context: .fixture(currentUID: 501, canonicalPathIsApproved: true, ownerUID: 502) + ) + + let result = await manager.perform( + .disable, + record: Fixtures.userAgent, + expectedChecksum: sha256(sourceData), + linkedProcesses: [] + ) + + XCTAssertEqual(result.status, .rejected("The automation source is not owned by the current user.")) + XCTAssertEqual(fileSystem.recordedOperations, []) + let invocationCount = await executor.invocationCount() + XCTAssertEqual(invocationCount, 0) + } + + func testSuccessfulDisableCreatesOwnerOnlyBackupAndVerifiesRefreshedState() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let sourceData = validLaunchAgentData() + let checksum = sha256(sourceData) + let disabledRecord = copyRecord( + Fixtures.userAgent, + sourceChecksum: checksum, + enabledState: .disabled, + state: .disabled + ) + let fileSystem = InMemoryAutomationFileSystem(files: [sourceURL: sourceData]) + let executor = RecordingAutomationMutationExecutor(results: [ + .success(AutomationExecutorResult( + postconditions: [.futureLaunchesDisabled], + evidence: ["launchctl confirmed disabled"] + )), + ]) + let manager = makeManager( + fileSystem: fileSystem, + executor: executor, + refresh: { snapshot(records: [disabledRecord]) } + ) + + let result = await manager.perform( + .disable, + record: Fixtures.userAgent, + expectedChecksum: checksum, + linkedProcesses: [] + ) + + XCTAssertEqual(result.status, .succeeded) + XCTAssertNotNil(result.backup) + XCTAssertEqual(result.rollback, .notNeeded) + XCTAssertEqual(result.verificationEvidence, [ + "Executor evidence redacted.", + "Refreshed inventory confirms future launches are disabled.", + ]) + XCTAssertTrue(fileSystem.recordedOperations.contains { + if case .createDirectory(_, permissions: 0o700) = $0 { return true } + return false + }) + XCTAssertTrue(fileSystem.recordedOperations.contains { + if case .writeTemporary(_, permissions: 0o600) = $0 { return true } + return false + }) + } + + func testBackupPostWriteCanonicalMetadataMustVerifyBeforePublication() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let sourceData = validLaunchAgentData() + let backupURL = URL(fileURLWithPath: + "/tmp/devscope-fixtures/recovery/11111111-2222-3333-4444-555555555555.backup" + ) + let fileSystem = InMemoryAutomationFileSystem( + files: [sourceURL: sourceData], + metadataAfterReplace: [backupURL: AutomationFileMetadata( + canonicalURL: URL(fileURLWithPath: "/tmp/outside-recovery/forged.backup"), + ownerUID: 501, + isSymbolicLink: false, + modificationDate: Date(timeIntervalSince1970: 1_000), + resourceIdentifier: "forged-backup", + permissions: 0o600 + )] + ) + let executor = RecordingAutomationMutationExecutor() + let manager = makeManager(fileSystem: fileSystem, executor: executor) + + let result = await manager.perform( + .disable, + record: Fixtures.userAgent, + expectedChecksum: sha256(sourceData), + linkedProcesses: [] + ) + + XCTAssertEqual( + result.status, + .failed("DevScope could not create owner-only recovery evidence.") + ) + XCTAssertTrue( + fileSystem.itemExists(at: backupURL), + "A post-write identity mismatch must not authorize deleting the replacement leaf." + ) + XCTAssertFalse(fileSystem.recordedOperations.contains { + if case .remove(let removedURL) = $0 { return removedURL == backupURL } + return false + }) + let invocationCount = await executor.invocationCount() + XCTAssertEqual(invocationCount, 0) + } + + func testExportReturnsRedactedArtifactWithoutSourceIOOrExecutorSideEffects() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let secret = "PRIVATE_TOKEN=do-not-export" + let sourceData = Data(secret.utf8) + let sourceChecksum = sha256(sourceData) + let record = AutomationRecord( + id: Fixtures.userAgent.id, + kind: Fixtures.userAgent.kind, + sourceKind: Fixtures.userAgent.sourceKind, + label: Fixtures.userAgent.label, + displayName: Fixtures.userAgent.displayName, + providerBundleIdentifier: nil, + ownerUID: 501, + ownership: .user, + executable: "/bin/sh", + arguments: ["-c", secret], + environment: ["TOKEN": secret], + workingDirectory: "/private/workspace", + schedule: Fixtures.userAgent.schedule, + sourceURL: sourceURL, + sourceChecksum: sourceChecksum, + enabledState: .enabled, + loadState: .loaded, + approvalState: .notApplicable, + state: .running, + evidence: [AutomationEvidence(strength: .strong, source: "test", detail: secret)], + capabilities: [.exportRecord], + validationFindings: [secret] + ) + let fileSystem = InMemoryAutomationFileSystem(files: [sourceURL: sourceData]) + let executor = RecordingAutomationMutationExecutor(results: [.failure(.applyFailed)]) + let manager = makeManager( + fileSystem: fileSystem, + executor: executor, + context: .fixture( + currentUID: 501, + canonicalPathIsApproved: true, + ownerUID: 501, + implementedCapabilities: [.exportRecord] + ) + ) + + let result = await manager.perform( + .exportRecord(redacted: true), + record: record, + expectedChecksum: record.sourceChecksum, + linkedProcesses: [] + ) + + XCTAssertEqual(result.status, .succeeded) + XCTAssertEqual(result.rollback, .notNeeded) + XCTAssertNil(result.backup) + let artifact = try! XCTUnwrap(result.exportArtifact) + XCTAssertTrue(artifact.isRedacted) + XCTAssertEqual(artifact.mediaType, "application/json") + XCTAssertFalse(String(decoding: artifact.data, as: UTF8.self).contains(secret)) + XCTAssertFalse(fileSystem.recordedOperations.contains { + if case .createDirectory = $0 { return true } + return false + }) + let invocationCount = await executor.invocationCount() + XCTAssertEqual(invocationCount, 0) + } + + func testUnredactedUserExportReturnsExactChecksumVerifiedSourceBytes() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let sourceData = validLaunchAgentData() + let checksum = sha256(sourceData) + let record = copyRecord(Fixtures.userAgent, sourceChecksum: checksum) + let executor = RecordingAutomationMutationExecutor(results: [.failure(.applyFailed)]) + let manager = makeManager(fileSystem: InMemoryAutomationFileSystem(files: [ + sourceURL: sourceData, + ]), executor: executor) + + let result = await manager.perform( + .exportRecord(redacted: false), + record: record, + expectedChecksum: checksum, + linkedProcesses: [] + ) + + XCTAssertEqual(result.status, .succeeded) + let artifact = try! XCTUnwrap(result.exportArtifact) + XCTAssertFalse(artifact.isRedacted) + XCTAssertEqual(artifact.format, "source.plist") + XCTAssertEqual(artifact.mediaType, "application/x-plist") + XCTAssertEqual(artifact.data, sourceData) + let invocationCount = await executor.invocationCount() + XCTAssertEqual(invocationCount, 0) + } + + func testProtectedRecordExportForcesRedactionAndNeedsNoReadableSource() async { + let secret = "--private-system-argument" + let record = AutomationRecord( + id: AutomationRecord.ID( + source: .serviceManagement, + ownerUID: 0, + label: "com.example.protected", + sourcePath: "bundle:com.example.protected" + ), + kind: .backgroundItem, + sourceKind: .serviceManagement, + label: "com.example.protected", + displayName: "Protected Background Item", + providerBundleIdentifier: "com.example.protected", + ownerUID: nil, + ownership: .thirdPartySystem, + executable: "/private/protected/helper", + arguments: [secret], + environment: ["SECRET": secret], + workingDirectory: nil, + schedule: AutomationSchedule(triggers: [.demand], summary: "On demand"), + sourceURL: nil, + sourceChecksum: "diagnostic-generation", + enabledState: .unknown, + loadState: .unknown, + approvalState: .unknown, + state: .unresolved, + evidence: [AutomationEvidence(strength: .strong, source: "diagnostic", detail: secret)], + capabilities: [.exportRecord], + validationFindings: [] + ) + let executor = RecordingAutomationMutationExecutor(results: [.failure(.applyFailed)]) + let manager = makeManager( + fileSystem: InMemoryAutomationFileSystem(files: [:]), + executor: executor, + context: .fixture( + currentUID: 501, + canonicalPathIsApproved: false, + ownerUID: nil, + implementedCapabilities: [.exportRecord] + ) + ) + + let result = await manager.perform( + .exportRecord(redacted: false), + record: record, + expectedChecksum: record.sourceChecksum, + linkedProcesses: [] + ) + + XCTAssertEqual(result.status, .succeeded) + let artifact = try! XCTUnwrap(result.exportArtifact) + XCTAssertTrue(artifact.isRedacted) + XCTAssertFalse(String(decoding: artifact.data, as: UTF8.self).contains(secret)) + let invocationCount = await executor.invocationCount() + XCTAssertEqual(invocationCount, 0) + } + + func testInvalidEditedSourceLeavesOriginalUnchangedAndNeverApplies() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let sourceData = validLaunchAgentData() + let checksum = sha256(sourceData) + let fileSystem = InMemoryAutomationFileSystem(files: [sourceURL: sourceData]) + let executor = RecordingAutomationMutationExecutor() + let manager = makeManager(fileSystem: fileSystem, executor: executor) + let payload = AutomationEditPayload( + label: Fixtures.userAgent.label, + executable: "/bin/sleep", + arguments: ["14400"], + environment: [:], + workingDirectory: nil, + schedule: Fixtures.userAgent.schedule, + rawRepresentation: Data("not a property list".utf8) + ) + + let result = await manager.perform( + .edit(payload), + record: Fixtures.userAgent, + expectedChecksum: checksum, + linkedProcesses: [] + ) + + XCTAssertEqual(result.status, .rejected("The launchd property list is not semantically valid.")) + XCTAssertEqual(fileSystem.storedData(at: sourceURL), sourceData) + let invocationCount = await executor.invocationCount() + XCTAssertEqual(invocationCount, 0) + } + + func testFailedApplyRestoresBackupAndPriorEnabledState() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let sourceData = validLaunchAgentData() + let checksum = sha256(sourceData) + let fileSystem = InMemoryAutomationFileSystem(files: [sourceURL: sourceData]) + let executor = RecordingAutomationMutationExecutor(results: [ + .failure(.applyFailed), + .success(AutomationExecutorResult( + postconditions: [.futureLaunchesEnabled], + evidence: ["prior state restored"] + )), + ]) + let manager = makeManager(fileSystem: fileSystem, executor: executor) + let editedData = validLaunchAgentData(arguments: ["7200"]) + let payload = AutomationEditPayload( + label: Fixtures.userAgent.label, + executable: "/bin/sleep", + arguments: ["7200"], + environment: [:], + workingDirectory: nil, + schedule: Fixtures.userAgent.schedule, + rawRepresentation: editedData + ) + + let result = await manager.perform( + .edit(payload), + record: Fixtures.userAgent, + expectedChecksum: checksum, + linkedProcesses: [] + ) + + XCTAssertEqual(result.status, .failed("The automation operation failed; the prior source and loaded state were restored.")) + XCTAssertEqual(result.rollback, .restored(try! XCTUnwrap(result.backup?.id))) + XCTAssertEqual(fileSystem.storedData(at: sourceURL), sourceData) + let operations = await executor.recordedOperations() + XCTAssertEqual(operations, [.edit(payload)]) + } + + func testPostInstallIdentityFailureRestoresOriginalBeforeExecutorRuns() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let sourceData = validLaunchAgentData() + let editedData = validLaunchAgentData(arguments: ["7200"]) + let fileSystem = InMemoryAutomationFileSystem( + files: [sourceURL: sourceData], + metadataFailuresAfterReplace: [sourceURL: 1] + ) + let executor = RecordingAutomationMutationExecutor() + let manager = makeManager(fileSystem: fileSystem, executor: executor) + let payload = AutomationEditPayload( + label: Fixtures.userAgent.label, + executable: "/bin/sleep", + arguments: ["7200"], + environment: [:], + workingDirectory: nil, + schedule: Fixtures.userAgent.schedule, + rawRepresentation: editedData + ) + + let result = await manager.perform( + .edit(payload), + record: Fixtures.userAgent, + expectedChecksum: sha256(sourceData), + linkedProcesses: [] + ) + + XCTAssertEqual( + result.status, + .failed("The automation operation failed; the prior source and loaded state were restored.") + ) + XCTAssertEqual(result.rollback, .restored(try! XCTUnwrap(result.backup?.id))) + XCTAssertEqual(fileSystem.storedData(at: sourceURL), sourceData) + XCTAssertFalse(result.appliedSteps.contains { $0.localizedCaseInsensitiveContains("cron") }) + let invocationCount = await executor.invocationCount() + XCTAssertEqual(invocationCount, 0) + } + + func testPostInstallByteMismatchNeverReachesExecutor() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let sourceData = validLaunchAgentData() + let editedData = validLaunchAgentData(arguments: ["7200"]) + let unreviewedData = validLaunchAgentData(arguments: ["attacker"]) + let fileSystem = InMemoryAutomationFileSystem( + files: [sourceURL: sourceData], + dataAfterReplace: [sourceURL: unreviewedData] + ) + let executor = RecordingAutomationMutationExecutor() + let manager = makeManager(fileSystem: fileSystem, executor: executor) + let payload = AutomationEditPayload( + label: Fixtures.userAgent.label, + executable: "/bin/sleep", + arguments: ["7200"], + environment: [:], + workingDirectory: nil, + schedule: Fixtures.userAgent.schedule, + rawRepresentation: editedData + ) + + let result = await manager.perform( + .edit(payload), + record: Fixtures.userAgent, + expectedChecksum: sha256(sourceData), + linkedProcesses: [] + ) + + XCTAssertEqual( + result.status, + .partialFailure("HIGH SEVERITY: the operation failed and automatic rollback did not complete.") + ) + XCTAssertEqual(fileSystem.storedData(at: sourceURL), unreviewedData) + let invocationCount = await executor.invocationCount() + XCTAssertEqual(invocationCount, 0) + } + + func testPartialReplacePreservesRecoveryEvidenceAndNeverInvokesExecutor() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let sourceData = validLaunchAgentData() + let editedData = validLaunchAgentData(arguments: ["7200"]) + let fileSystem = InMemoryAutomationFileSystem( + files: [sourceURL: sourceData], + partialReplaceAfterCommit: [sourceURL] + ) + let executor = RecordingAutomationMutationExecutor() + let manager = makeManager(fileSystem: fileSystem, executor: executor) + let payload = AutomationEditPayload( + label: Fixtures.userAgent.label, + executable: "/bin/sleep", + arguments: ["7200"], + environment: [:], + workingDirectory: nil, + schedule: Fixtures.userAgent.schedule, + rawRepresentation: editedData + ) + + let result = await manager.perform( + .edit(payload), + record: Fixtures.userAgent, + expectedChecksum: sha256(sourceData), + linkedProcesses: [] + ) + + XCTAssertNotNil(result.fileMutationEvidence) + XCTAssertNotNil(result.fileMutationEvidence?.recoveryHandle) + XCTAssertEqual(result.fileMutationEvidence?.commitState, .committed) + let invocationCount = await executor.invocationCount() + XCTAssertEqual(invocationCount, 0) + } + + func testPartialTrashMovePreservesRecoveryEvidenceAndNeverInvokesExecutor() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let sourceData = validLaunchAgentData() + let fileSystem = InMemoryAutomationFileSystem( + files: [sourceURL: sourceData], + partialTrashAfterCommit: [sourceURL] + ) + let executor = RecordingAutomationMutationExecutor() + let manager = makeManager(fileSystem: fileSystem, executor: executor) + + let result = await manager.perform( + .remove, + record: Fixtures.userAgent, + expectedChecksum: sha256(sourceData), + linkedProcesses: [] + ) + + XCTAssertNotNil(result.fileMutationEvidence) + XCTAssertNotNil(result.fileMutationEvidence?.recoveryHandle) + XCTAssertEqual(result.fileMutationEvidence?.kind, .trash) + XCTAssertEqual(fileSystem.storedData(at: sourceURL), sourceData) + let invocationCount = await executor.invocationCount() + XCTAssertEqual(invocationCount, 0) + } + + func testPartialBackupInstallIsReportedWithRecoveryHandleBeforeSourceMutation() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let sourceData = validLaunchAgentData() + let fileSystem = InMemoryAutomationFileSystem( + files: [sourceURL: sourceData], + partialReplaceAfterCommit: [deterministicFirstBackupURL()] + ) + let executor = RecordingAutomationMutationExecutor() + let refreshCount = LockedCallCounter() + let manager = makeManager( + fileSystem: fileSystem, + executor: executor, + refresh: { + _ = refreshCount.incrementAndGet() + return Fixtures.inventoryGeneration7 + } + ) + + let result = await manager.perform( + .disable, + record: Fixtures.userAgent, + expectedChecksum: sha256(sourceData), + linkedProcesses: [] + ) + + XCTAssertEqual( + result.status, + .partialFailure("HIGH SEVERITY: recovery evidence installation was only partially completed.") + ) + XCTAssertNotNil(result.fileMutationEvidence?.recoveryHandle) + XCTAssertEqual(refreshCount.current(), 1) + let invocationCount = await executor.invocationCount() + XCTAssertEqual(invocationCount, 0) + } + + func testUnchangedBackupReplacementCleansExactStagedFileAndRemainsOrdinary() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let sourceData = validLaunchAgentData() + let backupURL = deterministicFirstBackupURL() + let fileSystem = InMemoryAutomationFileSystem( + files: [sourceURL: sourceData], + unchangedReplaceOccurrences: [backupURL: [1]] + ) + let manager = makeManager( + fileSystem: fileSystem, + executor: RecordingAutomationMutationExecutor() + ) + + let result = await manager.perform( + .disable, + record: Fixtures.userAgent, + expectedChecksum: sha256(sourceData), + linkedProcesses: [] + ) + + XCTAssertEqual( + result.status, + .failed("DevScope could not create owner-only recovery evidence.") + ) + XCTAssertNil(result.fileMutationEvidence) + XCTAssertNil(fileSystem.storedData(at: backupURL)) + let stagedURLs = fileSystem.recordedOperations.compactMap { operation -> URL? in + guard case .writeTemporary(let url, _) = operation else { return nil } + return url + } + XCTAssertEqual(stagedURLs.count, 1) + XCTAssertFalse(fileSystem.itemExists(at: try! XCTUnwrap(stagedURLs.first))) + } + + func testUnchangedReplacementCleanupFailureSurfacesExactStagedRecoveryHandle() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let sourceData = validLaunchAgentData() + let backupURL = deterministicFirstBackupURL() + let fileSystem = InMemoryAutomationFileSystem( + files: [sourceURL: sourceData], + unchangedReplaceOccurrences: [backupURL: [1]], + unchangedReplaceRetainsStaged: true, + failStagedRemoves: true + ) + let manager = makeManager( + fileSystem: fileSystem, + executor: RecordingAutomationMutationExecutor() + ) + + let result = await manager.perform( + .disable, + record: Fixtures.userAgent, + expectedChecksum: sha256(sourceData), + linkedProcesses: [] + ) + + guard case .partialFailure = result.status else { + return XCTFail("Expected partial failure, got \(result.status)") + } + let handle = try! XCTUnwrap(result.fileMutationEvidence?.recoveryHandle) + XCTAssertTrue(handle.fileURL.lastPathComponent.hasPrefix(".devscope-fixture-")) + XCTAssertTrue(fileSystem.itemExists(at: handle.fileURL)) + XCTAssertEqual(fileSystem.storedData(at: handle.fileURL), sourceData) + } + + func testPartialManifestInstallIsReportedWithRecoveryHandleBeforeSourceMutation() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let sourceData = validLaunchAgentData() + let fileSystem = InMemoryAutomationFileSystem( + files: [sourceURL: sourceData], + partialReplaceAfterCommit: [deterministicFirstManifestURL()] + ) + let executor = RecordingAutomationMutationExecutor() + let refreshCount = LockedCallCounter() + let manager = makeManager( + fileSystem: fileSystem, + executor: executor, + refresh: { + _ = refreshCount.incrementAndGet() + return Fixtures.inventoryGeneration7 + } + ) + + let result = await manager.perform( + .disable, + record: Fixtures.userAgent, + expectedChecksum: sha256(sourceData), + linkedProcesses: [] + ) + + XCTAssertEqual( + result.status, + .partialFailure("HIGH SEVERITY: recovery evidence installation was only partially completed.") + ) + XCTAssertEqual(result.fileMutationEvidence?.resultURL, deterministicFirstManifestURL()) + XCTAssertNil(fileSystem.storedData(at: deterministicFirstManifestURL())) + XCTAssertNil(fileSystem.storedData(at: deterministicFirstBackupURL())) + XCTAssertTrue(result.fileMutationEvidence?.recoveryHandles.isEmpty == true) + XCTAssertEqual(refreshCount.current(), 1) + let invocationCount = await executor.invocationCount() + XCTAssertEqual(invocationCount, 0) + } + + func testObservedNonRecoveryCollisionIsReportedButNeverDeletedOrPromoted() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let sourceData = validLaunchAgentData() + let manifestURL = deterministicFirstManifestURL() + let collisionURL = manifestURL.deletingLastPathComponent() + .appendingPathComponent("attacker.keep") + let collisionData = Data("attacker-owned".utf8) + let fileSystem = InMemoryAutomationFileSystem( + files: [ + sourceURL: sourceData, + collisionURL: collisionData, + ], + partialReplaceAfterCommit: [manifestURL], + partialReplaceObservedOnly: [manifestURL: [collisionURL]] + ) + let manager = makeManager( + fileSystem: fileSystem, + executor: RecordingAutomationMutationExecutor() + ) + + let result = await manager.perform( + .disable, + record: Fixtures.userAgent, + expectedChecksum: sha256(sourceData), + linkedProcesses: [] + ) + + guard case .partialFailure = result.status else { + return XCTFail("Expected partial failure, got \(result.status)") + } + let evidence = try! XCTUnwrap(result.fileMutationEvidence) + XCTAssertEqual(fileSystem.storedData(at: collisionURL), collisionData) + XCTAssertTrue(evidence.observedFiles.contains { $0.fileURL == collisionURL }) + XCTAssertFalse(evidence.recoveryHandles.contains { $0.fileURL == collisionURL }) + XCTAssertNil(fileSystem.storedData(at: manifestURL)) + XCTAssertNil(fileSystem.storedData(at: deterministicFirstBackupURL())) + } + + func testManifestFailureCannotHidePartialBackupCleanup() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let sourceData = validLaunchAgentData() + let fileSystem = InMemoryAutomationFileSystem( + files: [sourceURL: sourceData], + dataAfterReplace: [deterministicFirstManifestURL(): Data("corrupt".utf8)], + partialRemoveAfterCommit: [deterministicFirstBackupURL()] + ) + let executor = RecordingAutomationMutationExecutor() + let refreshCount = LockedCallCounter() + let manager = makeManager( + fileSystem: fileSystem, + executor: executor, + refresh: { + _ = refreshCount.incrementAndGet() + return Fixtures.inventoryGeneration7 + } + ) + + let result = await manager.perform( + .disable, + record: Fixtures.userAgent, + expectedChecksum: sha256(sourceData), + linkedProcesses: [] + ) + + guard case .partialFailure = result.status else { + return XCTFail("Expected partial failure, got \(result.status)") + } + XCTAssertEqual(result.fileMutationEvidence?.kind, .remove) + XCTAssertNotNil(result.fileMutationEvidence?.recoveryHandle) + XCTAssertEqual(refreshCount.current(), 1) + let invocationCount = await executor.invocationCount() + XCTAssertEqual(invocationCount, 0) + } + + func testCorruptCommittedManifestIsRemovedWithItsBackupBeforeOrdinaryFailure() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let sourceData = validLaunchAgentData() + let backupURL = deterministicFirstBackupURL() + let manifestURL = deterministicFirstManifestURL() + let fileSystem = InMemoryAutomationFileSystem( + files: [sourceURL: sourceData], + dataAfterReplace: [manifestURL: Data("corrupt".utf8)] + ) + let executor = RecordingAutomationMutationExecutor() + let manager = makeManager(fileSystem: fileSystem, executor: executor) + + let result = await manager.perform( + .disable, + record: Fixtures.userAgent, + expectedChecksum: sha256(sourceData), + linkedProcesses: [] + ) + + XCTAssertEqual( + result.status, + .failed("DevScope could not create owner-only recovery evidence.") + ) + XCTAssertNil(fileSystem.storedData(at: backupURL)) + XCTAssertNil(fileSystem.storedData(at: manifestURL)) + let restarted = makeManager( + fileSystem: fileSystem, + executor: RecordingAutomationMutationExecutor() + ) + let manifests = await restarted.restorationManifests() + XCTAssertTrue(manifests.isEmpty) + let invocationCount = await executor.invocationCount() + XCTAssertEqual(invocationCount, 0) + } + + func testVerifiedUnchangedBackupCleanupRemainsAnOrdinaryFailure() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let sourceData = validLaunchAgentData() + let backupURL = deterministicFirstBackupURL() + let fileSystem = InMemoryAutomationFileSystem( + files: [sourceURL: sourceData], + dataAfterReplace: [backupURL: Data("corrupt".utf8)], + unchangedRemoves: [backupURL] + ) + let executor = RecordingAutomationMutationExecutor() + let manager = makeManager(fileSystem: fileSystem, executor: executor) + + let result = await manager.perform( + .disable, + record: Fixtures.userAgent, + expectedChecksum: sha256(sourceData), + linkedProcesses: [] + ) + + XCTAssertEqual( + result.status, + .failed("DevScope could not create owner-only recovery evidence.") + ) + XCTAssertNil(result.fileMutationEvidence) + XCTAssertEqual(fileSystem.storedData(at: backupURL), Data("corrupt".utf8)) + let invocationCount = await executor.invocationCount() + XCTAssertEqual(invocationCount, 0) + } + + func testRawBackupCleanupFailureBeforeMutationRemainsOrdinary() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let sourceData = validLaunchAgentData() + let backupURL = deterministicFirstBackupURL() + let fileSystem = InMemoryAutomationFileSystem( + files: [sourceURL: sourceData], + dataAfterReplace: [backupURL: Data("corrupt".utf8)], + failingRemoves: [backupURL] + ) + let manager = makeManager( + fileSystem: fileSystem, + executor: RecordingAutomationMutationExecutor() + ) + + let result = await manager.perform( + .disable, + record: Fixtures.userAgent, + expectedChecksum: sha256(sourceData), + linkedProcesses: [] + ) + + XCTAssertEqual( + result.status, + .failed("DevScope could not create owner-only recovery evidence.") + ) + XCTAssertNil(result.fileMutationEvidence) + XCTAssertEqual(fileSystem.storedData(at: backupURL), Data("corrupt".utf8)) + } + + func testVerifiedUnchangedCleanupIsPartialAfterAnotherArtifactWasDeleted() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let sourceData = validLaunchAgentData() + let backupURL = deterministicFirstBackupURL() + let manifestURL = deterministicFirstManifestURL() + let fileSystem = InMemoryAutomationFileSystem( + files: [sourceURL: sourceData], + dataAfterReplace: [manifestURL: Data("corrupt".utf8)], + unchangedRemoves: [backupURL] + ) + let executor = RecordingAutomationMutationExecutor() + let manager = makeManager(fileSystem: fileSystem, executor: executor) + + let result = await manager.perform( + .disable, + record: Fixtures.userAgent, + expectedChecksum: sha256(sourceData), + linkedProcesses: [] + ) + + XCTAssertEqual( + result.status, + .partialFailure("HIGH SEVERITY: recovery evidence installation was only partially completed.") + ) + XCTAssertNil(fileSystem.storedData(at: manifestURL)) + XCTAssertNotNil(fileSystem.storedData(at: backupURL)) + XCTAssertTrue(result.fileMutationEvidence?.recoveryHandles.contains { + $0.fileURL == backupURL + } == true) + let invocationCount = await executor.invocationCount() + XCTAssertEqual(invocationCount, 0) + } + + func testFailedVerificationRestoresAndReappliesPriorState() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let sourceData = validLaunchAgentData() + let checksum = sha256(sourceData) + let fileSystem = InMemoryAutomationFileSystem(files: [sourceURL: sourceData]) + let executor = RecordingAutomationMutationExecutor(results: [ + .success(AutomationExecutorResult( + postconditions: [.futureLaunchesDisabled], + evidence: ["disable command returned success"] + )), + .success(AutomationExecutorResult( + postconditions: [.futureLaunchesEnabled], + evidence: ["prior state restored"] + )), + ]) + let manager = makeManager( + fileSystem: fileSystem, + executor: executor, + refresh: { snapshot(records: [Fixtures.userAgent]) } + ) + + let result = await manager.perform( + .disable, + record: Fixtures.userAgent, + expectedChecksum: checksum, + linkedProcesses: [] + ) + + XCTAssertEqual(result.status, .failed("The automation operation failed; the prior source and loaded state were restored.")) + XCTAssertEqual(result.rollback, .restored(try! XCTUnwrap(result.backup?.id))) + let operations = await executor.recordedOperations() + XCTAssertEqual(operations, [.disable]) + } + + func testFailedRollbackReportsHighSeverityPartialFailure() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let sourceData = validLaunchAgentData() + let checksum = sha256(sourceData) + let fileSystem = InMemoryAutomationFileSystem(files: [sourceURL: sourceData]) + let executor = RecordingAutomationMutationExecutor(results: [ + .failure(.applyFailed), + ], restorationFails: true) + let manager = makeManager(fileSystem: fileSystem, executor: executor) + + let result = await manager.perform( + .disable, + record: Fixtures.userAgent, + expectedChecksum: checksum, + linkedProcesses: [] + ) + + XCTAssertEqual( + result.status, + .partialFailure("HIGH SEVERITY: the operation failed and automatic rollback did not complete.") + ) + XCTAssertEqual(result.rollback, .failed("Automatic rollback did not complete.")) + XCTAssertEqual( + result.manualRecovery, + "Use DevScope Restore with the recorded recovery identifier before retrying." + ) + XCTAssertNotEqual(result.status, .succeeded) + } + + func testConcurrentRequestsAreSerializedAcrossExecutorAwait() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let sourceData = validLaunchAgentData() + let checksum = sha256(sourceData) + let disabledRecord = copyRecord( + Fixtures.userAgent, + sourceChecksum: checksum, + enabledState: .disabled, + state: .disabled + ) + let fileSystem = InMemoryAutomationFileSystem(files: [sourceURL: sourceData]) + let success = AutomationExecutorResult( + postconditions: [.futureLaunchesDisabled], + evidence: [] + ) + let executor = RecordingAutomationMutationExecutor( + results: [.success(success), .success(success)], + delayNanoseconds: 50_000_000 + ) + let manager = makeManager( + fileSystem: fileSystem, + executor: executor, + refresh: { snapshot(records: [disabledRecord]) } + ) + + async let first = manager.perform( + .disable, + record: Fixtures.userAgent, + expectedChecksum: checksum, + linkedProcesses: [] + ) + async let second = manager.perform( + .disable, + record: Fixtures.userAgent, + expectedChecksum: checksum, + linkedProcesses: [] + ) + let results = await [first, second] + + XCTAssertTrue(results.allSatisfy { $0.status == .succeeded }) + let maximumConcurrency = await executor.maximumConcurrency() + XCTAssertEqual(maximumConcurrency, 1) + } + + func testRemoveMovesSourceToTrashBeforeApplyingTypedRemoval() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let sourceData = validLaunchAgentData() + let checksum = sha256(sourceData) + let fileSystem = InMemoryAutomationFileSystem(files: [sourceURL: sourceData]) + let executor = RecordingAutomationMutationExecutor(results: [ + .success(AutomationExecutorResult( + postconditions: [.sourceRemoved, .targetUnresolved], + evidence: ["service unloaded"] + )), + ]) + let manager = makeManager( + fileSystem: fileSystem, + executor: executor, + refresh: { snapshot(records: []) } + ) + + let result = await manager.perform( + .remove, + record: Fixtures.userAgent, + expectedChecksum: checksum, + linkedProcesses: [] + ) + + XCTAssertEqual(result.status, .succeeded) + XCTAssertNil(fileSystem.storedData(at: sourceURL)) + XCTAssertTrue(fileSystem.recordedOperations.contains { + if case .moveToTrash(let removedURL, _) = $0 { return removedURL == sourceURL } + return false + }) + } + + func testRemoveRejectsSourceRecreatedAtSameCanonicalPath() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let sourceData = validLaunchAgentData() + let recreatedData = validLaunchAgentData(label: "com.example.recreated") + let fileSystem = InMemoryAutomationFileSystem(files: [sourceURL: sourceData]) + let executor = RecordingAutomationMutationExecutor( + results: [.success(AutomationExecutorResult( + postconditions: [.sourceRemoved, .targetUnresolved], + evidence: [] + ))], + onApply: { fileSystem.setStoredData(recreatedData, at: sourceURL) } + ) + let recreated = copyRecord( + Fixtures.userAgent, + id: AutomationRecord.ID( + source: .launchAgent, + ownerUID: 501, + label: "com.example.recreated", + sourcePath: sourceURL.path + ), + label: "com.example.recreated", + sourceChecksum: sha256(recreatedData) + ) + let manager = makeManager( + fileSystem: fileSystem, + executor: executor, + refresh: { snapshot(records: [recreated]) } + ) + + let result = await manager.perform( + .remove, + record: Fixtures.userAgent, + expectedChecksum: sha256(sourceData), + linkedProcesses: [] + ) + + XCTAssertNotEqual(result.status, .succeeded) + XCTAssertEqual(fileSystem.storedData(at: sourceURL), recreatedData) + XCTAssertTrue(result.verificationEvidence.contains { $0.contains("transaction identity") }) + } + + func testRestoreUsesBackupFirstWhenRemovedSourceNoLongerExists() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let sourceData = validLaunchAgentData() + let checksum = sha256(sourceData) + let fileSystem = InMemoryAutomationFileSystem(files: [sourceURL: sourceData]) + let refreshCount = LockedCallCounter() + let restoredRecord = copyRecord(Fixtures.userAgent, sourceChecksum: checksum) + let manager = makeManager( + fileSystem: fileSystem, + executor: RecordingAutomationMutationExecutor(results: [ + .success(AutomationExecutorResult( + postconditions: [.sourceRemoved, .targetUnresolved], + evidence: [] + )), + .success(AutomationExecutorResult( + postconditions: [.sourceInstalled, .sourceChecksum(checksum)], + evidence: [] + )), + ]), + refresh: { + refreshCount.incrementAndGet() == 1 + ? snapshot(records: []) + : snapshot(records: [restoredRecord]) + } + ) + let removed = await manager.perform( + .remove, + record: Fixtures.userAgent, + expectedChecksum: checksum, + linkedProcesses: [] + ) + let backupID = try! XCTUnwrap(removed.backup?.id) + XCTAssertNil(fileSystem.storedData(at: sourceURL)) + + let restored = await manager.perform( + .restore(backupID), + record: Fixtures.userAgent, + expectedChecksum: nil, + linkedProcesses: [] + ) + + XCTAssertEqual(restored.status, .succeeded) + XCTAssertEqual(fileSystem.storedData(at: sourceURL), sourceData) + } + + func testRestoreRejectsExternallyEditedExistingDestinationWithoutMutation() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let sourceData = validLaunchAgentData() + let externalData = validLaunchAgentData(arguments: ["external"]) + let checksum = sha256(sourceData) + let disabled = copyRecord( + Fixtures.userAgent, + sourceChecksum: checksum, + enabledState: .disabled, + state: .disabled + ) + let fileSystem = InMemoryAutomationFileSystem(files: [sourceURL: sourceData]) + let executor = RecordingAutomationMutationExecutor(results: [ + .success(AutomationExecutorResult(postconditions: [.futureLaunchesDisabled], evidence: [])), + ]) + let manager = makeManager( + fileSystem: fileSystem, + executor: executor, + refresh: { snapshot(records: [disabled]) } + ) + let first = await manager.perform( + .disable, + record: Fixtures.userAgent, + expectedChecksum: checksum, + linkedProcesses: [] + ) + let backupID = try! XCTUnwrap(first.backup?.id) + fileSystem.setStoredData(externalData, at: sourceURL) + + let restored = await manager.perform( + .restore(backupID), + record: disabled, + expectedChecksum: checksum, + linkedProcesses: [] + ) + + XCTAssertEqual( + restored.status, + .rejected("The restore destination changed since it was inspected.") + ) + XCTAssertEqual(fileSystem.storedData(at: sourceURL), externalData) + let invocationCount = await executor.invocationCount() + XCTAssertEqual(invocationCount, 1) + } + + func testFailedRestoreIntoAbsentDestinationRollsBackToAbsenceNormally() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let sourceData = validLaunchAgentData() + let checksum = sha256(sourceData) + let fileSystem = InMemoryAutomationFileSystem(files: [sourceURL: sourceData]) + let refreshCount = LockedCallCounter() + let executor = RecordingAutomationMutationExecutor(results: [ + .success(AutomationExecutorResult( + postconditions: [.sourceRemoved, .targetUnresolved], + evidence: [] + )), + .failure(.applyFailed), + ]) + let manager = makeManager( + fileSystem: fileSystem, + executor: executor, + refresh: { + _ = refreshCount.incrementAndGet() + return snapshot(records: []) + } + ) + let removed = await manager.perform( + .remove, + record: Fixtures.userAgent, + expectedChecksum: checksum, + linkedProcesses: [] + ) + let backupID = try! XCTUnwrap(removed.backup?.id) + + let restored = await manager.perform( + .restore(backupID), + record: Fixtures.userAgent, + expectedChecksum: nil, + linkedProcesses: [] + ) + + XCTAssertEqual( + restored.status, + .failed("The automation operation failed; the prior absent source state was restored.") + ) + XCTAssertNotNil({ if case .restored = restored.rollback { return true }; return nil }()) + XCTAssertNil(fileSystem.storedData(at: sourceURL)) + let restorationStates = await executor.recordedRestorationStates() + XCTAssertTrue(restorationStates.isEmpty) + } + + func testManualRestoreOfAbsentBackupRemovesInstalledDestination() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let destination = sourceURL.deletingLastPathComponent() + .appendingPathComponent("duplicate.plist") + let sourceData = validLaunchAgentData() + let duplicateData = validLaunchAgentData(label: "com.example.duplicate") + let duplicateID = AutomationRecord.ID( + source: .launchAgent, + ownerUID: 501, + label: "com.example.duplicate", + sourcePath: destination.path + ) + let duplicateRecord = copyRecord( + Fixtures.userAgent, + id: duplicateID, + label: "com.example.duplicate", + sourceURL: destination, + sourceChecksum: sha256(duplicateData), + enabledState: .disabled, + loadState: .unloaded + ) + let refreshCount = LockedCallCounter() + let fileSystem = InMemoryAutomationFileSystem(files: [sourceURL: sourceData]) + let manager = makeManager( + fileSystem: fileSystem, + executor: RecordingAutomationMutationExecutor(results: [ + .success(AutomationExecutorResult( + postconditions: [ + .sourceInstalled, + .sourceChecksum(sha256(duplicateData)), + .futureLaunchesDisabled, + ], + evidence: [] + )), + .success(AutomationExecutorResult( + postconditions: [.sourceRemoved, .targetUnresolved], + evidence: [] + )), + ]), + refresh: { + refreshCount.incrementAndGet() == 1 + ? snapshot(records: [duplicateRecord]) + : snapshot(records: []) + } + ) + let duplicate = await manager.perform( + .duplicate(AutomationEditPayload( + label: "com.example.duplicate", + executable: "/bin/sleep", + arguments: ["14400"], + environment: [:], + workingDirectory: nil, + schedule: Fixtures.userAgent.schedule, + rawRepresentation: duplicateData, + destination: destination + )), + record: Fixtures.userAgent, + expectedChecksum: sha256(sourceData), + linkedProcesses: [] + ) + let backup = try! XCTUnwrap(duplicate.backup) + XCTAssertFalse(backup.sourceExisted) + XCTAssertEqual(fileSystem.storedData(at: destination), duplicateData) + + let restored = await manager.perform( + .restore(backup.id), + record: duplicateRecord, + expectedChecksum: sha256(duplicateData), + linkedProcesses: [] + ) + + XCTAssertEqual(restored.status, .succeeded) + XCTAssertNil(fileSystem.storedData(at: destination)) + } + + func testLaunchdDuplicateFailsAndRollsBackUnlessTheCopyIsDisabledAndUnloaded() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let destination = sourceURL.deletingLastPathComponent() + .appendingPathComponent("enabled-duplicate.plist") + let sourceData = validLaunchAgentData() + let duplicateData = validLaunchAgentData(label: "com.example.enabled-duplicate") + let duplicateRecord = copyRecord( + Fixtures.userAgent, + id: AutomationRecord.ID( + source: .launchAgent, + ownerUID: 501, + label: "com.example.enabled-duplicate", + sourcePath: destination.path + ), + label: "com.example.enabled-duplicate", + sourceURL: destination, + sourceChecksum: sha256(duplicateData), + enabledState: .enabled, + loadState: .unloaded + ) + let fileSystem = InMemoryAutomationFileSystem(files: [sourceURL: sourceData]) + let manager = makeManager( + fileSystem: fileSystem, + executor: RecordingAutomationMutationExecutor(results: [ + .success(AutomationExecutorResult( + postconditions: [ + .sourceInstalled, + .sourceChecksum(sha256(duplicateData)), + .futureLaunchesDisabled, + ], + evidence: [] + )), + ]), + refresh: { snapshot(records: [duplicateRecord]) } + ) + + let result = await manager.perform( + .duplicate(AutomationEditPayload( + label: duplicateRecord.label, + executable: "/bin/sleep", + arguments: ["14400"], + environment: [:], + workingDirectory: nil, + schedule: Fixtures.userAgent.schedule, + rawRepresentation: duplicateData, + destination: destination + )), + record: Fixtures.userAgent, + expectedChecksum: sha256(sourceData), + linkedProcesses: [] + ) + + XCTAssertNotEqual(result.status, .succeeded) + XCTAssertNil(fileSystem.storedData(at: destination)) + } + + func testPartialRemovalWhileRestoringAbsenceIsReportedAndSkipsExecutor() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let destination = sourceURL.deletingLastPathComponent() + .appendingPathComponent("partial-duplicate.plist") + let sourceData = validLaunchAgentData() + let duplicateData = validLaunchAgentData(label: "com.example.partial-duplicate") + let duplicateRecord = copyRecord( + Fixtures.userAgent, + id: AutomationRecord.ID( + source: .launchAgent, + ownerUID: 501, + label: "com.example.partial-duplicate", + sourcePath: destination.path + ), + label: "com.example.partial-duplicate", + sourceURL: destination, + sourceChecksum: sha256(duplicateData), + enabledState: .disabled, + loadState: .unloaded + ) + let executor = RecordingAutomationMutationExecutor(results: [ + .success(AutomationExecutorResult( + postconditions: [ + .sourceInstalled, + .sourceChecksum(sha256(duplicateData)), + .futureLaunchesDisabled, + ], + evidence: [] + )), + ]) + let fileSystem = InMemoryAutomationFileSystem( + files: [sourceURL: sourceData], + partialRemoveAfterCommit: [destination] + ) + let manager = makeManager( + fileSystem: fileSystem, + executor: executor, + refresh: { snapshot(records: [duplicateRecord]) } + ) + let duplicate = await manager.perform( + .duplicate(AutomationEditPayload( + label: duplicateRecord.label, + executable: "/bin/sleep", + arguments: ["14400"], + environment: [:], + workingDirectory: nil, + schedule: Fixtures.userAgent.schedule, + rawRepresentation: duplicateData, + destination: destination + )), + record: Fixtures.userAgent, + expectedChecksum: sha256(sourceData), + linkedProcesses: [] + ) + + let restored = await manager.perform( + .restore(try! XCTUnwrap(duplicate.backup?.id)), + record: duplicateRecord, + expectedChecksum: sha256(duplicateData), + linkedProcesses: [] + ) + + guard case .partialFailure = restored.status else { + return XCTFail("Expected partial failure, got \(restored.status)") + } + XCTAssertEqual(restored.fileMutationEvidence?.kind, .remove) + XCTAssertNotNil(restored.fileMutationEvidence?.recoveryHandle) + XCTAssertEqual(fileSystem.storedData(at: destination), duplicateData) + let invocationCount = await executor.invocationCount() + XCTAssertEqual(invocationCount, 1) + } + + func testRestoreAcceptsVerifiedRenamedSuccessorButRejectsUnrelatedRecord() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let originalData = validLaunchAgentData() + let renamedLabel = "com.example.renamed" + let renamedData = validLaunchAgentData(label: renamedLabel) + let originalChecksum = sha256(originalData) + let renamedChecksum = sha256(renamedData) + let renamedID = AutomationRecord.ID( + source: .launchAgent, + ownerUID: 501, + label: renamedLabel, + sourcePath: sourceURL.path + ) + let renamedRecord = copyRecord( + Fixtures.userAgent, + id: renamedID, + label: renamedLabel, + sourceChecksum: renamedChecksum + ) + let restoredRecord = copyRecord(Fixtures.userAgent, sourceChecksum: originalChecksum) + let refreshCount = LockedCallCounter() + let fileSystem = InMemoryAutomationFileSystem(files: [sourceURL: originalData]) + let manager = makeManager( + fileSystem: fileSystem, + executor: RecordingAutomationMutationExecutor(results: [ + .success(AutomationExecutorResult( + postconditions: [.sourceInstalled, .sourceChecksum(renamedChecksum)], evidence: [] + )), + .success(AutomationExecutorResult( + postconditions: [.sourceInstalled, .sourceChecksum(originalChecksum)], evidence: [] + )), + ]), + refresh: { + refreshCount.incrementAndGet() == 1 + ? snapshot(records: [renamedRecord]) + : snapshot(records: [restoredRecord]) + } + ) + let renamed = await manager.perform( + .edit(AutomationEditPayload( + label: renamedLabel, + executable: "/bin/sleep", + arguments: ["14400"], + environment: [:], + workingDirectory: nil, + schedule: Fixtures.userAgent.schedule, + rawRepresentation: renamedData + )), + record: Fixtures.userAgent, + expectedChecksum: originalChecksum, + linkedProcesses: [] + ) + let backupID = try! XCTUnwrap(renamed.backup?.id) + + let restored = await manager.perform( + .restore(backupID), + record: renamedRecord, + expectedChecksum: renamedChecksum, + linkedProcesses: [] + ) + XCTAssertEqual(restored.status, .succeeded) + XCTAssertEqual(fileSystem.storedData(at: sourceURL), originalData) + + let unrelated = copyRecord( + renamedRecord, + id: AutomationRecord.ID(rawValue: "unrelated-record") + ) + let rejected = await manager.perform( + .restore(backupID), record: unrelated, expectedChecksum: originalChecksum, linkedProcesses: [] + ) + XCTAssertEqual( + rejected.status, + .rejected("The selected recovery backup does not belong to this automation.") + ) + } + + func testImportRejectsExistingWrongOwnerDestinationBeforeBackup() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let destination = sourceURL.deletingLastPathComponent().appendingPathComponent("import.plist") + let sourceData = validLaunchAgentData() + let importedData = validLaunchAgentData(label: "com.example.imported") + let fileSystem = InMemoryAutomationFileSystem( + files: [sourceURL: sourceData, destination: importedData], + metadata: [ + destination: AutomationFileMetadata( + canonicalURL: destination, + ownerUID: 0, + isSymbolicLink: false, + modificationDate: Date(timeIntervalSince1970: 1_000), + resourceIdentifier: "root-owned-destination" + ), + ] + ) + let executor = RecordingAutomationMutationExecutor() + let manager = makeManager( + fileSystem: fileSystem, + executor: executor, + destinationContext: { _, _ in + .fixture(currentUID: 501, canonicalPathIsApproved: true, ownerUID: 0) + } + ) + let operation = AutomationOperation.importRecord(AutomationImportPayload( + destination: destination, + data: importedData, + expectedKind: .launchAgent, + expectedDestinationChecksum: sha256(importedData) + )) + + let result = await manager.perform( + operation, + record: Fixtures.userAgent, + expectedChecksum: sha256(sourceData), + linkedProcesses: [] + ) + + XCTAssertEqual(result.status, .rejected("The intended destination is not owned by the current user.")) + XCTAssertEqual(fileSystem.recordedOperations, []) + } + + func testResultRedactsOperationPayloadAndExecutorEvidence() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let sourceData = validLaunchAgentData() + let checksum = sha256(sourceData) + let secret = "TOP-SECRET-TOKEN" + let editedData = validLaunchAgentData( + label: Fixtures.userAgent.label, + arguments: [secret], + environment: ["TOKEN": secret], + workingDirectory: "/tmp/\(secret)" + ) + let editedChecksum = sha256(editedData) + let editedRecord = copyRecord( + Fixtures.userAgent, + sourceChecksum: editedChecksum, + enabledState: .enabled, + state: .idle + ) + let fileSystem = InMemoryAutomationFileSystem(files: [sourceURL: sourceData]) + let executor = RecordingAutomationMutationExecutor(results: [ + .success(AutomationExecutorResult( + postconditions: [.sourceInstalled, .sourceChecksum(editedChecksum)], + evidence: ["executor saw \(secret)"] + )), + ]) + let manager = makeManager( + fileSystem: fileSystem, + executor: executor, + refresh: { snapshot(records: [editedRecord]) } + ) + let operation = AutomationOperation.edit(AutomationEditPayload( + label: Fixtures.userAgent.label, + executable: "/bin/sleep", + arguments: [secret], + environment: ["TOKEN": secret], + workingDirectory: "/tmp/\(secret)", + schedule: Fixtures.userAgent.schedule, + rawRepresentation: editedData + )) + + let result = await manager.perform( + operation, + record: Fixtures.userAgent, + expectedChecksum: checksum, + linkedProcesses: [] + ) + + XCTAssertEqual(result.status, .succeeded) + XCTAssertFalse(String(describing: result).contains(secret)) + } + + func testImportCannotVerifyAgainstUnchangedOriginalRecord() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let destination = sourceURL.deletingLastPathComponent().appendingPathComponent("imported.plist") + let sourceData = validLaunchAgentData() + let importedData = validLaunchAgentData(label: "com.example.imported") + let importedChecksum = sha256(importedData) + let fileSystem = InMemoryAutomationFileSystem(files: [sourceURL: sourceData]) + let executor = RecordingAutomationMutationExecutor(results: [ + .success(AutomationExecutorResult( + postconditions: [.sourceInstalled, .sourceChecksum(importedChecksum)], + evidence: [] + )), + ]) + let manager = makeManager( + fileSystem: fileSystem, + executor: executor, + refresh: { snapshot(records: [Fixtures.userAgent]) } + ) + + let result = await manager.perform( + .importRecord(AutomationImportPayload( + destination: destination, + data: importedData, + expectedKind: .launchAgent + )), + record: Fixtures.userAgent, + expectedChecksum: sha256(sourceData), + linkedProcesses: [] + ) + + XCTAssertNotEqual(result.status, .succeeded) + XCTAssertNil(fileSystem.storedData(at: destination)) + guard let backupID = result.backup?.id else { + return XCTFail("Expected recovery backup, got \(result.status)") + } + XCTAssertEqual(result.rollback, .restored(backupID)) + } + + func testLabelChangingEditVerifiesNewIdentityAtSameDestination() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let sourceData = validLaunchAgentData() + let newLabel = "com.example.renamed" + let editedData = validLaunchAgentData(label: newLabel) + let editedChecksum = sha256(editedData) + let newID = AutomationRecord.ID( + source: .launchAgent, + ownerUID: 501, + label: newLabel, + sourcePath: sourceURL.path + ) + let editedRecord = copyRecord( + Fixtures.userAgent, + id: newID, + label: newLabel, + sourceURL: sourceURL, + sourceChecksum: editedChecksum + ) + let fileSystem = InMemoryAutomationFileSystem(files: [sourceURL: sourceData]) + let executor = RecordingAutomationMutationExecutor(results: [ + .success(AutomationExecutorResult( + postconditions: [.sourceInstalled, .sourceChecksum(editedChecksum)], + evidence: [] + )), + ]) + let manager = makeManager( + fileSystem: fileSystem, + executor: executor, + refresh: { snapshot(records: [editedRecord]) } + ) + let payload = AutomationEditPayload( + label: newLabel, + executable: "/bin/sleep", + arguments: ["14400"], + environment: [:], + workingDirectory: nil, + schedule: Fixtures.userAgent.schedule, + rawRepresentation: editedData + ) + + let result = await manager.perform( + .edit(payload), + record: Fixtures.userAgent, + expectedChecksum: sha256(sourceData), + linkedProcesses: [] + ) + + XCTAssertEqual(result.status, .succeeded) + } + + func testPartialSuccessorManifestReplacementPreservesEvidenceThroughSourceRollback() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let sourceData = validLaunchAgentData() + let newLabel = "com.example.partial-successor" + let editedData = validLaunchAgentData(label: newLabel) + let editedChecksum = sha256(editedData) + let editedRecord = copyRecord( + Fixtures.userAgent, + id: AutomationRecord.ID( + source: .launchAgent, + ownerUID: 501, + label: newLabel, + sourcePath: sourceURL.path + ), + label: newLabel, + sourceURL: sourceURL, + sourceChecksum: editedChecksum + ) + let disabledRecord = copyRecord( + Fixtures.userAgent, + sourceChecksum: sha256(sourceData), + enabledState: .disabled, + state: .disabled + ) + let manifestURL = deterministicFirstManifestURL() + let fileSystem = InMemoryAutomationFileSystem( + files: [sourceURL: sourceData], + partialReplaceAfterCommitOccurrences: [manifestURL: [2]] + ) + let preState = AutomationPreTransactionState( + sourceExisted: true, + enabledState: Fixtures.userAgent.enabledState, + loadState: Fixtures.userAgent.loadState, + linkedProcesses: [] + ) + let executor = RecordingAutomationMutationExecutor(results: [ + .success(AutomationExecutorResult( + postconditions: [.sourceInstalled, .sourceChecksum(editedChecksum)], + evidence: [] + )), + ] + Array(repeating: .success(AutomationExecutorResult( + postconditions: [.futureLaunchesDisabled], + evidence: [] + )), count: 20)) + let refreshCount = LockedCallCounter() + let manager = makeManager( + fileSystem: fileSystem, + executor: executor, + refresh: { + switch refreshCount.incrementAndGet() { + case 1: snapshot(records: [editedRecord]) + case 2: snapshot(records: [Fixtures.userAgent]) + default: snapshot(records: [disabledRecord]) + } + } + ) + let result = await manager.perform( + .edit(AutomationEditPayload( + label: newLabel, + executable: "/bin/sleep", + arguments: ["14400"], + environment: [:], + workingDirectory: nil, + schedule: Fixtures.userAgent.schedule, + rawRepresentation: editedData + )), + record: Fixtures.userAgent, + expectedChecksum: sha256(sourceData), + linkedProcesses: [] + ) + + XCTAssertEqual( + result.status, + .partialFailure( + "HIGH SEVERITY: the filesystem reported a partial mutation; automatic rollback restored the prior state." + ) + ) + XCTAssertEqual(result.rollback, .restored(try! XCTUnwrap(result.backup?.id))) + XCTAssertEqual(result.fileMutationEvidence?.kind, .replace) + XCTAssertNotNil(result.fileMutationEvidence?.recoveryHandle) + XCTAssertNil(result.rollbackFileMutationEvidence) + XCTAssertEqual(refreshCount.current(), 2) + XCTAssertEqual(fileSystem.storedData(at: sourceURL), sourceData) + let invocationCount = await executor.invocationCount() + XCTAssertEqual(invocationCount, 1) + let restorationStates = await executor.recordedRestorationStates() + XCTAssertEqual(restorationStates, [preState]) + + let restoredBackupID = try! XCTUnwrap(result.backup?.id) + let restored = await manager.restorationManifests() + XCTAssertTrue(restored.contains { $0.id == restoredBackupID }) + await assertLiveRecoveryInventoryMatchesRestartAfterRetentionChurn( + manager: manager, + fileSystem: fileSystem, + expectedChecksum: sha256(sourceData) + ) + } + + func testSuccessorCompensationRetentionSurvivesLaterCleanupPartial() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let sourceData = validLaunchAgentData() + let sourceChecksum = sha256(sourceData) + let newLabel = "com.example.successor-cleanup-partial" + let editedData = validLaunchAgentData(label: newLabel) + let editedChecksum = sha256(editedData) + let editedRecord = copyRecord( + Fixtures.userAgent, + id: AutomationRecord.ID( + source: .launchAgent, + ownerUID: 501, + label: newLabel, + sourcePath: sourceURL.path + ), + label: newLabel, + sourceURL: sourceURL, + sourceChecksum: editedChecksum + ) + let disabledRecord = copyRecord( + Fixtures.userAgent, + sourceChecksum: sourceChecksum, + enabledState: .disabled, + state: .disabled + ) + let manifestURL = deterministicFirstManifestURL() + let fileSystem = InMemoryAutomationFileSystem( + files: [sourceURL: sourceData], + partialReplaceAfterCommitOccurrences: [manifestURL: [2]], + partialReplaceRetainsStagedOccurrences: [manifestURL: [2]], + failStagedRemoves: true + ) + let executor = RecordingAutomationMutationExecutor(results: [ + .success(AutomationExecutorResult( + postconditions: [.sourceInstalled, .sourceChecksum(editedChecksum)], + evidence: [] + )), + ] + Array(repeating: .success(AutomationExecutorResult( + postconditions: [.futureLaunchesDisabled], + evidence: [] + )), count: 20)) + let refreshCount = LockedCallCounter() + let manager = makeManager( + fileSystem: fileSystem, + executor: executor, + refresh: { + switch refreshCount.incrementAndGet() { + case 1: snapshot(records: [editedRecord]) + case 2: snapshot(records: [Fixtures.userAgent]) + default: snapshot(records: [disabledRecord]) + } + } + ) + + let result = await manager.perform( + .edit(AutomationEditPayload( + label: newLabel, + executable: "/bin/sleep", + arguments: ["14400"], + environment: [:], + workingDirectory: nil, + schedule: Fixtures.userAgent.schedule, + rawRepresentation: editedData + )), + record: Fixtures.userAgent, + expectedChecksum: sourceChecksum, + linkedProcesses: [] + ) + + guard case .partialFailure = result.status else { + return XCTFail("Expected partial failure, got \(result.status)") + } + let evidence = try! XCTUnwrap(result.fileMutationEvidence) + let stagedRecovery = try! XCTUnwrap(evidence.recoveryHandles.first { + $0.fileURL.lastPathComponent.hasSuffix(".tmp") + }) + XCTAssertTrue(fileSystem.itemExists(at: stagedRecovery.fileURL)) + let restoredBackupID = try! XCTUnwrap(result.backup?.id) + let restored = await manager.restorationManifests() + XCTAssertTrue(restored.contains { $0.id == restoredBackupID }) + + await assertLiveRecoveryInventoryMatchesRestartAfterRetentionChurn( + manager: manager, + fileSystem: fileSystem, + expectedChecksum: sourceChecksum + ) + } + + private func assertLiveRecoveryInventoryMatchesRestartAfterRetentionChurn( + manager: AutomationManager, + fileSystem: InMemoryAutomationFileSystem, + expectedChecksum: String + ) async { + for _ in 0..<20 { + let succeeded = await manager.perform( + .disable, + record: Fixtures.userAgent, + expectedChecksum: expectedChecksum, + linkedProcesses: [] + ) + XCTAssertEqual(succeeded.status, .succeeded) + } + let live = await manager.restorationManifests() + let restarted = makeManager( + fileSystem: fileSystem, + executor: RecordingAutomationMutationExecutor() + ) + let reloaded = await restarted.restorationManifests() + XCTAssertEqual(live, reloaded) + XCTAssertEqual(live.count, 20) + } + + func testSuccessorRefusesFreshManifestOccupantAndReportsItAsObservationOnly() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let sourceData = validLaunchAgentData() + let newLabel = "com.example.fresh-successor-collision" + let editedData = validLaunchAgentData(label: newLabel) + let editedChecksum = sha256(editedData) + let editedRecord = copyRecord( + Fixtures.userAgent, + id: AutomationRecord.ID( + source: .launchAgent, + ownerUID: 501, + label: newLabel, + sourcePath: sourceURL.path + ), + label: newLabel, + sourceURL: sourceURL, + sourceChecksum: editedChecksum + ) + let manifestURL = deterministicFirstManifestURL() + let fileSystem = InMemoryAutomationFileSystem(files: [sourceURL: sourceData]) + let preState = AutomationPreTransactionState( + sourceExisted: true, + enabledState: Fixtures.userAgent.enabledState, + loadState: Fixtures.userAgent.loadState, + linkedProcesses: [] + ) + let executor = RecordingAutomationMutationExecutor(results: [ + .success(AutomationExecutorResult( + postconditions: [.sourceInstalled, .sourceChecksum(editedChecksum)], evidence: [] + )), + .success(AutomationExecutorResult( + postconditions: [.preTransactionStateRestored(preState)], evidence: [] + )), + ]) + let refreshCount = LockedCallCounter() + let manager = makeManager( + fileSystem: fileSystem, + executor: executor, + refresh: { + let call = refreshCount.incrementAndGet() + if call == 1 { + let validPriorManifest = try! fileSystem.read(manifestURL) + fileSystem.setStoredData(validPriorManifest, at: manifestURL) + fileSystem.setMetadata(AutomationFileMetadata( + canonicalURL: manifestURL, + ownerUID: 501, + isSymbolicLink: false, + modificationDate: Date(timeIntervalSince1970: 9_999), + resourceIdentifier: "fixture:fresh-manifest-occupant", + permissions: 0o600 + ), for: manifestURL) + } + return call == 1 + ? snapshot(records: [editedRecord]) + : snapshot(records: [Fixtures.userAgent]) + } + ) + + let result = await manager.perform( + .edit(AutomationEditPayload( + label: newLabel, + executable: "/bin/sleep", + arguments: ["14400"], + environment: [:], + workingDirectory: nil, + schedule: Fixtures.userAgent.schedule, + rawRepresentation: editedData + )), + record: Fixtures.userAgent, + expectedChecksum: sha256(sourceData), + linkedProcesses: [] + ) + + XCTAssertEqual( + try! fileSystem.metadata(for: manifestURL).resourceIdentifier, + "fixture:fresh-manifest-occupant" + ) + let evidence = try! XCTUnwrap(result.fileMutationEvidence) + XCTAssertTrue(evidence.observedFiles.contains { $0.fileURL == manifestURL }) + XCTAssertFalse(evidence.recoveryHandles.contains { $0.fileURL == manifestURL }) + XCTAssertEqual(result.rollback, .restored(try! XCTUnwrap(result.backup?.id))) + let live = await manager.restorationManifests() + let restarted = makeManager( + fileSystem: fileSystem, + executor: RecordingAutomationMutationExecutor() + ) + let reloaded = await restarted.restorationManifests() + XCTAssertTrue(live.isEmpty) + XCTAssertEqual(reloaded.count, 1) + XCTAssertEqual(reloaded.first?.id, result.backup?.id) + XCTAssertEqual(fileSystem.recordedOperations.filter { operation in + guard case .replace(let destination, _) = operation else { return false } + return destination == manifestURL + }.count, 1) + } + + func testSuccessorRefusesMissingManifestWithoutRecreatingItFromAmbientState() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let sourceData = validLaunchAgentData() + let newLabel = "com.example.missing-successor-manifest" + let editedData = validLaunchAgentData(label: newLabel) + let editedChecksum = sha256(editedData) + let editedRecord = copyRecord( + Fixtures.userAgent, + id: AutomationRecord.ID( + source: .launchAgent, + ownerUID: 501, + label: newLabel, + sourcePath: sourceURL.path + ), + label: newLabel, + sourceURL: sourceURL, + sourceChecksum: editedChecksum + ) + let manifestURL = deterministicFirstManifestURL() + let fileSystem = InMemoryAutomationFileSystem(files: [sourceURL: sourceData]) + let preState = AutomationPreTransactionState( + sourceExisted: true, + enabledState: Fixtures.userAgent.enabledState, + loadState: Fixtures.userAgent.loadState, + linkedProcesses: [] + ) + let executor = RecordingAutomationMutationExecutor(results: [ + .success(AutomationExecutorResult( + postconditions: [.sourceInstalled, .sourceChecksum(editedChecksum)], evidence: [] + )), + .success(AutomationExecutorResult( + postconditions: [.preTransactionStateRestored(preState)], evidence: [] + )), + ]) + let refreshCount = LockedCallCounter() + let manager = makeManager( + fileSystem: fileSystem, + executor: executor, + refresh: { + let call = refreshCount.incrementAndGet() + if call == 1 { + let rootURL = manifestURL.deletingLastPathComponent() + let root = try! XCTUnwrap(AutomationDirectoryAuthorization( + directoryURL: rootURL, + resourceIdentifier: try! fileSystem.metadata(for: rootURL).resourceIdentifier + )) + let manifest = try! XCTUnwrap(AutomationFileAuthorization( + fileURL: manifestURL, + directory: root, + expectation: .existing(resourceIdentifier: try! XCTUnwrap( + try! fileSystem.metadata(for: manifestURL).resourceIdentifier + )) + )) + _ = try! fileSystem.removeItem(manifest) + } + return call == 1 ? snapshot(records: [editedRecord]) : snapshot(records: [Fixtures.userAgent]) + } + ) + + let result = await manager.perform( + .edit(AutomationEditPayload( + label: newLabel, + executable: "/bin/sleep", + arguments: ["14400"], + environment: [:], + workingDirectory: nil, + schedule: Fixtures.userAgent.schedule, + rawRepresentation: editedData + )), + record: Fixtures.userAgent, + expectedChecksum: sha256(sourceData), + linkedProcesses: [] + ) + + XCTAssertFalse(fileSystem.itemExists(at: manifestURL)) + let evidence = try! XCTUnwrap(result.fileMutationEvidence) + XCTAssertFalse(evidence.observedFiles.contains { $0.fileURL == manifestURL }) + XCTAssertFalse(evidence.recoveryHandles.contains { $0.fileURL == manifestURL }) + XCTAssertEqual(result.rollback, .restored(try! XCTUnwrap(result.backup?.id))) + let live = await manager.restorationManifests() + let restarted = makeManager( + fileSystem: fileSystem, + executor: RecordingAutomationMutationExecutor() + ) + let reloaded = await restarted.restorationManifests() + XCTAssertEqual(live, reloaded) + XCTAssertTrue(live.isEmpty) + } + + func testSuccessorDoesNotPromoteFreshValidManifestAndBackupPairDuringLiveReconcile() async { + await assertValidRecoveryAuthorityReplacementIsQuarantined(replaceRoot: false) + } + + func testSuccessorDoesNotPromoteRecoveryPairFromReplacedRootDuringLiveReconcile() async { + await assertValidRecoveryAuthorityReplacementIsQuarantined(replaceRoot: true) + } + + private func assertValidRecoveryAuthorityReplacementIsQuarantined( + replaceRoot: Bool + ) async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let sourceData = validLaunchAgentData() + let newLabel = replaceRoot + ? "com.example.replaced-recovery-root" : "com.example.fresh-recovery-pair" + let editedData = validLaunchAgentData(label: newLabel) + let editedChecksum = sha256(editedData) + let editedRecord = copyRecord( + Fixtures.userAgent, + id: AutomationRecord.ID( + source: .launchAgent, + ownerUID: 501, + label: newLabel, + sourcePath: sourceURL.path + ), + label: newLabel, + sourceURL: sourceURL, + sourceChecksum: editedChecksum + ) + let manifestURL = deterministicFirstManifestURL() + let backupURL = deterministicFirstBackupURL() + let recoveryRoot = manifestURL.deletingLastPathComponent() + let fileSystem = InMemoryAutomationFileSystem(files: [sourceURL: sourceData]) + let executor = RecordingAutomationMutationExecutor(results: [ + .success(AutomationExecutorResult( + postconditions: [.sourceInstalled, .sourceChecksum(editedChecksum)], evidence: [] + )), + ]) + let refreshCount = LockedCallCounter() + let manager = makeManager( + fileSystem: fileSystem, + executor: executor, + refresh: { + let call = refreshCount.incrementAndGet() + if call == 1 { + if replaceRoot { + fileSystem.setMetadata(AutomationFileMetadata( + canonicalURL: recoveryRoot, + ownerUID: 501, + isSymbolicLink: false, + modificationDate: Date(timeIntervalSince1970: 9_999), + resourceIdentifier: "fixture:fresh-recovery-root", + permissions: 0o700 + ), for: recoveryRoot) + } else { + for (url, identity) in [ + (manifestURL, "fixture:fresh-valid-manifest"), + (backupURL, "fixture:fresh-valid-backup"), + ] { + let validBytes = try! fileSystem.read(url) + fileSystem.setStoredData(validBytes, at: url) + fileSystem.setMetadata(AutomationFileMetadata( + canonicalURL: url, + ownerUID: 501, + isSymbolicLink: false, + modificationDate: Date(timeIntervalSince1970: 9_999), + resourceIdentifier: identity, + permissions: 0o600 + ), for: url) + } + } + } + return call == 1 ? snapshot(records: [editedRecord]) : snapshot(records: [Fixtures.userAgent]) + } + ) + + let result = await manager.perform( + .edit(AutomationEditPayload( + label: newLabel, + executable: "/bin/sleep", + arguments: ["14400"], + environment: [:], + workingDirectory: nil, + schedule: Fixtures.userAgent.schedule, + rawRepresentation: editedData + )), + record: Fixtures.userAgent, + expectedChecksum: sha256(sourceData), + linkedProcesses: [] + ) + + let evidence = try! XCTUnwrap(result.fileMutationEvidence) + XCTAssertTrue(evidence.observedFiles.contains { $0.fileURL == manifestURL }) + XCTAssertTrue(evidence.observedFiles.contains { $0.fileURL == backupURL }) + XCTAssertFalse(evidence.recoveryHandles.contains { $0.fileURL == manifestURL }) + XCTAssertFalse(evidence.recoveryHandles.contains { $0.fileURL == backupURL }) + XCTAssertEqual(fileSystem.storedData(at: backupURL), sourceData) + XCTAssertNotNil(try? PropertyListSerialization.propertyList( + from: try! XCTUnwrap(fileSystem.storedData(at: manifestURL)), + options: [], + format: nil + )) + XCTAssertEqual(fileSystem.recordedOperations.filter { operation in + guard case .replace(let destination, _) = operation else { return false } + return destination == manifestURL + }.count, 1) + XCTAssertEqual(fileSystem.recordedOperations.filter { operation in + guard case .replace(let destination, _) = operation else { return false } + return destination == backupURL + }.count, 1) + if replaceRoot { + XCTAssertEqual( + try! fileSystem.metadata(for: recoveryRoot).resourceIdentifier, + "fixture:fresh-recovery-root" + ) + } else { + XCTAssertEqual( + try! fileSystem.metadata(for: manifestURL).resourceIdentifier, + "fixture:fresh-valid-manifest" + ) + XCTAssertEqual( + try! fileSystem.metadata(for: backupURL).resourceIdentifier, + "fixture:fresh-valid-backup" + ) + } + let live = await manager.restorationManifests() + XCTAssertTrue(live.isEmpty) + let restarted = makeManager( + fileSystem: fileSystem, + executor: RecordingAutomationMutationExecutor() + ) + let reloaded = await restarted.restorationManifests() + XCTAssertEqual(reloaded.count, 1) + XCTAssertEqual(reloaded.first?.id, result.backup?.id) + } + + func testSuccessorRevalidatesBackupAfterManifestCommitBeforePublishingMaps() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let sourceData = validLaunchAgentData() + let newLabel = "com.example.backup-toctou" + let editedData = validLaunchAgentData(label: newLabel) + let editedChecksum = sha256(editedData) + let editedRecord = copyRecord( + Fixtures.userAgent, + id: AutomationRecord.ID( + source: .launchAgent, + ownerUID: 501, + label: newLabel, + sourcePath: sourceURL.path + ), + label: newLabel, + sourceURL: sourceURL, + sourceChecksum: editedChecksum + ) + let backupURL = deterministicFirstBackupURL() + let manifestURL = deterministicFirstManifestURL() + let attackerData = Data("attacker-backup-after-final-preflight".utf8) + let fileSystem = InMemoryAutomationFileSystem(files: [sourceURL: sourceData]) + let backupReads = LockedCallCounter() + fileSystem.setReadObserver { url, _ in + guard url == backupURL, backupReads.incrementAndGet() == 3 else { return } + fileSystem.setStoredData(attackerData, at: backupURL) + fileSystem.setMetadata(AutomationFileMetadata( + canonicalURL: backupURL, + ownerUID: 501, + isSymbolicLink: false, + modificationDate: Date(timeIntervalSince1970: 20_000), + resourceIdentifier: "fixture:attacker-backup-after-preflight", + permissions: 0o600 + ), for: backupURL) + } + let preState = AutomationPreTransactionState( + sourceExisted: true, + enabledState: Fixtures.userAgent.enabledState, + loadState: Fixtures.userAgent.loadState, + linkedProcesses: [] + ) + let executor = RecordingAutomationMutationExecutor(results: [ + .success(AutomationExecutorResult( + postconditions: [.sourceInstalled, .sourceChecksum(editedChecksum)], evidence: [] + )), + .success(AutomationExecutorResult( + postconditions: [.preTransactionStateRestored(preState)], evidence: [] + )), + ]) + let refreshes = LockedCallCounter() + let manager = makeManager( + fileSystem: fileSystem, + executor: executor, + refresh: { + refreshes.incrementAndGet() == 1 + ? snapshot(records: [editedRecord]) : snapshot(records: [Fixtures.userAgent]) + } + ) + + let result = await manager.perform( + .edit(AutomationEditPayload( + label: newLabel, + executable: "/bin/sleep", + arguments: ["14400"], + environment: [:], + workingDirectory: nil, + schedule: Fixtures.userAgent.schedule, + rawRepresentation: editedData + )), + record: Fixtures.userAgent, + expectedChecksum: sha256(sourceData), + linkedProcesses: [] + ) + + XCTAssertNotEqual(result.status, .succeeded) + XCTAssertEqual(fileSystem.storedData(at: backupURL), attackerData) + XCTAssertFalse(fileSystem.recordedOperations.contains { operation in + guard case .remove(let removedURL) = operation else { return false } + return removedURL == backupURL + }) + let evidence = result.fileMutationEvidence + XCTAssertNotNil(evidence) + XCTAssertTrue(evidence?.observedFiles.contains { $0.fileURL == backupURL } == true) + XCTAssertFalse(evidence?.recoveryHandles.contains { $0.fileURL == backupURL } == true) + let restoredManifest = try! XCTUnwrap(fileSystem.storedData(at: manifestURL)) + let plist = try! XCTUnwrap( + PropertyListSerialization.propertyList(from: restoredManifest, format: nil) + as? [String: Any] + ) + XCTAssertEqual( + plist["authorizedRecordIDs"] as? [String], + [Fixtures.userAgent.id.rawValue] + ) + let live = await manager.restorationManifests() + let restarted = makeManager( + fileSystem: fileSystem, + executor: RecordingAutomationMutationExecutor() + ) + let reloaded = await restarted.restorationManifests() + XCTAssertEqual(live, reloaded) + XCTAssertTrue(live.isEmpty) + } + + func testSuccessorRechecksBackupIdentityAfterFinalContentVerification() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let sourceData = validLaunchAgentData() + let newLabel = "com.example.backup-identity-toctou" + let editedData = validLaunchAgentData(label: newLabel) + let editedChecksum = sha256(editedData) + let editedRecord = copyRecord( + Fixtures.userAgent, + id: AutomationRecord.ID( + source: .launchAgent, + ownerUID: 501, + label: newLabel, + sourcePath: sourceURL.path + ), + label: newLabel, + sourceURL: sourceURL, + sourceChecksum: editedChecksum + ) + let backupURL = deterministicFirstBackupURL() + let manifestURL = deterministicFirstManifestURL() + let replacementIdentity = "fixture:same-byte-backup-after-final-read" + let fileSystem = InMemoryAutomationFileSystem(files: [sourceURL: sourceData]) + let backupReads = LockedCallCounter() + fileSystem.setReadObserver { url, _ in + guard url == backupURL, backupReads.incrementAndGet() == 4 else { return } + fileSystem.setStoredData(sourceData, at: backupURL) + fileSystem.setMetadata(AutomationFileMetadata( + canonicalURL: backupURL, + ownerUID: 501, + isSymbolicLink: false, + modificationDate: Date(timeIntervalSince1970: 30_000), + resourceIdentifier: replacementIdentity, + permissions: 0o600 + ), for: backupURL) + } + let preState = AutomationPreTransactionState( + sourceExisted: true, + enabledState: Fixtures.userAgent.enabledState, + loadState: Fixtures.userAgent.loadState, + linkedProcesses: [] + ) + let executor = RecordingAutomationMutationExecutor(results: [ + .success(AutomationExecutorResult( + postconditions: [.sourceInstalled, .sourceChecksum(editedChecksum)], evidence: [] + )), + .success(AutomationExecutorResult( + postconditions: [.preTransactionStateRestored(preState)], evidence: [] + )), + ]) + let refreshes = LockedCallCounter() + let manager = makeManager( + fileSystem: fileSystem, + executor: executor, + refresh: { + refreshes.incrementAndGet() == 1 + ? snapshot(records: [editedRecord]) : snapshot(records: [Fixtures.userAgent]) + } + ) + + let result = await manager.perform( + .edit(AutomationEditPayload( + label: newLabel, + executable: "/bin/sleep", + arguments: ["14400"], + environment: [:], + workingDirectory: nil, + schedule: Fixtures.userAgent.schedule, + rawRepresentation: editedData + )), + record: Fixtures.userAgent, + expectedChecksum: sha256(sourceData), + linkedProcesses: [] + ) + + XCTAssertNotEqual(result.status, .succeeded) + XCTAssertEqual(fileSystem.storedData(at: backupURL), sourceData) + XCTAssertEqual( + try! fileSystem.metadata(for: backupURL).resourceIdentifier, + replacementIdentity + ) + XCTAssertFalse(fileSystem.recordedOperations.contains { operation in + guard case .remove(let removedURL) = operation else { return false } + return removedURL == backupURL + }) + let evidence = result.fileMutationEvidence + XCTAssertNotNil(evidence) + XCTAssertTrue(evidence?.observedFiles.contains { $0.fileURL == backupURL } == true) + XCTAssertFalse(evidence?.recoveryHandles.contains { $0.fileURL == backupURL } == true) + let restoredManifest = try! XCTUnwrap(fileSystem.storedData(at: manifestURL)) + let plist = try! XCTUnwrap( + PropertyListSerialization.propertyList(from: restoredManifest, format: nil) + as? [String: Any] + ) + XCTAssertEqual( + plist["authorizedRecordIDs"] as? [String], + [Fixtures.userAgent.id.rawValue] + ) + let live = await manager.restorationManifests() + XCTAssertTrue(live.isEmpty) + let restarted = makeManager( + fileSystem: fileSystem, + executor: RecordingAutomationMutationExecutor() + ) + let reloaded = await restarted.restorationManifests() + XCTAssertEqual(reloaded.count, 1) + XCTAssertEqual(reloaded.first?.id, result.backup?.id) + } + + func testSuccessorRechecksInstalledManifestIdentityAfterFinalRead() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let sourceData = validLaunchAgentData() + let newLabel = "com.example.manifest-identity-toctou" + let editedData = validLaunchAgentData(label: newLabel) + let editedChecksum = sha256(editedData) + let editedRecord = copyRecord( + Fixtures.userAgent, + id: AutomationRecord.ID( + source: .launchAgent, + ownerUID: 501, + label: newLabel, + sourcePath: sourceURL.path + ), + label: newLabel, + sourceURL: sourceURL, + sourceChecksum: editedChecksum + ) + let manifestURL = deterministicFirstManifestURL() + let replacementIdentity = "fixture:same-byte-manifest-after-final-read" + let replacementBytes = LockedDataCapture() + let fileSystem = InMemoryAutomationFileSystem(files: [sourceURL: sourceData]) + let manifestReads = LockedCallCounter() + fileSystem.setReadObserver { url, data in + guard url == manifestURL, manifestReads.incrementAndGet() == 3 else { return } + replacementBytes.set(data) + fileSystem.setStoredData(data, at: manifestURL) + fileSystem.setMetadata(AutomationFileMetadata( + canonicalURL: manifestURL, + ownerUID: 501, + isSymbolicLink: false, + modificationDate: Date(timeIntervalSince1970: 40_000), + resourceIdentifier: replacementIdentity, + permissions: 0o600 + ), for: manifestURL) + } + let preState = AutomationPreTransactionState( + sourceExisted: true, + enabledState: Fixtures.userAgent.enabledState, + loadState: Fixtures.userAgent.loadState, + linkedProcesses: [] + ) + let executor = RecordingAutomationMutationExecutor(results: [ + .success(AutomationExecutorResult( + postconditions: [.sourceInstalled, .sourceChecksum(editedChecksum)], evidence: [] + )), + .success(AutomationExecutorResult( + postconditions: [.preTransactionStateRestored(preState)], evidence: [] + )), + ]) + let refreshes = LockedCallCounter() + let manager = makeManager( + fileSystem: fileSystem, + executor: executor, + refresh: { + refreshes.incrementAndGet() == 1 + ? snapshot(records: [editedRecord]) : snapshot(records: [Fixtures.userAgent]) + } + ) + + let result = await manager.perform( + .edit(AutomationEditPayload( + label: newLabel, + executable: "/bin/sleep", + arguments: ["14400"], + environment: [:], + workingDirectory: nil, + schedule: Fixtures.userAgent.schedule, + rawRepresentation: editedData + )), + record: Fixtures.userAgent, + expectedChecksum: sha256(sourceData), + linkedProcesses: [] + ) + + XCTAssertNotEqual(result.status, .succeeded) + XCTAssertEqual(fileSystem.storedData(at: manifestURL), replacementBytes.current()) + XCTAssertEqual( + try! fileSystem.metadata(for: manifestURL).resourceIdentifier, + replacementIdentity + ) + XCTAssertFalse(fileSystem.recordedOperations.contains { operation in + guard case .remove(let removedURL) = operation else { return false } + return removedURL == manifestURL + }) + let evidence = result.fileMutationEvidence + XCTAssertNotNil(evidence) + XCTAssertTrue(evidence?.observedFiles.contains { $0.fileURL == manifestURL } == true) + XCTAssertFalse(evidence?.recoveryHandles.contains { $0.fileURL == manifestURL } == true) + let plist = try! XCTUnwrap( + PropertyListSerialization.propertyList( + from: try! XCTUnwrap(fileSystem.storedData(at: manifestURL)), + format: nil + ) as? [String: Any] + ) + XCTAssertEqual( + plist["authorizedRecordIDs"] as? [String], + [Fixtures.userAgent.id.rawValue, editedRecord.id.rawValue].sorted() + ) + let live = await manager.restorationManifests() + XCTAssertTrue(live.isEmpty) + let restarted = makeManager( + fileSystem: fileSystem, + executor: RecordingAutomationMutationExecutor() + ) + let reloaded = await restarted.restorationManifests() + XCTAssertEqual(reloaded.count, 1) + XCTAssertEqual(reloaded.first?.id, result.backup?.id) + } + + func testSuccessorManifestPostverificationFailureRestoresPriorManifest() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let sourceData = validLaunchAgentData() + let newLabel = "com.example.corrupt-successor" + let editedData = validLaunchAgentData(label: newLabel) + let editedChecksum = sha256(editedData) + let editedRecord = copyRecord( + Fixtures.userAgent, + id: AutomationRecord.ID( + source: .launchAgent, + ownerUID: 501, + label: newLabel, + sourcePath: sourceURL.path + ), + label: newLabel, + sourceURL: sourceURL, + sourceChecksum: editedChecksum + ) + let disabledRecord = copyRecord( + Fixtures.userAgent, + sourceChecksum: sha256(sourceData), + enabledState: .disabled, + state: .disabled + ) + let manifestURL = deterministicFirstManifestURL() + let fileSystem = InMemoryAutomationFileSystem( + files: [sourceURL: sourceData], + dataAfterReplaceOccurrences: [manifestURL: [2: Data("corrupt".utf8)]] + ) + let executor = RecordingAutomationMutationExecutor(results: [ + .success(AutomationExecutorResult( + postconditions: [.sourceInstalled, .sourceChecksum(editedChecksum)], evidence: [] + )), + ] + Array(repeating: .success(AutomationExecutorResult( + postconditions: [.futureLaunchesDisabled], + evidence: [] + )), count: 20)) + let refreshCount = LockedCallCounter() + let manager = makeManager( + fileSystem: fileSystem, + executor: executor, + refresh: { + switch refreshCount.incrementAndGet() { + case 1: snapshot(records: [editedRecord]) + case 2: snapshot(records: [Fixtures.userAgent]) + default: snapshot(records: [disabledRecord]) + } + } + ) + let result = await manager.perform( + .edit(AutomationEditPayload( + label: newLabel, + executable: "/bin/sleep", + arguments: ["14400"], + environment: [:], + workingDirectory: nil, + schedule: Fixtures.userAgent.schedule, + rawRepresentation: editedData + )), + record: Fixtures.userAgent, + expectedChecksum: sha256(sourceData), + linkedProcesses: [] + ) + + guard case .partialFailure = result.status else { + return XCTFail("Expected partial failure, got \(result.status)") + } + XCTAssertEqual(result.rollback, .restored(try! XCTUnwrap(result.backup?.id))) + XCTAssertEqual(result.fileMutationEvidence?.kind, .replace) + XCTAssertNil(result.rollbackFileMutationEvidence) + XCTAssertEqual(refreshCount.current(), 2) + XCTAssertEqual(fileSystem.storedData(at: sourceURL), sourceData) + let restoredBackupID = try! XCTUnwrap(result.backup?.id) + let restored = await manager.restorationManifests() + XCTAssertTrue(restored.contains { $0.id == restoredBackupID }) + await assertLiveRecoveryInventoryMatchesRestartAfterRetentionChurn( + manager: manager, + fileSystem: fileSystem, + expectedChecksum: sha256(sourceData) + ) + } + + func testSuccessorCompensationAndSourceRollbackPartialsRemainSeparatelyVisible() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let sourceData = validLaunchAgentData() + let newLabel = "com.example.aggregate-successor" + let editedData = validLaunchAgentData(label: newLabel) + let editedChecksum = sha256(editedData) + let editedRecord = copyRecord( + Fixtures.userAgent, + id: AutomationRecord.ID( + source: .launchAgent, + ownerUID: 501, + label: newLabel, + sourcePath: sourceURL.path + ), + label: newLabel, + sourceURL: sourceURL, + sourceChecksum: editedChecksum + ) + let manifestURL = deterministicFirstManifestURL() + let fileSystem = InMemoryAutomationFileSystem( + files: [sourceURL: sourceData], + dataAfterReplaceOccurrences: [manifestURL: [2: Data("corrupt".utf8)]], + partialReplaceAfterCommitOccurrences: [ + manifestURL: [3], + sourceURL: [2], + ] + ) + let executor = RecordingAutomationMutationExecutor(results: [ + .success(AutomationExecutorResult( + postconditions: [.sourceInstalled, .sourceChecksum(editedChecksum)], evidence: [] + )), + ]) + let refreshCount = LockedCallCounter() + let manager = makeManager( + fileSystem: fileSystem, + executor: executor, + refresh: { + _ = refreshCount.incrementAndGet() + return snapshot(records: [editedRecord]) + } + ) + let result = await manager.perform( + .edit(AutomationEditPayload( + label: newLabel, + executable: "/bin/sleep", + arguments: ["14400"], + environment: [:], + workingDirectory: nil, + schedule: Fixtures.userAgent.schedule, + rawRepresentation: editedData + )), + record: Fixtures.userAgent, + expectedChecksum: sha256(sourceData), + linkedProcesses: [] + ) + + XCTAssertEqual( + result.status, + .partialFailure( + "HIGH SEVERITY: the operation failed and automatic rollback reached a partial filesystem state." + ) + ) + XCTAssertEqual(result.rollback, .failed("Automatic rollback did not complete.")) + let manifestEvidence = try! XCTUnwrap(result.fileMutationEvidence) + let rollbackEvidence = try! XCTUnwrap(result.rollbackFileMutationEvidence) + XCTAssertEqual(manifestEvidence.resultURL, manifestURL) + XCTAssertGreaterThanOrEqual(manifestEvidence.recoveryHandles.count, 1) + XCTAssertEqual(rollbackEvidence.resultURL, sourceURL) + XCTAssertGreaterThanOrEqual(rollbackEvidence.recoveryHandles.count, 1) + XCTAssertEqual(refreshCount.current(), 2) + for evidence in [manifestEvidence, rollbackEvidence] { + for recovery in evidence.recoveryHandles { + XCTAssertTrue(fileSystem.itemExists(at: recovery.fileURL)) + guard case .existing(let expectedIdentity) = recovery.expectation else { + return XCTFail("Recovery handle must bind an existing exact entry.") + } + XCTAssertEqual( + try! fileSystem.metadata(for: recovery.fileURL).resourceIdentifier, + expectedIdentity + ) + } + } + let restarted = makeManager( + fileSystem: fileSystem, + executor: RecordingAutomationMutationExecutor() + ) + let manifests = await restarted.restorationManifests() + XCTAssertEqual(manifests.first?.authorizedRecordIDs, [Fixtures.userAgent.id]) + } + + func testExternalEditDuringExecutorSuspensionIsNeverOverwrittenByRollback() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let sourceData = validLaunchAgentData() + let editedData = validLaunchAgentData(arguments: ["7200"]) + let externalData = validLaunchAgentData(arguments: ["3600"]) + let fileSystem = InMemoryAutomationFileSystem(files: [sourceURL: sourceData]) + let executor = RecordingAutomationMutationExecutor( + results: [.failure(.applyFailed)], + onApply: { fileSystem.setStoredData(externalData, at: sourceURL) } + ) + let manager = makeManager(fileSystem: fileSystem, executor: executor) + let payload = AutomationEditPayload( + label: Fixtures.userAgent.label, + executable: "/bin/sleep", + arguments: ["7200"], + environment: [:], + workingDirectory: nil, + schedule: Fixtures.userAgent.schedule, + rawRepresentation: editedData + ) + + let result = await manager.perform( + .edit(payload), + record: Fixtures.userAgent, + expectedChecksum: sha256(sourceData), + linkedProcesses: [] + ) + + XCTAssertEqual( + result.status, + .partialFailure("HIGH SEVERITY: the operation failed and automatic rollback did not complete.") + ) + XCTAssertEqual(fileSystem.storedData(at: sourceURL), externalData) + } + + func testRollbackRefusesWhenDestinationAuthorizationChangesDuringExecutorAwait() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let sourceData = validLaunchAgentData() + let editedData = validLaunchAgentData(arguments: ["7200"]) + let authorizationRevoked = LockedFlag() + let fileSystem = InMemoryAutomationFileSystem(files: [sourceURL: sourceData]) + let executor = RecordingAutomationMutationExecutor( + results: [.failure(.applyFailed)], + onApply: { authorizationRevoked.set() } + ) + let manager = makeManager( + fileSystem: fileSystem, + executor: executor, + destinationContext: { _, _ in + .fixture( + currentUID: 501, + canonicalPathIsApproved: !authorizationRevoked.value, + ownerUID: 501 + ) + } + ) + + let result = await manager.perform( + .edit(AutomationEditPayload( + label: Fixtures.userAgent.label, + executable: "/bin/sleep", + arguments: ["7200"], + environment: [:], + workingDirectory: nil, + schedule: Fixtures.userAgent.schedule, + rawRepresentation: editedData + )), + record: Fixtures.userAgent, + expectedChecksum: sha256(sourceData), + linkedProcesses: [] + ) + + XCTAssertEqual( + result.status, + .partialFailure("HIGH SEVERITY: the operation failed and automatic rollback did not complete.") + ) + XCTAssertEqual(fileSystem.storedData(at: sourceURL), editedData) + } + + func testStopAndCompoundStopRequireRefreshedBirthAndRelaunchProof() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let sourceData = validLaunchAgentData() + let originalBirth = ProcessBirthToken(seconds: 1_000, microseconds: 1) + let relaunchedBirth = ProcessBirthToken(seconds: 2_000, microseconds: 2) + let controlled = classifiedAutomationProcess(pid: 700, birth: originalBirth) + let relaunched = classifiedAutomationProcess(pid: 701, birth: relaunchedBirth) + + for operation in [AutomationOperation.stopCurrentRun, .disableAndStop] { + let refreshedRecord = operation == .disableAndStop + ? copyRecord(Fixtures.userAgent, enabledState: .disabled, state: .disabled) + : Fixtures.userAgent + let fileSystem = InMemoryAutomationFileSystem(files: [sourceURL: sourceData]) + let manager = makeManager( + fileSystem: fileSystem, + executor: RecordingAutomationMutationExecutor(results: [ + .success(AutomationExecutorResult( + postconditions: operation == .disableAndStop + ? [.futureLaunchesDisabled, .noLinkedProcess] + : [.noLinkedProcess], + evidence: [] + )), + ]), + refresh: { snapshot(records: [refreshedRecord]) }, + refreshProcesses: { [relaunched.process] } + ) + + let result = await manager.perform( + operation, + record: Fixtures.userAgent, + expectedChecksum: sha256(sourceData), + linkedProcesses: [controlled] + ) + XCTAssertNotEqual(result.status, .succeeded) + XCTAssertTrue(result.verificationEvidence.contains { + $0.contains("controlled or relaunched process") + }) + } + } + + func testStopRejectsStrongBirthlessRelaunchCandidate() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let sourceData = validLaunchAgentData() + let controlled = classifiedAutomationProcess( + pid: 700, + birth: ProcessBirthToken(seconds: 1_000, microseconds: 1) + ) + let birthless = DevProcess( + pid: 701, + parentPID: 1, + executable: "/bin/sleep", + command: "/bin/sleep 14400", + argumentVector: ["/bin/sleep", "14400"], + birthToken: nil, + launchLabel: Fixtures.userAgent.label + ) + let manager = makeManager( + fileSystem: InMemoryAutomationFileSystem(files: [sourceURL: sourceData]), + executor: RecordingAutomationMutationExecutor(results: [ + .success(AutomationExecutorResult(postconditions: [.noLinkedProcess], evidence: [])), + ]), + refreshProcesses: { [birthless] } + ) + + let result = await manager.perform( + .stopCurrentRun, + record: Fixtures.userAgent, + expectedChecksum: sha256(sourceData), + linkedProcesses: [controlled] + ) + + XCTAssertNotEqual(result.status, .succeeded) + XCTAssertTrue(result.verificationEvidence.contains { $0.contains("relaunched process") }) + } + + func testStopFailsClosedWhenLiveProcessVerificationIsUnavailable() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let sourceData = validLaunchAgentData() + let controlled = classifiedAutomationProcess( + pid: 700, + birth: ProcessBirthToken(seconds: 1_000, microseconds: 1) + ) + let fileSystem = InMemoryAutomationFileSystem(files: [sourceURL: sourceData]) + let manager = makeManager( + fileSystem: fileSystem, + executor: RecordingAutomationMutationExecutor(results: [ + .success(AutomationExecutorResult( + postconditions: [.noLinkedProcess], + evidence: [] + )), + ]), + refreshProcesses: { + throw AutomationManagerConfigurationError.runtimeProcessVerificationUnavailable + } + ) + + let result = await manager.perform( + .stopCurrentRun, + record: Fixtures.userAgent, + expectedChecksum: sha256(sourceData), + linkedProcesses: [controlled] + ) + + XCTAssertNotEqual(result.status, .succeeded) + XCTAssertTrue(result.verificationEvidence.contains { + $0.contains("Live process verification was unavailable") + }) + } + + func testDurableManifestReloadsInANewManager() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let sourceData = validLaunchAgentData() + let checksum = sha256(sourceData) + let disabledRecord = copyRecord( + Fixtures.userAgent, + sourceChecksum: checksum, + enabledState: .disabled, + state: .disabled + ) + let fileSystem = InMemoryAutomationFileSystem(files: [sourceURL: sourceData]) + let success = AutomationExecutorResult( + postconditions: [.futureLaunchesDisabled], + evidence: [] + ) + let first = makeManager( + fileSystem: fileSystem, + executor: RecordingAutomationMutationExecutor(results: [.success(success)]), + refresh: { snapshot(records: [disabledRecord]) } + ) + let result = await first.perform( + .disable, + record: Fixtures.userAgent, + expectedChecksum: checksum, + linkedProcesses: [] + ) + let second = makeManager( + fileSystem: fileSystem, + executor: RecordingAutomationMutationExecutor() + ) + + let manifests = await second.restorationManifests() + XCTAssertEqual(manifests.map(\.id), [try! XCTUnwrap(result.backup?.id)]) + XCTAssertEqual(manifests.first?.backupURL.path, "/redacted") + } + + func testSourceLessCronCanUseRecoverableTransactionIdentity() async { + let transactionURL = URL(fileURLWithPath: "/tmp/devscope-fixtures/current-crontab") + let data = Data("0 9 * * 1 /bin/sleep 1\n".utf8) + let checksum = sha256(data) + let fileSystem = InMemoryAutomationFileSystem(files: [transactionURL: data]) + let metadata = try! fileSystem.metadata(for: transactionURL) + let cron = AutomationRecord( + id: AutomationRecord.ID( + source: .crontab, + ownerUID: 501, + label: "cron-entry", + sourcePath: "/.devscope/current-user-crontab/test" + ), + kind: .cron, + sourceKind: .crontab, + label: "Cron entry 1", + displayName: "Cron entry 1", + providerBundleIdentifier: nil, + ownerUID: 501, + ownership: .user, + executable: nil, + arguments: [], + commandSignature: "/bin/sleep 1", + environment: [:], + workingDirectory: nil, + schedule: AutomationSchedule(triggers: [.cron("0 9 * * 1")], summary: "Cron"), + sourceURL: nil, + sourceChecksum: checksum, + enabledState: .enabled, + loadState: .unknown, + approvalState: .notApplicable, + state: .idle, + evidence: [], + capabilities: [.exportRecord], + validationFindings: [] + ) + let manager = makeManager( + fileSystem: fileSystem, + executor: RecordingAutomationMutationExecutor(results: [ + .success(AutomationExecutorResult(postconditions: [.targetResolved], evidence: [])), + ]), + context: .fixture( + currentUID: 501, + canonicalPathIsApproved: true, + ownerUID: 501, + implementedCapabilities: [.exportRecord] + ), + recoverableSource: { _ in + AutomationRecoverableSource( + transactionURL: transactionURL, + data: data, + checksum: checksum, + metadata: metadata + ) + } + ) + + let result = await manager.perform( + .exportRecord(redacted: true), + record: cron, + expectedChecksum: checksum, + linkedProcesses: [] + ) + + XCTAssertEqual(result.status, .succeeded) + } + + func testPartialRecoverableTransactionSourceIsNeverDowngradedToPreflightRejection() async { + let transactionURL = URL(fileURLWithPath: "/tmp/devscope-fixtures/partial-crontab") + let data = Data("0 9 * * 1 /bin/sleep 1\n".utf8) + let checksum = sha256(data) + let entry = try! XCTUnwrap(CronParser.parse(String(decoding: data, as: UTF8.self)).entries.first) + let cron = cronRecord(entry: entry, checksum: checksum) + let fileSystem = InMemoryAutomationFileSystem( + directories: [transactionURL.deletingLastPathComponent()] + ) + let executor = RecordingAutomationMutationExecutor() + let refreshCount = LockedCallCounter() + let manager = makeManager( + fileSystem: fileSystem, + executor: executor, + context: .fixture(currentUID: 501, canonicalPathIsApproved: true, ownerUID: 501), + recoverableSource: { _ in + fileSystem.setStoredData(data, at: transactionURL) + let directoryMetadata = try fileSystem.metadata( + for: transactionURL.deletingLastPathComponent() + ) + let directory = try XCTUnwrap(AutomationDirectoryAuthorization( + directoryURL: transactionURL.deletingLastPathComponent(), + resourceIdentifier: directoryMetadata.resourceIdentifier + )) + let metadata = try fileSystem.metadata(for: transactionURL) + let recovery = try XCTUnwrap(AutomationFileAuthorization( + fileURL: transactionURL, + directory: directory, + expectation: .existing(resourceIdentifier: try XCTUnwrap(metadata.resourceIdentifier)) + )) + throw AutomationFilePartialMutation( + kind: .replace, + commitState: .committed, + observedFiles: [recovery], + recoveryHandle: recovery, + resultURL: transactionURL + ) + }, + refresh: { + _ = refreshCount.incrementAndGet() + return Fixtures.inventoryGeneration7 + } + ) + + let result = await manager.perform( + .disable, + record: cron, + expectedChecksum: checksum, + linkedProcesses: [] + ) + + XCTAssertEqual( + result.status, + .partialFailure("HIGH SEVERITY: recoverable source materialization reached a partial filesystem state.") + ) + XCTAssertEqual(result.fileMutationEvidence?.recoveryHandle?.fileURL, transactionURL) + XCTAssertEqual(result.fileMutationEvidence?.commitState, .committed) + XCTAssertEqual(refreshCount.current(), 1) + XCTAssertEqual(fileSystem.storedData(at: transactionURL), data) + let invocationCount = await executor.invocationCount() + XCTAssertEqual(invocationCount, 0) + } + + func testSecondRecoverableSourcePartialPreservesEvidenceRefreshesAndSkipsExecutor() async { + let transactionURL = URL(fileURLWithPath: "/tmp/devscope-fixtures/second-partial-crontab") + let initial = Data("0 9 * * 1 /bin/sleep 1\n".utf8) + let materialized = Data("0 10 * * 1 /bin/sleep 2\n".utf8) + let checksum = sha256(initial) + let entry = try! XCTUnwrap( + CronParser.parse(String(decoding: initial, as: UTF8.self)).entries.first + ) + let cron = cronRecord(entry: entry, checksum: checksum) + let fileSystem = InMemoryAutomationFileSystem(files: [transactionURL: initial]) + let executor = RecordingAutomationMutationExecutor() + let sourceCalls = LockedCallCounter() + let refreshCount = LockedCallCounter() + let manager = makeManager( + fileSystem: fileSystem, + executor: executor, + context: .fixture(currentUID: 501, canonicalPathIsApproved: true, ownerUID: 501), + recoverableSource: { _ in + if sourceCalls.incrementAndGet() == 1 { + return AutomationRecoverableSource( + transactionURL: transactionURL, + data: initial, + checksum: checksum, + metadata: try fileSystem.metadata(for: transactionURL) + ) + } + fileSystem.setStoredData(materialized, at: transactionURL) + let directoryMetadata = try fileSystem.metadata( + for: transactionURL.deletingLastPathComponent() + ) + let directory = try XCTUnwrap(AutomationDirectoryAuthorization( + directoryURL: transactionURL.deletingLastPathComponent(), + resourceIdentifier: directoryMetadata.resourceIdentifier + )) + let metadata = try fileSystem.metadata(for: transactionURL) + let recovery = try XCTUnwrap(AutomationFileAuthorization( + fileURL: transactionURL, + directory: directory, + expectation: .existing(resourceIdentifier: try XCTUnwrap(metadata.resourceIdentifier)) + )) + throw AutomationFilePartialMutation( + kind: .replace, + commitState: .committed, + observedFiles: [recovery], + recoveryHandle: recovery, + resultURL: transactionURL + ) + }, + refresh: { + _ = refreshCount.incrementAndGet() + return Fixtures.inventoryGeneration7 + } + ) + + let result = await manager.perform( + .disable, + record: cron, + expectedChecksum: checksum, + linkedProcesses: [] + ) + + XCTAssertEqual( + result.status, + .partialFailure( + "HIGH SEVERITY: recoverable source materialization reached a partial filesystem state." + ) + ) + XCTAssertNotNil(result.backup) + XCTAssertEqual(result.fileMutationEvidence?.recoveryHandle?.fileURL, transactionURL) + XCTAssertEqual(result.fileMutationEvidence?.commitState, .committed) + XCTAssertEqual(sourceCalls.current(), 2) + XCTAssertEqual(refreshCount.current(), 1) + XCTAssertEqual(fileSystem.storedData(at: transactionURL), materialized) + let invocationCount = await executor.invocationCount() + XCTAssertEqual(invocationCount, 0) + } + + func testRecoverableSourceRevalidationRejectsAdapterSnapshotThatDiffersFromDisk() async { + let transactionURL = URL(fileURLWithPath: "/tmp/devscope-fixtures/current-crontab") + let data = Data("0 9 * * 1 /bin/sleep 1\n".utf8) + let changed = Data("0 10 * * 1 /bin/sleep 2\n".utf8) + let checksum = sha256(data) + let fileSystem = InMemoryAutomationFileSystem(files: [transactionURL: data]) + let metadata = try! fileSystem.metadata(for: transactionURL) + let calls = LockedCallCounter() + let cron = cronRecord(entry: try! XCTUnwrap(CronParser.parse(String(decoding: data, as: UTF8.self)).entries.first), checksum: checksum) + let executor = RecordingAutomationMutationExecutor() + let manager = makeManager( + fileSystem: fileSystem, + executor: executor, + context: .fixture(currentUID: 501, canonicalPathIsApproved: true, ownerUID: 501), + recoverableSource: { _ in + if calls.incrementAndGet() == 2 { fileSystem.setStoredData(changed, at: transactionURL) } + return AutomationRecoverableSource( + transactionURL: transactionURL, + data: data, + checksum: checksum, + metadata: metadata + ) + } + ) + + let result = await manager.perform( + .disable, + record: cron, + expectedChecksum: checksum, + linkedProcesses: [] + ) + + XCTAssertEqual( + result.status, + .failed("The recoverable source identity changed immediately before mutation.") + ) + let invocationCount = await executor.invocationCount() + XCTAssertEqual(invocationCount, 0) + } + + func testCronEditBindsRawDocumentToReviewedFieldsAndImportVerifiesStableEntryIdentity() async { + let transactionURL = URL(fileURLWithPath: "/tmp/devscope-fixtures/current-crontab") + let data = Data("SHELL=/bin/sh\n0 9 * * 1 /usr/bin/echo hello\n".utf8) + let checksum = sha256(data) + let actualSnapshot = await CronAutomationSource( + commandRunner: RecordingAutomationCommandRunner(result: .success(AutomationCommandResult( + status: 0, + standardOutput: data, + standardError: Data() + ))), + currentUID: 501, + currentUsername: "test-user" + ).snapshot() + let cron = try! XCTUnwrap(actualSnapshot.records.first) + XCTAssertEqual(cron.sourceChecksum, checksum) + let fileSystem = InMemoryAutomationFileSystem(files: [transactionURL: data]) + let captured: @Sendable (AutomationRecord) async throws -> AutomationRecoverableSource = { _ in + AutomationRecoverableSource( + transactionURL: transactionURL, + data: try fileSystem.read(transactionURL), + checksum: sha256(try fileSystem.read(transactionURL)), + metadata: try fileSystem.metadata(for: transactionURL) + ) + } + let manager = makeManager( + fileSystem: fileSystem, + executor: RecordingAutomationMutationExecutor(results: [ + .success(AutomationExecutorResult( + postconditions: [.sourceInstalled, .sourceChecksum(checksum)], evidence: [] + )), + ]), + context: .fixture(currentUID: 501, canonicalPathIsApproved: true, ownerUID: 501), + recoverableSource: captured, + refresh: { snapshot(records: [cron]) } + ) + + let mismatched = await manager.perform( + .edit(AutomationEditPayload( + label: cron.label, + executable: "/usr/bin/echo", + arguments: ["reviewed-other-command"], + environment: ["SHELL": "/bin/sh"], + workingDirectory: nil, + schedule: cron.schedule, + rawRepresentation: data + )), + record: cron, + expectedChecksum: checksum, + linkedProcesses: [] + ) + XCTAssertEqual( + mismatched.status, + .rejected("The rendered crontab does not match the reviewed command, schedule, and environment.") + ) + + let imported = await manager.perform( + .importRecord(AutomationImportPayload( + destination: transactionURL, + data: data, + expectedKind: .cron, + expectedDestinationChecksum: checksum + )), + record: cron, + expectedChecksum: checksum, + linkedProcesses: [] + ) + XCTAssertEqual(imported.status, .succeeded) + } + + func testCronEditRejectsAmbiguousIntendedEntryAndStaleRefreshedContent() async { + let transactionURL = URL(fileURLWithPath: "/tmp/devscope-fixtures/current-crontab") + let original = Data("0 9 * * 1 /usr/bin/echo original\n".utf8) + let entry = try! XCTUnwrap(CronParser.parse(String(decoding: original, as: UTF8.self)).entries.first) + let record = cronRecord(entry: entry, checksum: sha256(original)) + let ambiguous = Data("0 9 * * 1 /usr/bin/echo changed\n0 9 * * 1 /usr/bin/echo changed\n".utf8) + let changed = Data("0 9 * * 1 /usr/bin/echo changed\n".utf8) + let staleRecord = cronRecord(entry: entry, checksum: sha256(changed)) + let fileSystem = InMemoryAutomationFileSystem(files: [transactionURL: original]) + let captured: @Sendable (AutomationRecord) async throws -> AutomationRecoverableSource = { _ in + let data = try fileSystem.read(transactionURL) + return AutomationRecoverableSource( + transactionURL: transactionURL, + data: data, + checksum: sha256(data), + metadata: try fileSystem.metadata(for: transactionURL) + ) + } + let executor = RecordingAutomationMutationExecutor(results: [ + .success(AutomationExecutorResult( + postconditions: [.sourceInstalled, .sourceChecksum(sha256(changed))], + evidence: [] + )), + ]) + let manager = makeManager( + fileSystem: fileSystem, + executor: executor, + context: .fixture(currentUID: 501, canonicalPathIsApproved: true, ownerUID: 501), + recoverableSource: captured, + refresh: { snapshot(records: [staleRecord]) } + ) + let payload: (Data) -> AutomationEditPayload = { data in + AutomationEditPayload( + label: record.label, + executable: "/usr/bin/echo", + arguments: ["changed"], + environment: [:], + workingDirectory: nil, + schedule: record.schedule, + rawRepresentation: data + ) + } + + let rejected = await manager.perform( + .edit(payload(ambiguous)), + record: record, + expectedChecksum: sha256(original), + linkedProcesses: [] + ) + XCTAssertEqual( + rejected.status, + .rejected("The intended cron entry is missing or ambiguous in the rendered document.") + ) + + let stale = await manager.perform( + .edit(payload(changed)), + record: record, + expectedChecksum: sha256(original), + linkedProcesses: [] + ) + XCTAssertNotEqual(stale.status, .succeeded) + } + + func testCronEditRejectsAddOnlyDocumentThatKeepsSelectedEntry() async { + let transactionURL = URL(fileURLWithPath: "/tmp/devscope-fixtures/current-crontab") + let original = Data("0 9 * * 1 /usr/bin/echo original\n".utf8) + let addOnly = Data("0 9 * * 1 /usr/bin/echo original\n0 10 * * 1 /usr/bin/echo changed\n".utf8) + let originalRecords = await realCronRecords(original) + let addOnlyRecords = await realCronRecords(addOnly) + let selected = try! XCTUnwrap(originalRecords.first) + let intended = try! XCTUnwrap(addOnlyRecords.last) + let fileSystem = InMemoryAutomationFileSystem(files: [transactionURL: original]) + let executor = RecordingAutomationMutationExecutor(results: [ + .success(AutomationExecutorResult( + postconditions: [.sourceInstalled, .sourceChecksum(sha256(addOnly))], + evidence: [] + )), + ]) + let manager = makeManager( + fileSystem: fileSystem, + executor: executor, + context: .fixture(currentUID: 501, canonicalPathIsApproved: true, ownerUID: 501), + recoverableSource: recoverableCronSource(transactionURL, fileSystem: fileSystem), + refresh: { snapshot(records: [intended]) } + ) + + let result = await manager.perform( + .edit(AutomationEditPayload( + label: selected.label, + executable: "/usr/bin/echo", + arguments: ["changed"], + environment: [:], + workingDirectory: nil, + schedule: AutomationSchedule(triggers: [.cron("0 10 * * 1")], summary: "reviewed"), + rawRepresentation: addOnly + )), + record: selected, + expectedChecksum: sha256(original), + linkedProcesses: [] + ) + + XCTAssertEqual( + result.status, + .rejected("A cron edit must replace exactly the selected entry and preserve unrelated entries.") + ) + let invocationCount = await executor.invocationCount() + XCTAssertEqual(invocationCount, 0) + } + + func testCronEditRejectsChangingUnselectedIdenticalOccurrence() async { + let transactionURL = URL(fileURLWithPath: "/tmp/devscope-fixtures/current-crontab") + let original = Data("0 9 * * 1 /usr/bin/echo same\n0 9 * * 1 /usr/bin/echo same\n".utf8) + let wrongOccurrence = Data("0 9 * * 1 /usr/bin/echo same\n0 9 * * 1 /usr/bin/echo changed\n".utf8) + let originalRecords = await realCronRecords(original) + let intendedRecords = await realCronRecords(wrongOccurrence) + let selected = try! XCTUnwrap(originalRecords.first) + let intended = try! XCTUnwrap(intendedRecords.last) + let fileSystem = InMemoryAutomationFileSystem(files: [transactionURL: original]) + let executor = RecordingAutomationMutationExecutor(results: [ + .success(AutomationExecutorResult( + postconditions: [.sourceInstalled, .sourceChecksum(sha256(wrongOccurrence))], + evidence: [] + )), + ]) + let manager = makeManager( + fileSystem: fileSystem, + executor: executor, + context: .fixture(currentUID: 501, canonicalPathIsApproved: true, ownerUID: 501), + recoverableSource: recoverableCronSource(transactionURL, fileSystem: fileSystem), + refresh: { snapshot(records: [intended]) } + ) + + let result = await manager.perform( + .edit(AutomationEditPayload( + label: selected.label, + executable: "/usr/bin/echo", + arguments: ["changed"], + environment: [:], + workingDirectory: nil, + schedule: selected.schedule, + rawRepresentation: wrongOccurrence + )), + record: selected, + expectedChecksum: sha256(original), + linkedProcesses: [] + ) + + XCTAssertEqual( + result.status, + .rejected("The intended cron entry does not replace the selected occurrence.") + ) + let invocationCount = await executor.invocationCount() + XCTAssertEqual(invocationCount, 0) + } + + func testRealCronSourceDuplicateSucceedsAndFailedDuplicateRollsBack() async { + let transactionURL = URL(fileURLWithPath: "/tmp/devscope-fixtures/current-crontab") + let original = Data("0 9 * * 1 /usr/bin/echo original\n".utf8) + let duplicated = Data("0 9 * * 1 /usr/bin/echo original\n0 10 * * 1 /usr/bin/echo copy\n".utf8) + let originalRecords = await realCronRecords(original) + let duplicatedRecords = await realCronRecords(duplicated) + let selected = try! XCTUnwrap(originalRecords.first) + let duplicatedRecord = try! XCTUnwrap(duplicatedRecords.last) + let payload = AutomationEditPayload( + label: selected.label, + executable: "/usr/bin/echo", + arguments: ["copy"], + environment: [:], + workingDirectory: nil, + schedule: AutomationSchedule(triggers: [.cron("0 10 * * 1")], summary: "reviewed"), + rawRepresentation: duplicated, + destination: transactionURL, + expectedDestinationChecksum: sha256(original) + ) + + let successFS = InMemoryAutomationFileSystem(files: [transactionURL: original]) + let success = makeManager( + fileSystem: successFS, + executor: RecordingAutomationMutationExecutor(results: [ + .success(AutomationExecutorResult( + postconditions: [.sourceInstalled, .sourceChecksum(sha256(duplicated))], + evidence: [] + )), + ]), + context: .fixture(currentUID: 501, canonicalPathIsApproved: true, ownerUID: 501), + recoverableSource: recoverableCronSource(transactionURL, fileSystem: successFS), + refresh: { snapshot(records: [duplicatedRecord]) } + ) + let succeeded = await success.perform( + .duplicate(payload), + record: selected, + expectedChecksum: sha256(original), + linkedProcesses: [] + ) + XCTAssertEqual(succeeded.status, .succeeded) + + let rollbackFS = InMemoryAutomationFileSystem(files: [transactionURL: original]) + let rollback = makeManager( + fileSystem: rollbackFS, + executor: RecordingAutomationMutationExecutor(results: [.failure(.applyFailed)]), + context: .fixture(currentUID: 501, canonicalPathIsApproved: true, ownerUID: 501), + recoverableSource: recoverableCronSource(transactionURL, fileSystem: rollbackFS), + refresh: { snapshot(records: [selected]) } + ) + let failed = await rollback.perform( + .duplicate(payload), + record: selected, + expectedChecksum: sha256(original), + linkedProcesses: [] + ) + XCTAssertEqual(failed.rollback, .restored(try! XCTUnwrap(failed.backup?.id))) + XCTAssertEqual(rollbackFS.storedData(at: transactionURL), original) + } + + func testCronDuplicateRejectsSameSchedulePrependThatReusesSelectedID() async { + let transactionURL = URL(fileURLWithPath: "/tmp/devscope-fixtures/current-crontab") + let original = Data("0 9 * * 1 /usr/bin/echo original\n".utf8) + let prepended = Data("0 9 * * 1 /usr/bin/echo copy\n0 9 * * 1 /usr/bin/echo original\n".utf8) + let originalRecords = await realCronRecords(original) + let selected = try! XCTUnwrap(originalRecords.first) + let fileSystem = InMemoryAutomationFileSystem(files: [transactionURL: original]) + let executor = RecordingAutomationMutationExecutor() + let manager = makeManager( + fileSystem: fileSystem, + executor: executor, + context: .fixture(currentUID: 501, canonicalPathIsApproved: true, ownerUID: 501), + recoverableSource: recoverableCronSource(transactionURL, fileSystem: fileSystem) + ) + + let result = await manager.perform( + .duplicate(AutomationEditPayload( + label: selected.label, + executable: "/usr/bin/echo", + arguments: ["copy"], + environment: [:], + workingDirectory: nil, + schedule: selected.schedule, + rawRepresentation: prepended, + destination: transactionURL, + expectedDestinationChecksum: sha256(original) + )), + record: selected, + expectedChecksum: sha256(original), + linkedProcesses: [] + ) + + XCTAssertEqual(result.status, .rejected("The added cron entry identity is not distinct.")) + let invocationCount = await executor.invocationCount() + XCTAssertEqual(invocationCount, 0) + } + + func testRealCronSourcePresentRestoreSucceedsAndFailedRestoreRollsBack() async { + let transactionURL = URL(fileURLWithPath: "/tmp/devscope-fixtures/current-crontab") + let original = Data("0 9 * * 1 /usr/bin/echo original\n".utf8) + let edited = Data("0 10 * * 1 /usr/bin/echo changed\n".utf8) + let originalRecords = await realCronRecords(original) + let editedRecords = await realCronRecords(edited) + let originalRecord = try! XCTUnwrap(originalRecords.first) + let editedRecord = try! XCTUnwrap(editedRecords.first) + let editPayload = AutomationEditPayload( + label: originalRecord.label, + executable: "/usr/bin/echo", + arguments: ["changed"], + environment: [:], + workingDirectory: nil, + schedule: editedRecord.schedule, + rawRepresentation: edited + ) + + for restoreSucceeds in [true, false] { + let fileSystem = InMemoryAutomationFileSystem(files: [transactionURL: original]) + let refreshCount = LockedCallCounter() + let executor = RecordingAutomationMutationExecutor(results: [ + .success(AutomationExecutorResult( + postconditions: [.sourceInstalled, .sourceChecksum(sha256(edited))], + evidence: [] + )), + restoreSucceeds + ? .success(AutomationExecutorResult( + postconditions: [.sourceInstalled, .sourceChecksum(sha256(original))], + evidence: [] + )) + : .failure(.applyFailed), + ]) + let manager = makeManager( + fileSystem: fileSystem, + executor: executor, + context: .fixture(currentUID: 501, canonicalPathIsApproved: true, ownerUID: 501), + recoverableSource: recoverableCronSource(transactionURL, fileSystem: fileSystem), + refresh: { + let call = refreshCount.incrementAndGet() + return restoreSucceeds && call > 1 + ? snapshot(records: [originalRecord]) + : snapshot(records: [editedRecord]) + } + ) + let edit = await manager.perform( + .edit(editPayload), + record: originalRecord, + expectedChecksum: sha256(original), + linkedProcesses: [] + ) + let backupID = try! XCTUnwrap(edit.backup?.id) + let restored = await manager.perform( + .restore(backupID), + record: editedRecord, + expectedChecksum: sha256(edited), + linkedProcesses: [] + ) + + if restoreSucceeds { + XCTAssertEqual(restored.status, .succeeded) + XCTAssertEqual(fileSystem.storedData(at: transactionURL), original) + } else { + XCTAssertEqual(restored.rollback, .restored(try! XCTUnwrap(restored.backup?.id))) + XCTAssertEqual(fileSystem.storedData(at: transactionURL), edited) + } + } + } + + func testDestinationResourceIdentityChangeBlocksBeforeReplacement() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let sourceData = validLaunchAgentData(arguments: ["14400"]) + let editedData = validLaunchAgentData(arguments: ["7200"]) + let fileSystem = InMemoryAutomationFileSystem(files: [sourceURL: sourceData]) + let calls = LockedCallCounter() + let manager = makeManager( + fileSystem: fileSystem, + executor: RecordingAutomationMutationExecutor(), + destinationContext: { _, _ in + if calls.incrementAndGet() == 2 { + fileSystem.setMetadata( + AutomationFileMetadata( + canonicalURL: sourceURL, + ownerUID: 501, + isSymbolicLink: false, + modificationDate: Date(timeIntervalSince1970: 2_000), + resourceIdentifier: "replacement-identity" + ), + for: sourceURL + ) + } + return .fixture(currentUID: 501, canonicalPathIsApproved: true, ownerUID: 501) + } + ) + let payload = AutomationEditPayload( + label: Fixtures.userAgent.label, + executable: "/bin/sleep", + arguments: ["7200"], + environment: [:], + workingDirectory: nil, + schedule: Fixtures.userAgent.schedule, + rawRepresentation: editedData + ) + + let result = await manager.perform( + .edit(payload), + record: Fixtures.userAgent, + expectedChecksum: sha256(sourceData), + linkedProcesses: [] + ) + + XCTAssertEqual( + result.status, + .failed("The intended destination identity changed immediately before mutation.") + ) + XCTAssertEqual(fileSystem.storedData(at: sourceURL), sourceData) + } + + func testImportRejectsSymlinkAndPathEscapeDestinations() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let destination = sourceURL.deletingLastPathComponent().appendingPathComponent("linked.plist") + let sourceData = validLaunchAgentData() + let importedData = validLaunchAgentData(label: "com.example.linked") + let fileSystem = InMemoryAutomationFileSystem( + files: [sourceURL: sourceData, destination: importedData], + metadata: [ + destination: AutomationFileMetadata( + canonicalURL: destination, + ownerUID: 501, + isSymbolicLink: true, + modificationDate: Date(timeIntervalSince1970: 1_000), + resourceIdentifier: "symlink-destination" + ), + ] + ) + let operation = AutomationOperation.importRecord(AutomationImportPayload( + destination: destination, + data: importedData, + expectedKind: .launchAgent, + expectedDestinationChecksum: sha256(importedData) + )) + let symlinkManager = makeManager( + fileSystem: fileSystem, + executor: RecordingAutomationMutationExecutor(), + destinationContext: { _, _ in + .fixture( + currentUID: 501, + canonicalPathIsApproved: true, + ownerUID: 501, + isSymlink: true + ) + } + ) + let symlinkResult = await symlinkManager.perform( + operation, + record: Fixtures.userAgent, + expectedChecksum: sha256(sourceData), + linkedProcesses: [] + ) + let escapeManager = makeManager( + fileSystem: fileSystem, + executor: RecordingAutomationMutationExecutor(), + destinationContext: { _, _ in + .fixture(currentUID: 501, canonicalPathIsApproved: false, ownerUID: 501) + } + ) + let escapeResult = await escapeManager.perform( + operation, + record: Fixtures.userAgent, + expectedChecksum: sha256(sourceData), + linkedProcesses: [] + ) + + XCTAssertEqual(symlinkResult.status, .rejected("The intended destination is a symbolic link.")) + XCTAssertEqual( + escapeResult.status, + .rejected("The intended destination resolves outside the approved automation folder.") + ) + } + + func testRollbackRestoresDisabledButLoadedPreStateExactly() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let sourceData = validLaunchAgentData() + let preStateRecord = copyRecord( + Fixtures.userAgent, + sourceChecksum: sha256(sourceData), + enabledState: .disabled, + loadState: .loaded, + state: .running + ) + let fileSystem = InMemoryAutomationFileSystem(files: [sourceURL: sourceData]) + let executor = RecordingAutomationMutationExecutor(results: [.failure(.applyFailed)]) + let originalBirth = ProcessBirthToken(seconds: 1_000, microseconds: 1) + let controlled = classifiedAutomationProcess(pid: 700, birth: originalBirth) + let relaunched = classifiedAutomationProcess( + pid: 701, + birth: ProcessBirthToken(seconds: 2_000, microseconds: 2) + ) + let manager = makeManager( + fileSystem: fileSystem, + executor: executor, + refresh: { snapshot(records: [preStateRecord]) }, + refreshProcesses: { [relaunched.process] } + ) + + let result = await manager.perform( + .disable, + record: preStateRecord, + expectedChecksum: sha256(sourceData), + linkedProcesses: [controlled] + ) + + XCTAssertEqual(result.rollback, .restored(try! XCTUnwrap(result.backup?.id))) + let states = await executor.recordedRestorationStates() + XCTAssertEqual(states, [AutomationPreTransactionState( + enabledState: .disabled, + loadState: .loaded, + linkedProcesses: [AutomationLinkedProcessIdentity( + processID: controlled.process.pid, + birthToken: originalBirth + )] + )]) + } + + func testRecoveryRetentionKeepsTwentyNewestIncludingCurrentRollbackEvidence() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let sourceData = validLaunchAgentData() + let checksum = sha256(sourceData) + let disabledRecord = copyRecord( + Fixtures.userAgent, + sourceChecksum: checksum, + enabledState: .disabled, + state: .disabled + ) + let success = AutomationExecutorResult( + postconditions: [.futureLaunchesDisabled], + evidence: [] + ) + let fileSystem = InMemoryAutomationFileSystem(files: [sourceURL: sourceData]) + let executor = RecordingAutomationMutationExecutor( + results: Array(repeating: .success(success), count: 21) + ) + let manager = makeManager( + fileSystem: fileSystem, + executor: executor, + refresh: { snapshot(records: [disabledRecord]) } + ) + var latestID: AutomationBackup.ID? + for _ in 0..<21 { + let result = await manager.perform( + .disable, + record: Fixtures.userAgent, + expectedChecksum: checksum, + linkedProcesses: [] + ) + XCTAssertEqual(result.status, .succeeded) + latestID = result.backup?.id + } + + let manifests = await manager.restorationManifests() + XCTAssertEqual(manifests.count, 20) + XCTAssertTrue(manifests.contains { $0.id == latestID }) + } + + func testPruneRestoresManifestWhenBackupDeletionFailsAndRestartSeesConsistentPair() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let sourceData = validLaunchAgentData() + let checksum = sha256(sourceData) + let disabledRecord = copyRecord( + Fixtures.userAgent, + sourceChecksum: checksum, + enabledState: .disabled, + state: .disabled + ) + let success = AutomationExecutorResult( + postconditions: [.futureLaunchesDisabled], + evidence: [] + ) + let fileSystem = InMemoryAutomationFileSystem( + files: [sourceURL: sourceData], + failingRemoves: [deterministicFirstBackupURL()] + ) + let executor = RecordingAutomationMutationExecutor( + results: Array(repeating: .success(success), count: 21) + ) + let refreshCount = LockedCallCounter() + let manager = makeManager( + fileSystem: fileSystem, + executor: executor, + refresh: { + _ = refreshCount.incrementAndGet() + return snapshot(records: [disabledRecord]) + } + ) + for _ in 0..<20 { + let result = await manager.perform( + .disable, + record: Fixtures.userAgent, + expectedChecksum: checksum, + linkedProcesses: [] + ) + XCTAssertEqual(result.status, .succeeded) + } + + let failedPrune = await manager.perform( + .disable, + record: Fixtures.userAgent, + expectedChecksum: checksum, + linkedProcesses: [] + ) + + XCTAssertEqual( + failedPrune.status, + .partialFailure("HIGH SEVERITY: recovery evidence installation was only partially completed.") + ) + XCTAssertEqual(failedPrune.fileMutationEvidence?.kind, .remove) + XCTAssertTrue(fileSystem.itemExists(at: deterministicFirstManifestURL())) + XCTAssertTrue(fileSystem.itemExists(at: deterministicFirstBackupURL())) + XCTAssertEqual(refreshCount.current(), 21) + let invocationCount = await executor.invocationCount() + XCTAssertEqual(invocationCount, 20) + + let live = await manager.restorationManifests() + let restarted = makeManager( + fileSystem: fileSystem, + executor: RecordingAutomationMutationExecutor() + ) + let restartedManifests = await restarted.restorationManifests() + XCTAssertEqual(live, restartedManifests) + XCTAssertEqual(restartedManifests.count, 21) + XCTAssertTrue(restartedManifests.contains { + $0.backupURL.path == "/redacted" && $0.id.rawValue.uuidString.hasSuffix("555555555555") + }) + } + + func testPruneReportsAttackerManifestAsObservedButNeverAsRecoveryHandle() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let sourceData = validLaunchAgentData() + let checksum = sha256(sourceData) + let disabledRecord = copyRecord( + Fixtures.userAgent, + sourceChecksum: checksum, + enabledState: .disabled, + state: .disabled + ) + let manifestURL = deterministicFirstManifestURL() + let backupURL = deterministicFirstBackupURL() + let attackerData = Data("attacker-manifest".utf8) + let success = AutomationExecutorResult( + postconditions: [.futureLaunchesDisabled], + evidence: [] + ) + let fileSystem = InMemoryAutomationFileSystem( + files: [sourceURL: sourceData], + failingRemoves: [backupURL], + filesInstalledBeforeRemoveFailure: [backupURL: [manifestURL: attackerData]] + ) + let executor = RecordingAutomationMutationExecutor( + results: Array(repeating: .success(success), count: 21) + ) + let manager = makeManager( + fileSystem: fileSystem, + executor: executor, + refresh: { snapshot(records: [disabledRecord]) } + ) + for _ in 0..<20 { + let result = await manager.perform( + .disable, + record: Fixtures.userAgent, + expectedChecksum: checksum, + linkedProcesses: [] + ) + XCTAssertEqual(result.status, .succeeded) + } + + let failedPrune = await manager.perform( + .disable, + record: Fixtures.userAgent, + expectedChecksum: checksum, + linkedProcesses: [] + ) + + guard case .partialFailure = failedPrune.status else { + return XCTFail("Expected partial failure, got \(failedPrune.status)") + } + let evidence = try! XCTUnwrap(failedPrune.fileMutationEvidence) + XCTAssertEqual(fileSystem.storedData(at: manifestURL), attackerData) + XCTAssertTrue(evidence.observedFiles.contains { $0.fileURL == manifestURL }) + XCTAssertFalse(evidence.recoveryHandles.contains { $0.fileURL == manifestURL }) + XCTAssertTrue(evidence.recoveryHandles.contains { $0.fileURL == backupURL }) + let invocationCount = await executor.invocationCount() + XCTAssertEqual(invocationCount, 20) + + let currentManifests = await manager.restorationManifests() + let restarted = makeManager( + fileSystem: fileSystem, + executor: RecordingAutomationMutationExecutor() + ) + let restartedManifests = await restarted.restorationManifests() + XCTAssertEqual(currentManifests, restartedManifests) + XCTAssertEqual(currentManifests.count, 20) + } + + func testPrePruneManifestAndBackupReplacementsAreNeverMutatedAndMapsMatchRestart() async { + for replacesManifest in [true, false] { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let sourceData = validLaunchAgentData() + let checksum = sha256(sourceData) + let disabledRecord = copyRecord( + Fixtures.userAgent, + sourceChecksum: checksum, + enabledState: .disabled, + state: .disabled + ) + let success = AutomationExecutorResult( + postconditions: [.futureLaunchesDisabled], + evidence: [] + ) + let fileSystem = InMemoryAutomationFileSystem(files: [sourceURL: sourceData]) + let executor = RecordingAutomationMutationExecutor( + results: Array(repeating: .success(success), count: 21) + ) + let manager = makeManager( + fileSystem: fileSystem, + executor: executor, + refresh: { snapshot(records: [disabledRecord]) } + ) + for _ in 0..<20 { + let result = await manager.perform( + .disable, + record: Fixtures.userAgent, + expectedChecksum: checksum, + linkedProcesses: [] + ) + XCTAssertEqual(result.status, .succeeded) + } + + let targetURL = replacesManifest + ? deterministicFirstManifestURL() : deterministicFirstBackupURL() + let pairedURL = replacesManifest + ? deterministicFirstBackupURL() : deterministicFirstManifestURL() + let pairedData = fileSystem.storedData(at: pairedURL) + let attackerData = Data( + (replacesManifest ? "fresh-manifest-before-prune" : "fresh-backup-before-prune").utf8 + ) + fileSystem.setStoredData(attackerData, at: targetURL) + fileSystem.setMetadata(AutomationFileMetadata( + canonicalURL: targetURL, + ownerUID: 501, + isSymbolicLink: false, + modificationDate: Date(timeIntervalSince1970: 9_999), + resourceIdentifier: replacesManifest + ? "fixture:fresh-manifest-before-prune" : "fixture:fresh-backup-before-prune", + permissions: 0o600 + ), for: targetURL) + + let result = await manager.perform( + .disable, + record: Fixtures.userAgent, + expectedChecksum: checksum, + linkedProcesses: [] + ) + + XCTAssertEqual(result.status, .succeeded) + XCTAssertEqual(fileSystem.storedData(at: targetURL), attackerData) + XCTAssertEqual(fileSystem.storedData(at: pairedURL), pairedData) + XCTAssertFalse(fileSystem.recordedOperations.contains { operation in + guard case .remove(let removedURL) = operation else { return false } + return removedURL == targetURL || removedURL == pairedURL + }) + let live = await manager.restorationManifests() + let restarted = makeManager( + fileSystem: fileSystem, + executor: RecordingAutomationMutationExecutor() + ) + let reloaded = await restarted.restorationManifests() + XCTAssertEqual(live, reloaded) + XCTAssertEqual(live.count, 20) + } + } + + func testRawAndUnchangedFirstPruneArtifactFailuresRemainOrdinary() async { + for returnsUnchanged in [false, true] { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let sourceData = validLaunchAgentData() + let checksum = sha256(sourceData) + let disabledRecord = copyRecord( + Fixtures.userAgent, + sourceChecksum: checksum, + enabledState: .disabled, + state: .disabled + ) + let manifestURL = deterministicFirstManifestURL() + let fileSystem = InMemoryAutomationFileSystem( + files: [sourceURL: sourceData], + failingRemoves: returnsUnchanged ? [] : [manifestURL], + unchangedRemoves: returnsUnchanged ? [manifestURL] : [] + ) + let executor = RecordingAutomationMutationExecutor(results: Array( + repeating: .success(AutomationExecutorResult( + postconditions: [.futureLaunchesDisabled], evidence: [] + )), + count: 21 + )) + let manager = makeManager( + fileSystem: fileSystem, + executor: executor, + refresh: { snapshot(records: [disabledRecord]) } + ) + for _ in 0..<20 { + let succeeded = await manager.perform( + .disable, + record: Fixtures.userAgent, + expectedChecksum: checksum, + linkedProcesses: [] + ) + XCTAssertEqual( + succeeded.status, + .succeeded + ) + } + + let failed = await manager.perform( + .disable, + record: Fixtures.userAgent, + expectedChecksum: checksum, + linkedProcesses: [] + ) + + XCTAssertEqual( + failed.status, + .failed("DevScope could not create owner-only recovery evidence.") + ) + XCTAssertNil(failed.fileMutationEvidence) + XCTAssertTrue(fileSystem.itemExists(at: manifestURL)) + XCTAssertTrue(fileSystem.itemExists(at: deterministicFirstBackupURL())) + let live = await manager.restorationManifests() + let restarted = makeManager( + fileSystem: fileSystem, + executor: RecordingAutomationMutationExecutor() + ) + let reloaded = await restarted.restorationManifests() + XCTAssertEqual(live, reloaded) + XCTAssertEqual(live.count, 21) + let invocationCount = await executor.invocationCount() + XCTAssertEqual(invocationCount, 20) + } + } + + func testForgedManifestCannotLoadOrPruneBackupOutsideRecoveryRoot() async { + let recoveryRoot = URL(fileURLWithPath: "/tmp/devscope-fixtures/recovery") + let externalBackup = URL(fileURLWithPath: "/tmp/devscope-fixtures/do-not-delete.backup") + let uuid = UUID(uuidString: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")! + let manifestURL = recoveryRoot.appendingPathComponent( + "\(uuid.uuidString.lowercased()).manifest.plist" + ) + let externalData = Data("external-owner-data".utf8) + let manifest = try! PropertyListSerialization.data( + fromPropertyList: [ + "version": 2, + "id": uuid.uuidString.lowercased(), + "recordID": Fixtures.userAgent.id.rawValue, + "sourcePath": try! XCTUnwrap(Fixtures.userAgent.sourceURL).path, + "backupPath": externalBackup.path, + "checksum": sha256(externalData), + "createdAt": Date(timeIntervalSince1970: 1).timeIntervalSince1970, + "sourceExisted": true, + "ownerUID": 501, + "kind": AutomationKind.launchAgent.rawValue, + "sourceKind": AutomationSourceKind.launchAgent.rawValue, + "authorizedRecordIDs": [Fixtures.userAgent.id.rawValue], + "parentResourceIdentifier": "fixture:/tmp/devscope-fixtures", + ], + format: .binary, + options: 0 + ) + let fileSystem = InMemoryAutomationFileSystem( + files: [manifestURL: manifest, externalBackup: externalData], + metadata: [ + recoveryRoot: AutomationFileMetadata( + canonicalURL: recoveryRoot, + ownerUID: 501, + isSymbolicLink: false, + modificationDate: Date(timeIntervalSince1970: 1), + resourceIdentifier: "recovery-root", + permissions: 0o700 + ), + manifestURL: AutomationFileMetadata( + canonicalURL: manifestURL, + ownerUID: 501, + isSymbolicLink: false, + modificationDate: Date(timeIntervalSince1970: 1), + resourceIdentifier: "forged-manifest", + permissions: 0o600 + ), + externalBackup: AutomationFileMetadata( + canonicalURL: externalBackup, + ownerUID: 501, + isSymbolicLink: false, + modificationDate: Date(timeIntervalSince1970: 1), + resourceIdentifier: "external-backup", + permissions: 0o600 + ), + ], + directories: [recoveryRoot] + ) + let manager = makeManager( + fileSystem: fileSystem, + executor: RecordingAutomationMutationExecutor() + ) + + let manifests = await manager.restorationManifests() + XCTAssertEqual(manifests, []) + XCTAssertEqual(fileSystem.storedData(at: externalBackup), externalData) + } + + func testCronDisableStagesTheCompleteLosslessDocumentBeforeTheExecutor() async { + let transactionURL = URL(fileURLWithPath: "/tmp/devscope-fixtures/current-crontab") + let original = Data("SHELL=/bin/zsh\n# preserve me\n0 * * * * /usr/bin/true\n".utf8) + let intended = Data("SHELL=/bin/zsh\n# preserve me\n# devscope-disabled: 0 * * * * /usr/bin/true\n".utf8) + let originalRecords = await realCronRecords(original) + let intendedRecords = await realCronRecords(intended) + let record = try! XCTUnwrap(originalRecords.first) + let disabled = try! XCTUnwrap(intendedRecords.first) + let fileSystem = InMemoryAutomationFileSystem(files: [transactionURL: original]) + let executor = RecordingAutomationMutationExecutor(results: [ + .success(AutomationExecutorResult( + postconditions: [.futureLaunchesDisabled, .sourceChecksum(sha256(intended))], + evidence: [] + )), + ]) + let manager = makeManager( + fileSystem: fileSystem, + executor: executor, + recoverableSource: recoverableCronSource(transactionURL, fileSystem: fileSystem), + refresh: { snapshot(records: [disabled]) } + ) + + let result = await manager.perform( + .disable, + record: record, + expectedChecksum: sha256(original), + linkedProcesses: [] + ) + + XCTAssertEqual(result.status, .succeeded) + XCTAssertEqual(fileSystem.storedData(at: transactionURL), intended) + } + + func testCronRemoveInstallsTheRemainingDocumentWithoutTrashingTheTransactionSource() async { + let transactionURL = URL(fileURLWithPath: "/tmp/devscope-fixtures/current-crontab") + let original = Data("# preserve me\n0 * * * * /usr/bin/true\n30 * * * * /usr/bin/false\n".utf8) + let intended = Data("# preserve me\n30 * * * * /usr/bin/false\n".utf8) + let records = await realCronRecords(original) + let record = try! XCTUnwrap(records.first) + let remaining = await realCronRecords(intended) + let fileSystem = InMemoryAutomationFileSystem(files: [transactionURL: original]) + let executor = RecordingAutomationMutationExecutor(results: [ + .success(AutomationExecutorResult( + postconditions: [.sourceRemoved, .sourceChecksum(sha256(intended))], + evidence: [] + )), + ]) + let manager = makeManager( + fileSystem: fileSystem, + executor: executor, + recoverableSource: recoverableCronSource(transactionURL, fileSystem: fileSystem), + refresh: { snapshot(records: remaining) } + ) + + let result = await manager.perform( + .remove, + record: record, + expectedChecksum: sha256(original), + linkedProcesses: [] + ) + + XCTAssertEqual(result.status, .succeeded) + XCTAssertEqual(fileSystem.storedData(at: transactionURL), intended) + XCTAssertFalse(fileSystem.recordedOperations.contains { operation in + if case .moveToTrash = operation { return true } + return false + }) + } + + func testConfirmedCronRunNowCanFinishImmediatelyAndRedactsTheExactCommandFromResult() async { + let transactionURL = URL(fileURLWithPath: "/tmp/devscope-fixtures/current-crontab") + let secretCommand = "/usr/bin/curl -H 'Authorization: secret-value' https://example.invalid" + let data = Data("0 * * * * \(secretCommand)\n".utf8) + let records = await realCronRecords(data) + let record = try! XCTUnwrap(records.first) + let fileSystem = InMemoryAutomationFileSystem(files: [transactionURL: data]) + let executor = RecordingAutomationMutationExecutor(results: [ + .success(AutomationExecutorResult( + postconditions: [.targetResolved, .runCompleted], + evidence: [] + )), + ]) + let manager = makeManager( + fileSystem: fileSystem, + executor: executor, + recoverableSource: recoverableCronSource(transactionURL, fileSystem: fileSystem), + refresh: { snapshot(records: [record]) } + ) + let confirmation = AutomationRunToCompletionConfirmation( + recordID: record.id, + sourceChecksum: record.sourceChecksum, + exactCommand: secretCommand + ) + + let result = await manager.perform( + .confirmedRunToCompletion(confirmation), + record: record, + expectedChecksum: record.sourceChecksum, + linkedProcesses: [] + ) + + XCTAssertEqual(result.status, .succeeded) + guard case .confirmedRunToCompletion(let redacted) = result.operation else { + return XCTFail("Expected the typed confirmed operation") + } + XCTAssertEqual(redacted.recordID, record.id) + XCTAssertEqual(redacted.exactCommand, "") + XCTAssertFalse(String(describing: result).contains(secretCommand)) + } + + func testConfirmedCronRunRejectsAStaleRecordGenerationBeforeExecutorInvocation() async { + let transactionURL = URL(fileURLWithPath: "/tmp/devscope-fixtures/current-crontab") + let staleData = Data("0 * * * * /usr/bin/old-command\n".utf8) + let currentData = Data("0 * * * * /usr/bin/current-command\n".utf8) + let staleRecords = await realCronRecords(staleData) + let staleRecord = try! XCTUnwrap(staleRecords.first) + let currentChecksum = try! XCTUnwrap(CronDocumentChecksum.checksum(currentData)) + let fileSystem = InMemoryAutomationFileSystem(files: [transactionURL: currentData]) + let executor = RecordingAutomationMutationExecutor() + let manager = makeManager( + fileSystem: fileSystem, + executor: executor, + recoverableSource: recoverableCronSource(transactionURL, fileSystem: fileSystem) + ) + let confirmation = AutomationRunToCompletionConfirmation( + recordID: staleRecord.id, + sourceChecksum: staleRecord.sourceChecksum, + exactCommand: staleRecord.commandSignature! + ) + + let result = await manager.perform( + .confirmedRunToCompletion(confirmation), + record: staleRecord, + expectedChecksum: currentChecksum, + linkedProcesses: [] + ) + + XCTAssertEqual( + result.status, + .rejected("The confirmed cron command belongs to a stale source generation.") + ) + let invocationCount = await executor.invocationCount() + XCTAssertEqual(invocationCount, 0) + } + + func testCronRollbackDoesNotOverwriteAnExternalEditAfterManagerVerificationFails() async { + let transactionURL = URL(fileURLWithPath: "/tmp/devscope-fixtures/current-crontab") + let original = Data("0 * * * * /usr/bin/true\n".utf8) + let intended = Data("# devscope-disabled: 0 * * * * /usr/bin/true\n".utf8) + let external = Data("15 * * * * /usr/bin/external\n".utf8) + let records = await realCronRecords(original) + let record = try! XCTUnwrap(records.first) + let runner = ScriptedManagerCommandRunner(results: [ + AutomationCommandResult(status: 0, standardOutput: Data(), standardError: Data()), + AutomationCommandResult(status: 0, standardOutput: intended, standardError: Data()), + AutomationCommandResult(status: 0, standardOutput: external, standardError: Data()), + ]) + let fileSystem = InMemoryAutomationFileSystem(files: [transactionURL: original]) + let manager = makeManager( + fileSystem: fileSystem, + executor: CronAutomationExecutor( + runner: runner, + fileSystem: fileSystem, + currentUID: 501 + ), + recoverableSource: recoverableCronSource(transactionURL, fileSystem: fileSystem), + refresh: { snapshot(records: await realCronRecords(original)) } + ) + + let result = await manager.perform( + .disable, + record: record, + expectedChecksum: record.sourceChecksum, + linkedProcesses: [] + ) + + XCTAssertEqual( + result.status, + .partialFailure("HIGH SEVERITY: the operation failed and automatic rollback did not complete.") + ) + XCTAssertEqual(fileSystem.storedData(at: transactionURL), original) + XCTAssertEqual(runner.invocations.count, 3) + XCTAssertEqual( + runner.invocations.filter { $0.executable == "/usr/bin/crontab" && $0.arguments != ["-l"] }.count, + 1, + "Rollback must not install the backup over an externally changed live crontab." + ) + } + + func testLegacyDisableUsesSelectedPathAbsenceAndPreservesRecoveryDescriptor() async { + let descriptorURL = URL(fileURLWithPath: "/tmp/devscope-fixtures/legacy-login-items.json") + let selected = LegacyLoginItemDescriptor( + name: "Backup", + path: "/Applications/Backup.app", + isHidden: false + ) + let records = await realLegacyLoginItemRecords([selected]) + let record = try! XCTUnwrap(records.first) + let descriptorData = legacyRecoveryDescriptorData(record: record, isHidden: false) + XCTAssertEqual(record.sourceChecksum, sha256(descriptorData)) + let fileSystem = InMemoryAutomationFileSystem(files: [descriptorURL: descriptorData]) + let manager = makeManager( + fileSystem: fileSystem, + executor: RecordingAutomationMutationExecutor(results: [ + .success(AutomationExecutorResult(postconditions: [.futureLaunchesDisabled], evidence: [])), + ]), + recoverableSource: recoverableLegacySource(descriptorURL, fileSystem: fileSystem), + refresh: { snapshot(records: [], health: .legacyLoginItem, state: .healthy) } + ) + + let result = await manager.perform( + .disable, + record: record, + expectedChecksum: record.sourceChecksum, + linkedProcesses: [] + ) + + XCTAssertEqual(result.status, .succeeded) + XCTAssertEqual(fileSystem.storedData(at: descriptorURL), descriptorData) + XCTAssertFalse(fileSystem.recordedOperations.contains { if case .moveToTrash = $0 { true } else { false } }) + } + + func testLegacyRemoveAllowsUnrelatedItemsAndPreservesRecoveryDescriptor() async { + let descriptorURL = URL(fileURLWithPath: "/tmp/devscope-fixtures/legacy-login-items.json") + let selected = LegacyLoginItemDescriptor( + name: "Backup", + path: "/Applications/Backup.app", + isHidden: false + ) + let unrelated = LegacyLoginItemDescriptor( + name: "Keep", + path: "/Applications/Keep.app", + isHidden: false + ) + let initialRecords = await realLegacyLoginItemRecords([selected, unrelated]) + let record = try! XCTUnwrap(initialRecords.first { $0.executable == selected.path }) + let descriptorData = legacyRecoveryDescriptorData(record: record, isHidden: false) + XCTAssertEqual(record.sourceChecksum, sha256(descriptorData)) + let remaining = await realLegacyLoginItemRecords([unrelated]) + let fileSystem = InMemoryAutomationFileSystem(files: [descriptorURL: descriptorData]) + let manager = makeManager( + fileSystem: fileSystem, + executor: RecordingAutomationMutationExecutor(results: [ + .success(AutomationExecutorResult(postconditions: [.sourceRemoved], evidence: [])), + ]), + recoverableSource: recoverableLegacySource(descriptorURL, fileSystem: fileSystem), + refresh: { snapshot(records: remaining, health: .legacyLoginItem, state: .healthy) } + ) + + let result = await manager.perform( + .remove, + record: record, + expectedChecksum: record.sourceChecksum, + linkedProcesses: [] + ) + + XCTAssertEqual(result.status, .succeeded) + XCTAssertEqual(fileSystem.storedData(at: descriptorURL), descriptorData) + XCTAssertTrue(remaining.contains { $0.executable == unrelated.path }) + XCTAssertFalse(fileSystem.recordedOperations.contains { if case .moveToTrash = $0 { true } else { false } }) + } + + func testLegacyAbsenceRequiresHealthyAuthoritativeSourceForEveryUnhealthyState() async { + let descriptorURL = URL(fileURLWithPath: "/tmp/devscope-fixtures/legacy-login-items.json") + let selected = LegacyLoginItemDescriptor( + name: "Backup", + path: "/Applications/Backup.app", + isHidden: true + ) + let records = await realLegacyLoginItemRecords([selected]) + let record = try! XCTUnwrap(records.first) + let descriptorData = legacyRecoveryDescriptorData(record: record, isHidden: true) + + for operation in [AutomationOperation.disable, .remove] { + for healthState in [ + AutomationSourceHealthState.failed, + .partial, + .permissionRequired, + ] { + let refreshCount = LockedCallCounter() + let fileSystem = InMemoryAutomationFileSystem(files: [descriptorURL: descriptorData]) + let postcondition: AutomationPostcondition = operation == .disable + ? .futureLaunchesDisabled + : .sourceRemoved + let executor = RecordingAutomationMutationExecutor(results: [ + .success(AutomationExecutorResult(postconditions: [postcondition], evidence: [])), + ]) + let manager = makeManager( + fileSystem: fileSystem, + executor: executor, + recoverableSource: recoverableLegacySource(descriptorURL, fileSystem: fileSystem), + refresh: { + refreshCount.incrementAndGet() == 1 + ? snapshot(records: [], health: .legacyLoginItem, state: healthState) + : snapshot(records: [record], health: .legacyLoginItem, state: .healthy) + } + ) + + let result = await manager.perform( + operation, + record: record, + expectedChecksum: sha256(descriptorData), + linkedProcesses: [] + ) + let message = "\(operation) \(healthState.rawValue)" + XCTAssertNotEqual(result.status, .succeeded, message) + XCTAssertEqual(result.rollback, .restored(try! XCTUnwrap(result.backup?.id)), message) + XCTAssertTrue( + result.verificationEvidence.contains { $0.lowercased().contains("authoritative") }, + message + ) + } + } + } + + func testCronDisableAndStopUsesOwnedGenerationForSnapshotAndPollFailures() async { + let transactionURL = URL(fileURLWithPath: "/tmp/devscope-fixtures/current-crontab") + let original = Data("* * * * * /usr/bin/true\n".utf8) + let intended = Data("# devscope-disabled: * * * * * /usr/bin/true\n".utf8) + let records = await realCronRecords(original) + let record = try! XCTUnwrap(records.first) + let (classified, processes) = managerCronProcessFixture() + + for postInstallFailure in ["snapshot", "poll"] { + let runner = ScriptedManagerCommandRunner(results: [ + AutomationCommandResult(status: 0, standardOutput: Data(), standardError: Data()), + AutomationCommandResult(status: 0, standardOutput: intended, standardError: Data()), + AutomationCommandResult(status: 0, standardOutput: intended, standardError: Data()), + AutomationCommandResult(status: 0, standardOutput: Data(), standardError: Data()), + AutomationCommandResult(status: 0, standardOutput: original, standardError: Data()), + ]) + let signalCount = LockedCallCounter() + let resolver: ProcessKiller.IdentityResolver = { _ in + .identity(ProcessIdentity(process: classified.process)) + } + let fileSystem = InMemoryAutomationFileSystem(files: [transactionURL: original]) + let executor = CronAutomationExecutor( + runner: runner, + fileSystem: fileSystem, + currentUID: 501, + processKiller: ProcessKiller( + identityResolver: resolver, + signalSender: { _, _ in _ = signalCount.incrementAndGet() } + ), + currentProcessID: 99_999, + processIdentityResolver: resolver, + processSnapshot: { + if postInstallFailure == "snapshot" { + throw AutomationExecutorError.processControlUnavailable + } + return processes + }, + now: { Date(timeIntervalSince1970: 3_600) }, + terminationVerificationPolicy: .init(maximumAttempts: 1, interval: .zero), + verificationSleep: { _ in } + ) + let manager = makeManager( + fileSystem: fileSystem, + executor: executor, + recoverableSource: recoverableCronSource(transactionURL, fileSystem: fileSystem), + refresh: { snapshot(records: records) }, + refreshProcesses: { processes } + ) + + let result = await manager.perform( + .disableAndStop, + record: record, + expectedChecksum: record.sourceChecksum, + linkedProcesses: [classified] + ) + + XCTAssertEqual( + result.status, + .partialFailure("HIGH SEVERITY: the operation failed and automatic rollback did not complete."), + postInstallFailure + ) + XCTAssertEqual(fileSystem.storedData(at: transactionURL), original, postInstallFailure) + XCTAssertEqual(runner.invocations.count, 5, postInstallFailure) + XCTAssertEqual(runner.invocations[2].arguments, ["-l"], postInstallFailure) + XCTAssertNotEqual(runner.invocations[3].arguments, ["-l"], postInstallFailure) + XCTAssertEqual(signalCount.current(), postInstallFailure == "poll" ? 1 : 0) + } + } + + func testManagerPassesImmutableVerifiedCronBytesAfterBackupPathMutation() async { + let transactionURL = URL(fileURLWithPath: "/tmp/devscope-fixtures/current-crontab") + let original = Data("0 * * * * /usr/bin/true\n5 * * * * /usr/bin/keep\n".utf8) + let intended = Data("# devscope-disabled: 0 * * * * /usr/bin/true\n5 * * * * /usr/bin/keep\n".utf8) + let altered = Data("0 * * * * /usr/bin/true\n10 * * * * /usr/bin/unrelated-change\n".utf8) + let records = await realCronRecords(original) + let record = try! XCTUnwrap(records.first) + let fileSystem = InMemoryAutomationFileSystem(files: [transactionURL: original]) + let callIndex = LockedCallCounter() + let runner = HookedManagerCommandRunner { command in + switch callIndex.incrementAndGet() { + case 1: + return AutomationCommandResult(status: 0, standardOutput: Data(), standardError: Data()) + case 2: + fileSystem.setReadObserver { url, _ in + guard url.pathExtension == "backup" else { return } + fileSystem.setReadObserver(nil) + fileSystem.setStoredData(altered, at: url) + } + return AutomationCommandResult(status: 0, standardOutput: intended, standardError: Data()) + case 3: + return AutomationCommandResult(status: 0, standardOutput: intended, standardError: Data()) + case 4: + XCTAssertEqual(fileSystem.storedData(at: URL(fileURLWithPath: command.arguments[0])), original) + return AutomationCommandResult(status: 0, standardOutput: Data(), standardError: Data()) + default: + return AutomationCommandResult(status: 0, standardOutput: original, standardError: Data()) + } + } + let manager = makeManager( + fileSystem: fileSystem, + executor: CronAutomationExecutor(runner: runner, fileSystem: fileSystem, currentUID: 501), + recoverableSource: recoverableCronSource(transactionURL, fileSystem: fileSystem), + refresh: { snapshot(records: records) } + ) + + let result = await manager.perform( + .disable, + record: record, + expectedChecksum: record.sourceChecksum, + linkedProcesses: [] + ) + + XCTAssertEqual(result.status, .failed("The automation operation failed; the prior source and loaded state were restored.")) + XCTAssertEqual(runner.invocations.count, 5) + XCTAssertEqual(fileSystem.storedData(at: deterministicFirstBackupURL()), altered) + } + + func testManagerPassesImmutableVerifiedLegacyHiddenValueAfterBackupPathMutation() async { + let descriptorURL = URL(fileURLWithPath: "/tmp/devscope-fixtures/legacy-login-items.json") + let selected = LegacyLoginItemDescriptor( + name: "Backup", + path: "/Applications/Backup.app", + isHidden: true + ) + let records = await realLegacyLoginItemRecords([selected]) + let record = try! XCTUnwrap(records.first) + let original = legacyRecoveryDescriptorData(record: record, isHidden: true) + let altered = legacyRecoveryDescriptorData(record: record, isHidden: false) + let fileSystem = InMemoryAutomationFileSystem(files: [descriptorURL: original]) + let commandIndex = LockedCallCounter() + let runner = HookedManagerCommandRunner { command in + if commandIndex.incrementAndGet() == 1 { + fileSystem.setReadObserver { url, _ in + guard url.pathExtension == "backup" else { return } + fileSystem.setReadObserver(nil) + fileSystem.setStoredData(altered, at: url) + } + } else { + XCTAssertEqual(Array(command.arguments.suffix(4)), ["--", "add", selected.path, "true"]) + } + return AutomationCommandResult(status: 0, standardOutput: Data(), standardError: Data()) + } + let listing = SequencedManagerLegacyLoginItemListing(values: [[], [selected]]) + let manager = makeManager( + fileSystem: fileSystem, + executor: LegacyLoginItemAutomationExecutor( + runner: runner, + listing: listing, + currentUID: 501 + ), + recoverableSource: recoverableLegacySource(descriptorURL, fileSystem: fileSystem), + refresh: { snapshot(records: records, health: .legacyLoginItem, state: .healthy) } + ) + + let result = await manager.perform( + .disable, + record: record, + expectedChecksum: sha256(original), + linkedProcesses: [] + ) + + XCTAssertEqual(result.status, .failed("The automation operation failed; the prior source and loaded state were restored.")) + XCTAssertEqual(runner.invocations.count, 2) + XCTAssertEqual(fileSystem.storedData(at: deterministicFirstBackupURL()), altered) + } + + func testBackupCorruptionBeforeManagerAuthenticationNeverReachesRecoveryExecutor() async { + let sourceURL = try! XCTUnwrap(Fixtures.userAgent.sourceURL) + let sourceData = validLaunchAgentData() + let fileSystem = InMemoryAutomationFileSystem(files: [sourceURL: sourceData]) + let executor = RecordingAutomationMutationExecutor( + results: [.failure(.applyFailed)], + onApply: { + fileSystem.setStoredData(Data("corrupted".utf8), at: deterministicFirstBackupURL()) + } + ) + let manager = makeManager(fileSystem: fileSystem, executor: executor) + + let result = await manager.perform( + .startNow, + record: Fixtures.userAgent, + expectedChecksum: sha256(sourceData), + linkedProcesses: [] + ) + + XCTAssertEqual( + result.status, + .partialFailure("HIGH SEVERITY: the operation failed and automatic rollback did not complete.") + ) + let restorationStates = await executor.recordedRestorationStates() + XCTAssertTrue(restorationStates.isEmpty) + } +} + +private extension AutomationManagerTests { + func makeManager( + fileSystem: InMemoryAutomationFileSystem, + executor: any AutomationMutationApplying, + context: AutomationCapabilityContext = .fixture( + currentUID: 501, + canonicalPathIsApproved: true, + ownerUID: 501 + ), + destinationContext: @escaping @Sendable ( + AutomationRecord, + URL + ) throws -> AutomationCapabilityContext = { _, _ in + .fixture(currentUID: 501, canonicalPathIsApproved: true, ownerUID: 501) + }, + recoverableSource: @escaping @Sendable ( + AutomationRecord + ) async throws -> AutomationRecoverableSource = { _ in + throw AutomationManagerConfigurationError.recoverableSourceUnavailable + }, + refresh: @escaping @Sendable () async -> AutomationInventorySnapshot = { + Fixtures.inventoryGeneration7 + }, + refreshProcesses: @escaping @Sendable () async throws -> [DevProcess] = { [] } + ) -> AutomationManager { + let uuidSequence = DeterministicAutomationUUIDSequence() + return AutomationManager( + fileSystem: fileSystem, + executor: executor, + capabilityContext: { _ in context }, + destinationContext: destinationContext, + recoverableSource: recoverableSource, + refresh: refresh, + refreshProcesses: refreshProcesses, + backupDirectory: URL(fileURLWithPath: "/tmp/devscope-fixtures/recovery"), + currentUID: 501, + now: { Date(timeIntervalSince1970: 20_000) }, + makeUUID: { uuidSequence.next() } + ) + } +} + +private final class DeterministicAutomationUUIDSequence: @unchecked Sendable { + private let lock = NSLock() + private var sequence: UInt8 = 0x55 + + func next() -> UUID { + lock.withLock { + defer { sequence &+= 1 } + return UUID(uuid: ( + 0x11, 0x11, 0x11, 0x11, + 0x22, 0x22, + 0x33, 0x33, + 0x44, 0x44, + 0x55, 0x55, 0x55, 0x55, 0x55, sequence + )) + } + } +} + +private final class LockedCallCounter: @unchecked Sendable { + private let lock = NSLock() + private var value = 0 + + func incrementAndGet() -> Int { + lock.withLock { + value += 1 + return value + } + } + + func current() -> Int { lock.withLock { value } } +} + +private final class LockedDataCapture: @unchecked Sendable { + private let lock = NSLock() + private var value: Data? + + func set(_ data: Data) { lock.withLock { value = data } } + + func current() -> Data? { lock.withLock { value } } +} + +private final class LockedFlag: @unchecked Sendable { + private let lock = NSLock() + private var isSet = false + + var value: Bool { lock.withLock { isSet } } + + func set() { + lock.withLock { isSet = true } + } +} + +private final class ScriptedManagerCommandRunner: AutomationCommandRunning, @unchecked Sendable { + private let lock = NSLock() + private var results: [AutomationCommandResult] + private var commands: [AutomationCommand] = [] + + init(results: [AutomationCommandResult]) { + self.results = results + } + + var invocations: [AutomationCommand] { + lock.withLock { commands } + } + + func run(_ command: AutomationCommand) async throws -> AutomationCommandResult { + lock.withLock { + commands.append(command) + guard !results.isEmpty else { + return AutomationCommandResult(status: 0, standardOutput: Data(), standardError: Data()) + } + return results.removeFirst() + } + } +} + +private final class HookedManagerCommandRunner: AutomationCommandRunning, @unchecked Sendable { + private let lock = NSLock() + private let handler: @Sendable (AutomationCommand) -> AutomationCommandResult + private var commands: [AutomationCommand] = [] + + init(handler: @escaping @Sendable (AutomationCommand) -> AutomationCommandResult) { + self.handler = handler + } + + var invocations: [AutomationCommand] { lock.withLock { commands } } + + func run(_ command: AutomationCommand) async throws -> AutomationCommandResult { + lock.withLock { commands.append(command) } + return handler(command) + } +} + +private final class SequencedManagerLegacyLoginItemListing: LegacyLoginItemListing, @unchecked Sendable { + private let lock = NSLock() + private var values: [[LegacyLoginItemDescriptor]] + + init(values: [[LegacyLoginItemDescriptor]]) { self.values = values } + + func currentUserLoginItems() async throws -> [LegacyLoginItemDescriptor] { + lock.withLock { values.isEmpty ? [] : values.removeFirst() } + } +} + +private enum AutomationManagerFixtureError: Error { + case applyFailed + case rollbackFailed +} + +private actor RecordingAutomationMutationExecutor: AutomationMutationApplying { + private var invocations = 0 + private var operations: [AutomationOperation] = [] + private var results: [Result] + private let delayNanoseconds: UInt64 + private let restorationFails: Bool + private let onApply: @Sendable () -> Void + private var activeInvocations = 0 + private var maximumActiveInvocations = 0 + private var restorationStates: [AutomationPreTransactionState] = [] + + init( + results: [Result] = [ + .success(AutomationExecutorResult(postconditions: [], evidence: [])), + ], + delayNanoseconds: UInt64 = 0, + restorationFails: Bool = false, + onApply: @escaping @Sendable () -> Void = {} + ) { + self.results = results + self.delayNanoseconds = delayNanoseconds + self.restorationFails = restorationFails + self.onApply = onApply + } + + func apply( + _ operation: AutomationOperation, + to record: AutomationRecord, + linkedProcesses: [ClassifiedDevProcess], + proposedSourceURL: URL? + ) async throws -> AutomationExecutorResult { + invocations += 1 + onApply() + operations.append(operation) + activeInvocations += 1 + maximumActiveInvocations = max(maximumActiveInvocations, activeInvocations) + defer { activeInvocations -= 1 } + guard !results.isEmpty else { + return AutomationExecutorResult(postconditions: [], evidence: []) + } + let result = results.removeFirst() + if delayNanoseconds > 0 { + try await Task.sleep(nanoseconds: delayNanoseconds) + } + return try result.get() + } + + func invocationCount() -> Int { + invocations + } + + func recordedOperations() -> [AutomationOperation] { + operations + } + + func maximumConcurrency() -> Int { + maximumActiveInvocations + } + + func recordedRestorationStates() -> [AutomationPreTransactionState] { + restorationStates + } + + func restorePreTransactionState( + _ state: AutomationPreTransactionState, + for record: AutomationRecord, + recovery: AutomationVerifiedRecoveryInput, + linkedProcesses: [ClassifiedDevProcess], + expectedAppliedState: AutomationExecutorAppliedState? + ) async throws -> AutomationExecutorResult { + if restorationFails { throw AutomationManagerFixtureError.rollbackFailed } + restorationStates.append(state) + return AutomationExecutorResult( + postconditions: [.preTransactionStateRestored(state)], + evidence: [] + ) + } +} + +private func validLaunchAgentData( + label: String = "com.example.backup", + arguments: [String] = ["14400"], + environment: [String: String] = [:], + workingDirectory: String? = nil +) -> Data { + var propertyList: [String: Any] = [ + "Label": label, + "ProgramArguments": ["/bin/sleep"] + arguments, + "RunAtLoad": true, + "StartInterval": 14_400, + ] + if !environment.isEmpty { + propertyList["EnvironmentVariables"] = environment + } + if let workingDirectory { + propertyList["WorkingDirectory"] = workingDirectory + } + return try! PropertyListSerialization.data( + fromPropertyList: propertyList, + format: .xml, + options: 0 + ) +} + +private func snapshot( + records: [AutomationRecord], + health sourceKind: AutomationSourceKind? = nil, + state: AutomationSourceHealthState = .healthy +) -> AutomationInventorySnapshot { + var health = Fixtures.inventoryGeneration7.health + if let sourceKind { + health[sourceKind] = AutomationSourceHealth( + kind: sourceKind, + state: state, + message: state == .healthy ? nil : "fixture \(state.rawValue)", + refreshedAt: Date(timeIntervalSince1970: 20_001) + ) + } + return AutomationInventorySnapshot( + generation: 8, + records: records, + health: health, + refreshedAt: Date(timeIntervalSince1970: 20_001) + ) +} + +private func copyRecord( + _ record: AutomationRecord, + id: AutomationRecord.ID? = nil, + label: String? = nil, + sourceURL: URL? = nil, + sourceChecksum: String? = nil, + enabledState: AutomationEnabledState? = nil, + loadState: AutomationLoadState? = nil, + state: AutomationState? = nil +) -> AutomationRecord { + AutomationRecord( + id: id ?? record.id, + kind: record.kind, + sourceKind: record.sourceKind, + label: label ?? record.label, + displayName: record.displayName, + providerBundleIdentifier: record.providerBundleIdentifier, + ownerUID: record.ownerUID, + ownership: record.ownership, + executable: record.executable, + arguments: record.arguments, + commandSignature: record.commandSignature, + environment: record.environment, + workingDirectory: record.workingDirectory, + schedule: record.schedule, + sourceURL: sourceURL ?? record.sourceURL, + sourceChecksum: sourceChecksum ?? record.sourceChecksum, + enabledState: enabledState ?? record.enabledState, + loadState: loadState ?? record.loadState, + approvalState: record.approvalState, + state: state ?? record.state, + evidence: record.evidence, + capabilities: record.capabilities, + validationFindings: record.validationFindings + ) +} + +private func sha256(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() +} + +private func deterministicFirstBackupURL() -> URL { + URL(fileURLWithPath: "/tmp/devscope-fixtures/recovery") + .appendingPathComponent("11111111-2222-3333-4444-555555555555.backup") +} + +private func deterministicFirstManifestURL() -> URL { + URL(fileURLWithPath: "/tmp/devscope-fixtures/recovery") + .appendingPathComponent("11111111-2222-3333-4444-555555555555.manifest.plist") +} + +private func classifiedAutomationProcess( + pid: Int32, + birth: ProcessBirthToken +) -> ClassifiedDevProcess { + ClassifiedDevProcess( + process: DevProcess( + pid: pid, + parentPID: 1, + executable: "/bin/sleep", + command: "/bin/sleep 14400", + argumentVector: ["/bin/sleep", "14400"], + birthToken: birth, + launchLabel: Fixtures.userAgent.label + ), + classification: DevProcessClassification( + kind: .backgroundAgent, + displayName: "Automation", + projectHint: nil + ) + ) +} + +private func cronRecord(entry: CronEntry, checksum: String) -> AutomationRecord { + AutomationRecord( + id: CronParser.recordID(for: entry, ownerUID: 501), + kind: .cron, + sourceKind: .crontab, + label: "Cron entry 1", + displayName: "Cron entry 1", + providerBundleIdentifier: nil, + ownerUID: 501, + ownership: .user, + executable: nil, + arguments: [], + commandSignature: entry.command, + environment: entry.environment, + workingDirectory: nil, + schedule: entry.schedule, + sourceURL: nil, + sourceChecksum: checksum, + enabledState: .enabled, + loadState: .unknown, + approvalState: .notApplicable, + state: .idle, + evidence: [], + capabilities: [], + validationFindings: [] + ) +} + +private func realCronRecords(_ data: Data) async -> [AutomationRecord] { + await CronAutomationSource( + commandRunner: RecordingAutomationCommandRunner(result: .success(AutomationCommandResult( + status: 0, + standardOutput: data, + standardError: Data() + ))), + currentUID: 501, + currentUsername: "test-user" + ).snapshot().records +} + +private func realLegacyLoginItemRecords( + _ items: [LegacyLoginItemDescriptor] +) async -> [AutomationRecord] { + await LegacyLoginItemAutomationSource( + adapter: StaticManagerLegacyLoginItemListing(items: items), + currentUID: 501 + ).snapshot().records +} + +private func managerCronProcessFixture() -> ( + classified: ClassifiedDevProcess, + processes: [DevProcess] +) { + let carrier = DevProcess( + pid: 66_101, + parentPID: 66_100, + executable: "/bin/sh", + command: "/bin/sh -c /usr/bin/true", + argumentVector: ["/bin/sh", "-c", "/usr/bin/true"], + resourceUsage: DevProcessResourceUsage( + cpuPercent: 0, + residentMemoryBytes: 1, + elapsedTime: "00:00:00" + ), + birthToken: ProcessBirthToken(seconds: 3_600, microseconds: 1) + ) + let daemon = DevProcess( + pid: 66_100, + parentPID: 1, + executable: "/usr/sbin/cron", + command: "/usr/sbin/cron", + argumentVector: ["/usr/sbin/cron"], + birthToken: ProcessBirthToken(seconds: 1, microseconds: 0) + ) + return ( + ClassifiedDevProcess( + process: carrier, + classification: DevProcessClassification(kind: .shell, displayName: "cron", projectHint: nil) + ), + [daemon, carrier] + ) +} + +private func legacyRecoveryDescriptorData( + record: AutomationRecord, + isHidden: Bool +) -> Data { + try! LegacyLoginItemRecoveryDocument.encode( + selectedRecord: record, + descriptor: LegacyLoginItemDescriptor( + name: record.label, + path: record.executable!, + isHidden: isHidden + ), + currentUID: 501 + ) +} + +private struct StaticManagerLegacyLoginItemListing: LegacyLoginItemListing { + let items: [LegacyLoginItemDescriptor] + + func currentUserLoginItems() async throws -> [LegacyLoginItemDescriptor] { items } +} + +private func recoverableCronSource( + _ transactionURL: URL, + fileSystem: InMemoryAutomationFileSystem +) -> @Sendable (AutomationRecord) async throws -> AutomationRecoverableSource { + { _ in + let data = try fileSystem.read(transactionURL) + return AutomationRecoverableSource( + transactionURL: transactionURL, + data: data, + checksum: sha256(data), + metadata: try fileSystem.metadata(for: transactionURL) + ) + } +} + +private func recoverableLegacySource( + _ transactionURL: URL, + fileSystem: InMemoryAutomationFileSystem +) -> @Sendable (AutomationRecord) async throws -> AutomationRecoverableSource { + { _ in + let data = try fileSystem.read(transactionURL) + return AutomationRecoverableSource( + transactionURL: transactionURL, + data: data, + checksum: sha256(data), + metadata: try fileSystem.metadata(for: transactionURL) + ) + } +} diff --git a/Tests/DevScopeCoreTests/AutomationModelsTests.swift b/Tests/DevScopeCoreTests/AutomationModelsTests.swift new file mode 100644 index 0000000..c2bdf3f --- /dev/null +++ b/Tests/DevScopeCoreTests/AutomationModelsTests.swift @@ -0,0 +1,127 @@ +import XCTest +@testable import DevScopeCore + +final class AutomationModelsTests: XCTestCase { + func testRecordIdentityDoesNotContainCommandSecrets() { + let id = AutomationRecord.ID( + source: .launchAgent, + ownerUID: 501, + label: "com.example.backup", + sourcePath: "/Users/example/Library/LaunchAgents/com.example.backup.plist" + ) + + XCTAssertEqual(id.rawValue.count, 64) + XCTAssertFalse(id.rawValue.contains("example")) + } + + func testRecordIdentityUsesEveryCanonicalSourceComponent() { + let base = AutomationRecord.ID( + source: .launchAgent, + ownerUID: 501, + label: "com.example.fixture", + sourcePath: "/tmp/devscope-fixtures/folder/../fixture.plist" + ) + + XCTAssertEqual(base, AutomationRecord.ID( + source: .launchAgent, + ownerUID: 501, + label: "com.example.fixture", + sourcePath: "/tmp/devscope-fixtures/fixture.plist" + )) + XCTAssertNotEqual(base, AutomationRecord.ID( + source: .launchDaemon, + ownerUID: 501, + label: "com.example.fixture", + sourcePath: "/tmp/devscope-fixtures/fixture.plist" + )) + XCTAssertNotEqual(base, AutomationRecord.ID( + source: .launchAgent, + ownerUID: 502, + label: "com.example.fixture", + sourcePath: "/tmp/devscope-fixtures/fixture.plist" + )) + XCTAssertNotEqual(base, AutomationRecord.ID( + source: .launchAgent, + ownerUID: 501, + label: "com.example.other-fixture", + sourcePath: "/tmp/devscope-fixtures/fixture.plist" + )) + XCTAssertNotEqual(base, AutomationRecord.ID( + source: .launchAgent, + ownerUID: 501, + label: "com.example.fixture", + sourcePath: "/tmp/devscope-fixtures/other-fixture.plist" + )) + } + + func testRecordIdentityDistinguishesDelimiterAmbiguousFieldTuples() { + let delimiterInPath = AutomationRecord.ID( + source: .launchAgent, + ownerUID: 501, + label: "com.example.fixture", + sourcePath: "/tmp/first\u{1F}/tmp/second.plist" + ) + let delimiterInLabel = AutomationRecord.ID( + source: .launchAgent, + ownerUID: 501, + label: "com.example.fixture\u{1F}/tmp/first", + sourcePath: "/tmp/second.plist" + ) + + XCTAssertNotEqual(delimiterInPath, delimiterInLabel) + } + + func testSnapshotFactoriesPreserveRecordsAndSourceFailure() { + let refreshedAt = Date(timeIntervalSince1970: 1_000) + let record = AutomationRecord( + id: AutomationRecord.ID(rawValue: "record-id"), + kind: .launchAgent, + sourceKind: .launchAgent, + label: "com.example.synthetic-backup", + displayName: "Synthetic Backup", + providerBundleIdentifier: nil, + ownerUID: 501, + ownership: .user, + executable: "/bin/sleep", + arguments: ["60"], + environment: [:], + workingDirectory: nil, + schedule: AutomationSchedule(triggers: [.runAtLoad], summary: "At load"), + sourceURL: URL(fileURLWithPath: "/tmp/com.example.synthetic-backup.plist"), + sourceChecksum: nil, + enabledState: .enabled, + loadState: .loaded, + approvalState: .notApplicable, + state: .running, + evidence: [AutomationEvidence( + strength: .strong, + source: "synthetic fixture", + detail: "Exact label and executable" + )], + capabilities: [.disableAndStop], + validationFindings: [] + ) + + let healthy = AutomationSourceSnapshot.healthy( + kind: .launchAgent, + records: [record], + refreshedAt: refreshedAt + ) + let failed = AutomationSourceSnapshot.failed( + kind: .serviceManagement, + message: "Synthetic source failure", + refreshedAt: refreshedAt + ) + + XCTAssertEqual(healthy.records, [record]) + XCTAssertEqual(healthy.health, AutomationSourceHealth( + kind: .launchAgent, + state: .healthy, + message: nil, + refreshedAt: refreshedAt + )) + XCTAssertTrue(failed.records.isEmpty) + XCTAssertEqual(failed.health.state, .failed) + XCTAssertEqual(failed.health.message, "Synthetic source failure") + } +} diff --git a/Tests/DevScopeCoreTests/AutomationNotificationPolicyTests.swift b/Tests/DevScopeCoreTests/AutomationNotificationPolicyTests.swift new file mode 100644 index 0000000..f0432ed --- /dev/null +++ b/Tests/DevScopeCoreTests/AutomationNotificationPolicyTests.swift @@ -0,0 +1,80 @@ +import XCTest +@testable import DevScopeCore + +final class AutomationNotificationPolicyTests: XCTestCase { + func testDefaultPreferencesStayOffAndOnlyDirectOptInRequestsAuthorization() { + var policy = AutomationNotificationPolicy(maximumRetainedEventIdentities: 8) + + XCTAssertEqual(policy.preferences, AutomationNotificationPreferences()) + XCTAssertEqual( + policy.setPreference(.unexpectedExit, isEnabled: false), + .none + ) + XCTAssertEqual( + policy.setPreference(.unexpectedExit, isEnabled: true), + .requestAuthorization + ) + } + + func testVerifiedEventsUseSafeContentAndBoundedIdentityDeduplication() { + var policy = AutomationNotificationPolicy(maximumRetainedEventIdentities: 2) + XCTAssertEqual(policy.setPreference(.unexpectedExit, isEnabled: true), .requestAuthorization) + policy.recordAuthorizationResult(granted: true) + + let first = unexpectedExit(label: "first", pid: 41) + let second = unexpectedExit(label: "second", pid: 42) + let third = unexpectedExit(label: "third", pid: 43) + + let content = policy.notification(for: first) + XCTAssertEqual(content?.title, "Automation stopped unexpectedly") + XCTAssertEqual(content?.body, "A verified user automation exited unexpectedly.") + XCTAssertNil(policy.notification(for: first), "The same verified event must be deduplicated.") + XCTAssertNotNil(policy.notification(for: second)) + XCTAssertNotNil(policy.notification(for: third)) + XCTAssertNotNil( + policy.notification(for: first), + "The oldest event identity should be eligible again after bounded eviction." + ) + + XCTAssertEqual( + policy.setPreference(.repeatedFailure, isEnabled: true), + .none, + "An already authorized notifier must not prompt for each preference." + ) + } + + func testAuthorizationRequestIsSingleFlightButCanRetryAfterDenial() { + var policy = AutomationNotificationPolicy(maximumRetainedEventIdentities: 8) + + XCTAssertEqual( + policy.setPreference(.unexpectedExit, isEnabled: true), + .requestAuthorization + ) + XCTAssertEqual( + policy.setPreference(.repeatedFailure, isEnabled: true), + .none, + "A second opt-in while the first system prompt is pending must not request again." + ) + policy.recordAuthorizationResult(granted: false) + XCTAssertEqual( + policy.setPreference(.crossedLongRunningThreshold, isEnabled: true), + .requestAuthorization, + "A later direct opt-in may retry after denial." + ) + } + + private func unexpectedExit(label: String, pid: Int32) -> AutomationEvent { + .unexpectedExit( + recordID: AutomationRecord.ID( + source: .launchAgent, + ownerUID: 501, + label: label, + sourcePath: "/Users/test/Library/LaunchAgents/\(label).plist" + ), + process: ProcessIdentity( + pid: pid, + birthToken: ProcessBirthToken(seconds: UInt64(pid), microseconds: 0) + ) + ) + } +} diff --git a/Tests/DevScopeCoreTests/AutomationPresentationTests.swift b/Tests/DevScopeCoreTests/AutomationPresentationTests.swift new file mode 100644 index 0000000..cf1f149 --- /dev/null +++ b/Tests/DevScopeCoreTests/AutomationPresentationTests.swift @@ -0,0 +1,988 @@ +import XCTest +@testable import DevScopeCore + +final class AutomationPresentationTests: XCTestCase { + func testNewestContextProjectsOneCoherentInventoryAndThresholdGeneration() { + let firstContext = AutomationPresentationContext( + inventory: Fixtures.inventoryGeneration7, + longRunningThreshold: 14_400 + ) + let nextInventory = AutomationInventorySnapshot( + generation: 8, + records: [], + health: [:], + refreshedAt: Date(timeIntervalSince1970: 20_000) + ) + let newestContext = AutomationPresentationContext( + inventory: nextInventory, + longRunningThreshold: 18_000 + ) + + let first = firstContext.build( + processes: [Fixtures.runningBackup], + now: Date(timeIntervalSince1970: 10_000) + ) + let newest = newestContext.build( + processes: [Fixtures.runningBackup], + now: Date(timeIntervalSince1970: 10_002) + ) + + XCTAssertEqual(first.inventory.generation, 7) + XCTAssertEqual(first.longRunningProcessIDs, [Fixtures.runningBackup.pid]) + XCTAssertEqual(newest.inventory.generation, 8) + XCTAssertTrue(newest.linksByProcessID.isEmpty) + XCTAssertTrue(newest.longRunningProcessIDs.isEmpty) + } + + func testPresentationContextSanitizesInvalidThresholdsAtTheBoundary() { + XCTAssertEqual( + AutomationPresentationContext( + inventory: Fixtures.inventoryGeneration7, + longRunningThreshold: -.infinity + ).longRunningThreshold, + AutomationPresentationContext.defaultLongRunningThreshold + ) + XCTAssertEqual( + AutomationPresentationContext( + inventory: Fixtures.inventoryGeneration7, + longRunningThreshold: -1 + ).longRunningThreshold, + 0 + ) + } + + + func testBuildKeepsInventoryLinksAndIndependentLongRunningStateCoherent() { + let unautomated = DevProcess( + pid: 77, parentPID: 1, executable: "/bin/other", command: "/bin/other", + resourceUsage: DevProcessResourceUsage( + cpuPercent: 0, residentMemoryBytes: 0, elapsedTime: "04:00:00" + ) + ) + + let snapshot = AutomationPresentationSnapshot.build( + inventory: Fixtures.inventoryGeneration7, + processes: [Fixtures.runningBackup, unautomated], + longRunningThreshold: 14_400, + now: Date(timeIntervalSince1970: 10_000) + ) + + XCTAssertEqual(snapshot.inventory.generation, 7) + XCTAssertEqual(snapshot.inventory.records.first?.state, .running) + XCTAssertEqual(snapshot.linksByProcessID[Fixtures.runningBackup.pid]?.recordID, Fixtures.userAgent.id) + XCTAssertEqual( + AutomationPresentation.uniquelyStrongLinkedProcessIdentities( + for: Fixtures.userAgent.id, + linksByProcessID: snapshot.allLinksByProcessID + ), + [ProcessIdentity(process: Fixtures.runningBackup)] + ) + XCTAssertEqual(snapshot.longRunningProcessIDs, [Fixtures.runningBackup.pid, unautomated.pid]) + XCTAssertNil(snapshot.processIdentitiesByID[unautomated.pid]?.birthToken) + XCTAssertTrue(snapshot.isProcessSnapshotComplete) + } + + func testPresentationPreservesEveryStrongRecordLinkForOneProcess() { + let launchAgent = copiedRecord( + Fixtures.userAgent, + label: Fixtures.userAgent.label, + ownership: .user, + state: .idle, + sourceKind: .launchAgent + ) + let launchDaemon = copiedRecord( + Fixtures.userAgent, + label: Fixtures.userAgent.label, + ownership: .user, + state: .idle, + sourceKind: .launchDaemon + ) + let snapshot = AutomationPresentationSnapshot.build( + inventory: AutomationInventorySnapshot( + generation: 9, + records: [launchAgent, launchDaemon], + health: [:], + refreshedAt: Date(timeIntervalSince1970: 9) + ), + processes: [Fixtures.runningBackup], + longRunningThreshold: 14_400, + now: Date(timeIntervalSince1970: 10_000) + ) + + XCTAssertEqual( + Set(snapshot.allLinksByProcessID[Fixtures.runningBackup.pid]?.map(\.recordID) ?? []), + [launchAgent.id, launchDaemon.id] + ) + XCTAssertNotNil(snapshot.linksByProcessID[Fixtures.runningBackup.pid]) + XCTAssertEqual( + AutomationPresentation.uniquelyStrongLinkedProcessIdentities( + for: launchAgent.id, + linksByProcessID: snapshot.allLinksByProcessID + ), + Set() + ) + } + + func testAmbiguousNilLabelProvenancePublishesNoControllablePresentationLink() { + let first = copiedRecord( + Fixtures.userAgent, + label: "com.example.first", + ownership: .user, + state: .idle, + sourceKind: .launchAgent + ) + let second = copiedRecord( + Fixtures.userAgent, + label: "com.example.second", + ownership: .user, + state: .idle, + sourceKind: .launchAgent + ) + let inventory = AutomationInventorySnapshot( + generation: 1, + records: [first, second], + health: [:], + refreshedAt: Date(timeIntervalSince1970: 10_000) + ) + + let snapshot = AutomationPresentationSnapshot.build( + inventory: inventory, + processes: [Fixtures.runningBackup], + longRunningThreshold: 14_400, + now: Date(timeIntervalSince1970: 10_000) + ) + + XCTAssertTrue(snapshot.linksByProcessID.isEmpty) + XCTAssertEqual(snapshot.inventory.records.map(\.state), [.idle, .idle]) + } + + func testThresholdOnlyChangeRecomputesDurationWithoutChangingLinksOrInventory() { + let first = AutomationPresentationSnapshot.build( + inventory: Fixtures.inventoryGeneration7, processes: [Fixtures.runningBackup], + longRunningThreshold: 14_400, now: Date(timeIntervalSince1970: 10_000) + ) + let second = AutomationPresentationSnapshot.build( + inventory: Fixtures.inventoryGeneration7, processes: [Fixtures.runningBackup], + longRunningThreshold: 18_000, now: Date(timeIntervalSince1970: 10_000) + ) + + XCTAssertEqual(first.inventory, second.inventory) + XCTAssertEqual(first.linksByProcessID, second.linksByProcessID) + XCTAssertEqual(first.longRunningProcessIDs, [Fixtures.runningBackup.pid]) + XCTAssertTrue(second.longRunningProcessIDs.isEmpty) + } + + func testRecycledPIDCannotRetainOldPresentationLinkIdentity() { + let old = AutomationPresentationSnapshot.build( + inventory: Fixtures.inventoryGeneration7, processes: [Fixtures.runningBackup], + longRunningThreshold: 14_400, now: Date(timeIntervalSince1970: 10_000) + ) + let replacement = DevProcess( + pid: Fixtures.runningBackup.pid, parentPID: 1, executable: "/bin/other", + command: "/bin/other", birthToken: ProcessBirthToken(seconds: 20_000, microseconds: 9) + ) + let current = AutomationPresentationSnapshot.build( + inventory: Fixtures.inventoryGeneration7, processes: [replacement], + longRunningThreshold: 14_400, now: Date(timeIntervalSince1970: 20_001) + ) + + XCTAssertNotNil(old.linksByProcessID[replacement.pid]) + XCTAssertNil(current.linksByProcessID[replacement.pid]) + XCTAssertEqual(current.processIdentitiesByID[replacement.pid]?.birthToken, replacement.birthToken) + } + + func testDuplicatePIDRowsAlwaysChooseNewestBirthRegardlessOfInputOrder() { + let old = Fixtures.runningBackup + let newest = DevProcess( + pid: old.pid, parentPID: 1, executable: "/bin/other", command: "/bin/other", + argumentVector: ["/bin/other"], + resourceUsage: DevProcessResourceUsage( + cpuPercent: 0, residentMemoryBytes: 0, elapsedTime: "04:00:01" + ), + birthToken: ProcessBirthToken(seconds: 40_000, microseconds: 1) + ) + + for processes in [[old, newest], [newest, old]] { + let snapshot = AutomationPresentationSnapshot.build( + inventory: Fixtures.inventoryGeneration7, processes: processes, + longRunningThreshold: 14_400, now: Date(timeIntervalSince1970: 40_001) + ) + XCTAssertNil(snapshot.linksByProcessID[old.pid]) + XCTAssertEqual(snapshot.processIdentitiesByID[old.pid]?.birthToken, newest.birthToken) + XCTAssertEqual(snapshot.longRunningProcessIdentities, [ProcessIdentity(process: newest)]) + } + } + + func testDuplicatePIDRowsWithSameBirthButConflictingMetadataFailClosed() { + let first = Fixtures.runningBackup + let conflicting = DevProcess( + pid: first.pid, parentPID: first.parentPID, executable: first.executable, + command: first.command, argumentVector: [first.executable, "different"], + resourceUsage: first.resourceUsage, birthToken: first.birthToken + ) + + let snapshot = AutomationPresentationSnapshot.build( + inventory: Fixtures.inventoryGeneration7, processes: [first, conflicting], + longRunningThreshold: 14_400, now: Date(timeIntervalSince1970: 10_001) + ) + + XCTAssertTrue(snapshot.linksByProcessID.isEmpty) + XCTAssertTrue(snapshot.processIdentitiesByID.isEmpty) + XCTAssertTrue(snapshot.longRunningProcessIDs.isEmpty) + } + + func testFiltersExcludeAppleByDefaultAndCombineExactFacetsDeterministically() { + let apple = copiedRecord( + Fixtures.userAgent, label: "com.apple.synthetic", ownership: .appleSystem, + state: .idle, sourceKind: .launchDaemon + ) + let user = copiedRecord( + Fixtures.userAgent, label: "com.example.weekly-report", ownership: .user, + state: .idle, sourceKind: .launchAgent + ) + + XCTAssertEqual(AutomationPresentation.filtered( + [apple, user], source: .launchd, state: .idle, ownership: .all, + searchText: "weekly", includeAppleSystemServices: false + ).map(\.label), ["com.example.weekly-report"]) + XCTAssertEqual(AutomationPresentation.filtered( + [apple, user], source: .launchd, state: .idle, ownership: .all, + searchText: "", includeAppleSystemServices: true + ).map(\.label), ["com.apple.synthetic", "com.example.weekly-report"]) + } + + func testInventoryCountExplainsAppleServicesHiddenByPolicy() { + let count = AutomationPresentation.inventoryCount( + visibleCount: 69, + eligibleCount: 69, + totalCount: 983 + ) + + XCTAssertEqual(count.primaryText, "69 automations") + XCTAssertEqual(count.contextText, "914 Apple system services hidden") + XCTAssertEqual( + count.accessibilityLabel, + "69 automations. 914 Apple system services hidden. " + + "In Settings, open Automations and enable Include Apple System Services to review them." + ) + } + + func testInventoryCountSeparatesActiveFiltersFromPolicyHiddenServices() { + let count = AutomationPresentation.inventoryCount( + visibleCount: 5, + eligibleCount: 69, + totalCount: 983 + ) + + XCTAssertEqual(count.primaryText, "5 of 69 automations") + XCTAssertEqual(count.contextText, "914 Apple system services hidden") + } + + func testInventoryCountOmitsHiddenContextWhenAppleServicesAreIncluded() { + let count = AutomationPresentation.inventoryCount( + visibleCount: 983, + eligibleCount: 983, + totalCount: 983 + ) + + XCTAssertEqual(count.primaryText, "983 automations") + XCTAssertNil(count.contextText) + XCTAssertEqual(count.accessibilityLabel, "983 automations") + } + + func testInventoryCountUsesSingularGrammarAndClampsInconsistentInputs() { + XCTAssertEqual( + AutomationPresentation.inventoryCount( + visibleCount: 1, + eligibleCount: 1, + totalCount: 1 + ).primaryText, + "1 automation" + ) + XCTAssertEqual( + AutomationPresentation.inventoryCount( + visibleCount: 20, + eligibleCount: 10, + totalCount: 5 + ).primaryText, + "5 automations" + ) + XCTAssertEqual( + AutomationPresentation.inventoryCount( + visibleCount: 1, + eligibleCount: 1, + totalCount: 2 + ).contextText, + "1 Apple system service hidden" + ) + } + + func testThirdPartyOwnershipFilterIncludesManagedInspectionOnlyDefinitions() { + let thirdParty = copiedRecord( + Fixtures.userAgent, label: "third", ownership: .thirdPartySystem, + state: .idle, sourceKind: .launchAgent + ) + let managed = copiedRecord( + Fixtures.userAgent, label: "managed", ownership: .managed, + state: .idle, sourceKind: .launchAgent + ) + let user = copiedRecord( + Fixtures.userAgent, label: "user", ownership: .user, + state: .idle, sourceKind: .launchAgent + ) + + XCTAssertEqual( + AutomationPresentation.filtered( + [thirdParty, managed, user], source: .all, state: nil, ownership: .thirdParty, + searchText: "", includeAppleSystemServices: false + ).map(\.label), + ["managed", "third"] + ) + } + + func testAutomatedAndLongRunningBadgesRemainIndependent() { + XCTAssertEqual( + AutomationPresentation.badges(isAutomated: true, isLongRunning: false, elapsed: "00:20:00"), + [.automated] + ) + XCTAssertEqual( + AutomationPresentation.badges(isAutomated: false, isLongRunning: true, elapsed: "05:00:00"), + [.longRunning("5h")] + ) + XCTAssertEqual( + AutomationPresentation.badges(isAutomated: true, isLongRunning: true, elapsed: "04:00:00"), + [.automated, .longRunning("4h")] + ) + } + + func testActivityTypeFiltersKeepAutomatedAndLongRunningIndependentAndCombinable() { + XCTAssertTrue(AutomationActivityTypeFilter.all.matches(isAutomated: false, isLongRunning: false)) + XCTAssertTrue(AutomationActivityTypeFilter.automated.matches(isAutomated: true, isLongRunning: false)) + XCTAssertFalse(AutomationActivityTypeFilter.automated.matches(isAutomated: false, isLongRunning: true)) + XCTAssertTrue(AutomationActivityTypeFilter.longRunning.matches(isAutomated: false, isLongRunning: true)) + XCTAssertFalse(AutomationActivityTypeFilter.both.matches(isAutomated: true, isLongRunning: false)) + XCTAssertTrue(AutomationActivityTypeFilter.both.matches(isAutomated: true, isLongRunning: true)) + } + + func testAutomationSearchCoversArgumentsSourceScheduleAndOwningApplication() { + let record = copiedRecord( + Fixtures.userAgent, + label: "com.example.report", + ownership: .user, + state: .idle, + sourceKind: .launchAgent, + displayName: "Weekly Report", + providerBundleIdentifier: "com.example.owner", + arguments: ["--workspace", "North Star"], + sourceURL: URL(fileURLWithPath: "/Users/test/Library/LaunchAgents/report.plist"), + schedule: AutomationSchedule(triggers: [.calendar("Monday")], summary: "Every Monday") + ) + + for query in ["weekly", "north star", "report.plist", "every monday", "example.owner"] { + XCTAssertEqual( + AutomationPresentation.filtered( + [record], source: .all, state: nil, ownership: .all, + searchText: query, includeAppleSystemServices: false + ).map(\.id), + [record.id], + "Expected \(query) to match the complete automation search surface." + ) + } + } + + func testManagementActionsFollowExactCapabilityDecisionAndConcreteBackups() { + let backup = AutomationBackup( + id: .init(rawValue: UUID(uuidString: "11111111-1111-1111-1111-111111111111")!), + recordID: Fixtures.userAgent.id, + sourceURL: URL(fileURLWithPath: "/redacted"), + backupURL: URL(fileURLWithPath: "/redacted"), + checksum: "checksum", + createdAt: Date(timeIntervalSince1970: 1) + ) + let decision = AutomationCapabilityDecision( + capabilities: [.startNow, .disable, .exportRecord, .remove, .restore], + reason: nil + ) + + XCTAssertEqual( + AutomationManagementPresentation.actions( + decision: decision, + backups: [backup], + record: Fixtures.userAgent + ), + [.startNow, .disable, .exportRedacted, .exportUnredacted, .remove, .restore(backup.id)] + ) + XCTAssertEqual( + AutomationManagementPresentation.actions( + decision: AutomationCapabilityDecision( + capabilities: [.exportRecord], + reason: "Modern background items must be managed in System Settings." + ), + backups: [backup], + record: Fixtures.userAgent + ), + [.exportRedacted, .exportUnredacted] + ) + + let protected = copiedRecord( + Fixtures.userAgent, + label: "background", + ownership: .thirdPartySystem, + state: .idle, + sourceKind: .serviceManagement, + kind: .backgroundItem + ) + XCTAssertEqual( + AutomationManagementPresentation.actions( + decision: AutomationCapabilityDecision(capabilities: [.exportRecord], reason: "Inspection only."), + backups: [], + record: protected + ), + [.exportRedacted] + ) + } + + func testManagementActionChromeKeepsLifecycleActionsPinnedWithRedundantSemanticCues() { + let expectations: [( + action: AutomationManagementAction, + placement: AutomationManagementActionPlacement, + emphasis: AutomationManagementActionEmphasis, + symbol: String + )] = [ + (.startNow, .pinned, .positive, "play.fill"), + (.stopCurrentRun, .pinned, .destructive, "stop.fill"), + (.enable, .pinned, .positive, "checkmark.circle"), + (.disable, .pinned, .caution, "pause.circle"), + (.disableAndStop, .pinned, .destructive, "stop.circle.fill"), + (.edit, .overflow, .neutral, "square.and.pencil"), + (.duplicate, .overflow, .neutral, "plus.square.on.square"), + (.importRecord, .overflow, .neutral, "square.and.arrow.down"), + (.exportRedacted, .overflow, .neutral, "square.and.arrow.up"), + (.exportUnredacted, .overflow, .caution, "lock.open.fill"), + (.remove, .overflow, .destructive, "trash"), + (.restore(.init(rawValue: UUID())), .history, .neutral, "clock.arrow.circlepath"), + ] + + for expectation in expectations { + XCTAssertEqual(expectation.action.placement, expectation.placement) + XCTAssertEqual(expectation.action.emphasis, expectation.emphasis) + XCTAssertEqual(expectation.action.systemImage, expectation.symbol) + } + } + + func testConfirmationPoliciesStateExactLifecycleConsequences() throws { + let stop = try XCTUnwrap(AutomationManagementPresentation.confirmation( + for: .stopCurrentRun, + record: Fixtures.userAgent + )) + XCTAssertTrue(stop.consequence.contains("exact launchd service")) + XCTAssertTrue(stop.consequence.contains("does not disable")) + XCTAssertFalse(stop.consequence.contains("KeepAlive")) + + let cron = copiedRecord( + Fixtures.userAgent, + label: "nightly", + ownership: .user, + state: .running, + sourceKind: .crontab, + kind: .cron + ) + XCTAssertTrue(try XCTUnwrap(AutomationManagementPresentation.confirmation( + for: .stopCurrentRun, + record: cron + )).consequence.contains("strongly linked")) + + let start = try XCTUnwrap(AutomationManagementPresentation.confirmation( + for: .startNow, + record: Fixtures.userAgent + )) + XCTAssertTrue(start.consequence.contains("immediate launch")) + + XCTAssertTrue(try XCTUnwrap(AutomationManagementPresentation.confirmation( + for: .enable, + record: Fixtures.userAgent + )).consequence.contains("does not start")) + XCTAssertTrue(try XCTUnwrap(AutomationManagementPresentation.confirmation( + for: .disable, + record: Fixtures.userAgent + )).consequence.contains("does not stop")) + XCTAssertTrue(try XCTUnwrap(AutomationManagementPresentation.confirmation( + for: .disableAndStop, + record: Fixtures.userAgent + )).consequence.contains("exact launchd service")) + XCTAssertTrue( + AutomationManagementPresentation.helpText( + for: .stopCurrentRun, + record: Fixtures.userAgent + ).contains("exact launchd service") + ) + XCTAssertTrue( + AutomationManagementPresentation.helpText( + for: .disableAndStop, + record: Fixtures.userAgent + ).contains("exact launchd service") + ) + XCTAssertTrue( + AutomationManagementPresentation.helpText( + for: .stopCurrentRun, + record: cron + ).contains("strongly linked") + ) + + let remove = try XCTUnwrap(AutomationManagementPresentation.confirmation( + for: .remove, + record: Fixtures.userAgent + )) + XCTAssertEqual(remove.requiredLabel, Fixtures.userAgent.label) + XCTAssertTrue(remove.consequence.contains("restoration manifest")) + XCTAssertFalse(remove.isSatisfiedByLabel("\(Fixtures.userAgent.label) ")) + XCTAssertTrue(remove.isSatisfiedByLabel(Fixtures.userAgent.label)) + } + + func testUnredactedExportAndConcreteRestoreRequireExplicitConfirmation() throws { + let backup = AutomationBackup( + id: .init(rawValue: UUID(uuidString: "22222222-2222-2222-2222-222222222222")!), + recordID: Fixtures.userAgent.id, + sourceURL: URL(fileURLWithPath: "/redacted"), + backupURL: URL(fileURLWithPath: "/redacted"), + checksum: "checksum", + createdAt: Date(timeIntervalSince1970: 10) + ) + let export = try XCTUnwrap(AutomationManagementPresentation.confirmation( + for: .exportUnredacted, + record: Fixtures.userAgent + )) + XCTAssertTrue(export.consequence.contains("secrets")) + + let restore = try XCTUnwrap(AutomationManagementPresentation.confirmation( + for: .restore(backup.id), + record: Fixtures.userAgent, + backups: [backup] + )) + XCTAssertTrue(restore.consequence.contains("verified backup")) + XCTAssertTrue(restore.consequence.contains(backup.createdAt.formatted(date: .abbreviated, time: .shortened))) + } + + func testOperationResultPresentationRedactsSecretsAndKeepsPartialRecoveryVisible() { + let result = AutomationOperationResult( + operation: .disable, + status: .partialFailure("launchctl failed --token=super-secret"), + appliedSteps: ["raw output --password=hunter2"], + verificationEvidence: ["API_KEY=hidden"], + rollback: .failed("rollback --secret=also-hidden"), + manualRecovery: "Retry with --access-token=last-secret" + ) + + let presentation = AutomationManagementPresentation.result(result) + XCTAssertEqual(presentation.title, "Partially completed") + XCTAssertTrue(presentation.detail.contains("")) + XCTAssertFalse(presentation.detail.contains("super-secret")) + XCTAssertFalse(presentation.recoveryGuidance?.contains("last-secret") ?? true) + XCTAssertFalse(presentation.detail.contains("hunter2")) + XCTAssertFalse(presentation.detail.contains("hidden")) + let completePresentation = String(describing: presentation) + for secret in ["super-secret", "hunter2", "hidden", "also-hidden", "last-secret"] { + XCTAssertFalse(completePresentation.contains(secret)) + } + } + + func testOperationResultPresentationKeepsRedactedAppliedVerificationAndRecoveryEvidence() { + let backup = AutomationBackup( + id: AutomationBackup.ID(rawValue: UUID(uuidString: "AAAA0000-0000-0000-0000-000000000001")!), + recordID: Fixtures.userAgent.id, + sourceURL: URL(fileURLWithPath: "/private/source.plist"), + backupURL: URL(fileURLWithPath: "/private/backup.plist"), + checksum: String(repeating: "a", count: 64), + createdAt: Date(timeIntervalSince1970: 20_000) + ) + let result = AutomationOperationResult( + operation: .edit(AutomationEditPayload( + label: "fixture", executable: "/bin/true", arguments: [], environment: [:], + workingDirectory: nil, schedule: Fixtures.userAgent.schedule, + rawRepresentation: nil + )), + status: .succeeded, + appliedSteps: ["Applied --token=private-value"], + verificationEvidence: ["Verified API_KEY=private-value"], + rollback: .restored(backup.id), + manualRecovery: nil, + backup: backup, + fileMutationEvidence: AutomationFilePartialMutation( + kind: .replace, + commitState: .unknown, + observedFiles: [], + recoveryHandle: nil, + recoveryHandles: [], + resultURL: nil + ) + ) + + let presentation = AutomationManagementPresentation.result(result) + XCTAssertEqual(presentation.appliedEvidence, ["Applied --token="]) + XCTAssertEqual(presentation.verificationEvidence, ["Verified API_KEY="]) + XCTAssertTrue(presentation.rollbackEvidence.contains("restored")) + XCTAssertTrue(presentation.rollbackEvidence.contains("AAAA0000")) + XCTAssertTrue(presentation.backupEvidence?.contains(String(repeating: "a", count: 12)) == true) + XCTAssertEqual( + presentation.mutationEvidence, + ["Source replacement outcome is unknown; 0 observed files and 0 recovery handles were retained."] + ) + XCTAssertFalse(String(describing: presentation).contains("private-value")) + XCTAssertFalse(String(describing: presentation).contains("/private/")) + } + + func testOperationResultPresentationExposesEveryExactRecoveryLocation() throws { + let directoryURL = URL(fileURLWithPath: "/tmp/devscope-recovery", isDirectory: true) + let directory = try XCTUnwrap(AutomationDirectoryAuthorization( + directoryURL: directoryURL, + resourceIdentifier: "directory-id" + )) + let firstURL = directoryURL.appendingPathComponent(".devscope-recovery-first") + let secondURL = directoryURL.appendingPathComponent(".devscope-recovery-second") + let first = try XCTUnwrap(AutomationFileAuthorization( + fileURL: firstURL, + directory: directory, + expectation: .existing(resourceIdentifier: "first-id") + )) + let second = try XCTUnwrap(AutomationFileAuthorization( + fileURL: secondURL, + directory: directory, + expectation: .existing(resourceIdentifier: "second-id") + )) + let result = AutomationOperationResult( + operation: .remove, + status: .partialFailure("Automatic recovery did not complete."), + appliedSteps: [], + verificationEvidence: [], + rollback: .notNeeded, + manualRecovery: nil, + fileMutationEvidence: AutomationFilePartialMutation( + kind: .remove, + commitState: .unknown, + observedFiles: [], + recoveryHandle: first, + recoveryHandles: [second], + resultURL: nil + ) + ) + + let guidance = try XCTUnwrap(AutomationManagementPresentation.result(result).recoveryGuidance) + + XCTAssertTrue(guidance.contains(firstURL.path), guidance) + XCTAssertTrue(guidance.contains(secondURL.path), guidance) + } + + func testCronConfirmationDisplaysRedactedCommandButBuildsExactTypedOperation() throws { + let cron = copiedRecord( + Fixtures.userAgent, + label: "nightly", + ownership: .user, + state: .idle, + sourceKind: .crontab, + kind: .cron, + commandSignature: "/usr/bin/curl --token=super-secret https://example.test" + ) + + let policy = try XCTUnwrap(AutomationManagementPresentation.confirmation( + for: .startNow, + record: cron + )) + XCTAssertEqual( + policy.displayedCommand, + "/usr/bin/curl --token= https://example.test" + ) + XCTAssertEqual( + AutomationManagementPresentation.confirmedOperation(for: .startNow, record: cron), + .confirmedRunToCompletion(AutomationRunToCompletionConfirmation( + recordID: cron.id, + sourceChecksum: cron.sourceChecksum, + exactCommand: "/usr/bin/curl --token=super-secret https://example.test" + )) + ) + } + + func testEditorValidationRequiresApprovedDuplicateDestinationAndValidRawPlist() { + XCTAssertEqual( + AutomationEditorPresentation.validationMessage( + record: Fixtures.userAgent, + purposeIsDuplicate: true, + label: "copy", + executable: "/bin/true", + arguments: [], + environment: [:], + schedule: Fixtures.userAgent.schedule, + usesRawRepresentation: false, + rawData: nil, + duplicateDestination: nil + ), + "Choose a label that resolves to a distinct approved destination." + ) + XCTAssertEqual( + AutomationEditorPresentation.validationMessage( + record: Fixtures.userAgent, + purposeIsDuplicate: false, + label: "valid", + executable: "/bin/true", + arguments: [], + environment: [:], + schedule: Fixtures.userAgent.schedule, + usesRawRepresentation: true, + rawData: Data("not a plist".utf8), + duplicateDestination: nil + ), + "The raw property list is not valid." + ) + } + + func testEditorRawLaunchdValidationRejectsMismatchedLabelAndCommand() throws { + let destination = URL(fileURLWithPath: "/Users/test/Library/LaunchAgents/copy.plist") + func plist(_ label: String, _ program: String) throws -> Data { + try PropertyListSerialization.data( + fromPropertyList: ["Label": label, "Program": program], + format: .xml, + options: 0 + ) + } + + XCTAssertEqual( + AutomationEditorPresentation.validationMessage( + record: Fixtures.userAgent, purposeIsDuplicate: true, + label: "expected", executable: "/bin/true", arguments: [], environment: [:], + schedule: Fixtures.userAgent.schedule, usesRawRepresentation: true, + rawData: try plist("different", "/bin/true"), duplicateDestination: destination + ), + "The raw property-list label does not match the reviewed label." + ) + XCTAssertEqual( + AutomationEditorPresentation.validationMessage( + record: Fixtures.userAgent, purposeIsDuplicate: true, + label: "expected", executable: "/bin/true", arguments: [], environment: [:], + schedule: Fixtures.userAgent.schedule, usesRawRepresentation: true, + rawData: try plist("expected", "/bin/false"), duplicateDestination: destination + ), + "The raw property-list command does not match the reviewed executable and arguments." + ) + } + + func testEditorRawCronValidationRequiresReviewedEntryAndRetainedOriginalForDuplicate() { + let schedule = AutomationSchedule(triggers: [.cron("* * * * *")], summary: "Every minute") + let cron = copiedRecord( + Fixtures.userAgent, label: "cron", ownership: .user, state: .idle, + sourceKind: .crontab, kind: .cron, schedule: schedule, + commandSignature: "/bin/true --old" + ) + let destination = URL(fileURLWithPath: "/tmp/cron-source") + + XCTAssertEqual( + AutomationEditorPresentation.validationMessage( + record: cron, purposeIsDuplicate: true, label: "cron-copy", + executable: "/bin/true", arguments: ["--new"], environment: cron.environment, schedule: schedule, + usesRawRepresentation: true, + rawData: Data("DEVSCOPE_FIXTURE=synthetic\n* * * * * /bin/true --new\n".utf8), + duplicateDestination: destination + ), + "The duplicate crontab must retain the selected entry and add one reviewed entry." + ) + XCTAssertNil(AutomationEditorPresentation.validationMessage( + record: cron, purposeIsDuplicate: true, label: "cron-copy", + executable: "/bin/true", arguments: ["--new"], environment: cron.environment, schedule: schedule, + usesRawRepresentation: true, + rawData: Data("DEVSCOPE_FIXTURE=synthetic\n* * * * * /bin/true --old\n* * * * * /bin/true --new\n".utf8), + duplicateDestination: destination + )) + } + + func testEditorParsesEditableEnvironmentAndEverySupportedScheduleToken() throws { + let environment = try XCTUnwrap(AutomationEditorPresentation.environment( + from: "API_URL=https://example.test?a=b\nTOKEN=value\n" + )) + XCTAssertEqual(environment, ["API_URL": "https://example.test?a=b", "TOKEN": "value"]) + XCTAssertEqual( + AutomationEditorPresentation.environmentText(for: environment), + "API_URL=https://example.test?a=b\nTOKEN=value" + ) + + let schedule = try XCTUnwrap(AutomationEditorPresentation.schedule( + from: "run-at-load\ninterval 900\nkeep-alive\ncalendar Mondays at 08:30" + )) + XCTAssertEqual(schedule.triggers, [ + .runAtLoad, + .keepAlive, + .interval(seconds: 900), + .calendar("Mondays at 08:30"), + ]) + XCTAssertEqual( + AutomationEditorPresentation.scheduleText(for: schedule), + "run-at-load\nkeep-alive\ninterval 900\ncalendar Mondays at 08:30" + ) + XCTAssertNil(AutomationEditorPresentation.environment(from: "INVALID")) + XCTAssertNil(AutomationEditorPresentation.schedule(from: "interval 0")) + XCTAssertNil(AutomationEditorPresentation.schedule(from: "unknown trigger")) + } + + func testEditorScheduleNormalizesManagerRoundTripAndRejectsContradictoryCombinations() throws { + XCTAssertEqual( + try XCTUnwrap(AutomationEditorPresentation.schedule(from: "keep-alive")).triggers, + [.runAtLoad, .keepAlive] + ) + XCTAssertEqual( + try XCTUnwrap(AutomationEditorPresentation.schedule(from: "at-login")).triggers, + [.runAtLoad] + ) + XCTAssertNil(AutomationEditorPresentation.schedule(from: "on-demand\ninterval 60")) + XCTAssertNil(AutomationEditorPresentation.schedule(from: "interval 60\ninterval 120")) + XCTAssertNil(AutomationEditorPresentation.schedule(from: "cron * * * * *\nrun-at-load")) + XCTAssertEqual( + try XCTUnwrap(AutomationEditorPresentation.schedule( + from: "calendar First\ninterval 60\ncalendar Second" + )).triggers, + [.interval(seconds: 60), .calendar("First"), .calendar("Second")] + ) + XCTAssertEqual( + AutomationEditorPresentation.scheduleText(for: AutomationSchedule( + triggers: [.atLogin], + summary: "At login" + )), + "run-at-load" + ) + } + + func testRawLaunchdValidationAcceptsChangedReviewedEnvironmentAndSchedule() throws { + let changedSchedule = AutomationSchedule( + triggers: [.runAtLoad, .interval(seconds: 900)], + summary: "At load, every 15 minutes" + ) + let changedEnvironment = ["MODE": "audit"] + let raw = try PropertyListSerialization.data( + fromPropertyList: [ + "Label": "edited", + "ProgramArguments": ["/bin/true", "--edited"], + "EnvironmentVariables": changedEnvironment, + "RunAtLoad": true, + "StartInterval": 900, + ], + format: .xml, + options: 0 + ) + + XCTAssertNil(AutomationEditorPresentation.validationMessage( + record: Fixtures.userAgent, + purposeIsDuplicate: false, + label: "edited", + executable: "/bin/true", + arguments: ["--edited"], + environment: changedEnvironment, + schedule: changedSchedule, + usesRawRepresentation: true, + rawData: raw, + duplicateDestination: Fixtures.userAgent.sourceURL + )) + XCTAssertNotNil(AutomationEditorPresentation.validationMessage( + record: Fixtures.userAgent, + purposeIsDuplicate: false, + label: "edited", + executable: "/bin/true", + arguments: ["--edited"], + environment: [:], + schedule: changedSchedule, + usesRawRepresentation: true, + rawData: raw, + duplicateDestination: Fixtures.userAgent.sourceURL + )) + } + + func testImportPreviewValidatesKindAndExactDestinationBeforeApply() throws { + let data = try PropertyListSerialization.data( + fromPropertyList: ["Label": "com.example.imported", "Program": "/bin/true"], + format: .xml, + options: 0 + ) + let destination = URL(fileURLWithPath: "/Users/test/Library/LaunchAgents/imported.plist") + let valid = AutomationImportPresentation( + data: data, + expectedRecord: Fixtures.userAgent, + destination: destination + ) + XCTAssertTrue(valid.canApply) + XCTAssertEqual(valid.destination, destination) + XCTAssertTrue(valid.summary.contains("com.example.imported")) + + let invalid = AutomationImportPresentation( + data: Data("* * * * * /bin/true".utf8), + expectedRecord: Fixtures.userAgent, + destination: destination + ) + XCTAssertFalse(invalid.canApply) + XCTAssertEqual(invalid.validationMessage, "The selected file is not a labeled launchd property list.") + } + + func testImportPreviewNamesReplacementTargetOwnershipAndConsequence() throws { + let data = try PropertyListSerialization.data( + fromPropertyList: ["Label": "com.example.imported", "Program": "/bin/true"], + format: .xml, + options: 0 + ) + let presentation = AutomationImportPresentation( + data: data, + expectedRecord: Fixtures.userAgent, + destination: URL(fileURLWithPath: "/Users/test/Library/LaunchAgents/imported.plist") + ) + + XCTAssertEqual(presentation.targetLabel, Fixtures.userAgent.label) + XCTAssertEqual(presentation.targetOwnership, "User owned") + XCTAssertTrue(presentation.consequence.contains("replace")) + XCTAssertTrue(presentation.consequence.contains(Fixtures.userAgent.label)) + XCTAssertTrue(presentation.consequence.contains("checksum")) + } + + func testVisibleSelectionDropsARecordExcludedByFilters() { + let visible = [Fixtures.userAgent.id] + XCTAssertEqual( + AutomationPresentation.resolvedSelection(current: nil, visibleIDs: visible), + Fixtures.userAgent.id + ) + XCTAssertEqual( + AutomationPresentation.resolvedSelection( + current: AutomationRecord.ID(rawValue: "hidden"), + visibleIDs: visible + ), + Fixtures.userAgent.id + ) + XCTAssertEqual( + AutomationPresentation.resolvedSelection(current: Fixtures.userAgent.id, visibleIDs: visible), + Fixtures.userAgent.id + ) + XCTAssertNil(AutomationPresentation.resolvedSelection( + current: Fixtures.userAgent.id, + visibleIDs: [] + )) + } +} + +private func copiedRecord( + _ source: AutomationRecord, + label: String, + ownership: AutomationOwnership, + state: AutomationState, + sourceKind: AutomationSourceKind, + kind: AutomationKind? = nil, + displayName: String? = nil, + providerBundleIdentifier: String? = nil, + arguments: [String]? = nil, + sourceURL: URL? = nil, + schedule: AutomationSchedule? = nil, + commandSignature: String? = nil +) -> AutomationRecord { + AutomationRecord( + id: AutomationRecord.ID(source: sourceKind, ownerUID: 501, label: label, sourcePath: "/\(label)"), + kind: kind ?? source.kind, sourceKind: sourceKind, label: label, displayName: displayName ?? label, + providerBundleIdentifier: providerBundleIdentifier ?? source.providerBundleIdentifier, ownerUID: source.ownerUID, + ownership: ownership, executable: source.executable, arguments: arguments ?? source.arguments, + commandSignature: commandSignature ?? source.commandSignature, environment: source.environment, + workingDirectory: source.workingDirectory, schedule: schedule ?? source.schedule, + sourceURL: sourceURL ?? source.sourceURL, sourceChecksum: source.sourceChecksum, + enabledState: source.enabledState, loadState: source.loadState, + approvalState: source.approvalState, state: state, evidence: source.evidence, + capabilities: source.capabilities, validationFindings: source.validationFindings + ) +} diff --git a/Tests/DevScopeCoreTests/AutomationProcessCorrelatorTests.swift b/Tests/DevScopeCoreTests/AutomationProcessCorrelatorTests.swift new file mode 100644 index 0000000..dc09cc0 --- /dev/null +++ b/Tests/DevScopeCoreTests/AutomationProcessCorrelatorTests.swift @@ -0,0 +1,601 @@ +import XCTest +@testable import DevScopeCore + +final class AutomationProcessCorrelatorTests: XCTestCase { + func testExactExecutableAndArgumentsCreateVerifiedLink() { + let links = AutomationProcessCorrelator.links( + records: [Fixtures.userAgent], + processes: [Fixtures.runningBackup], + now: Date(timeIntervalSince1970: 10_000) + ) + + XCTAssertEqual(links.count, 1) + XCTAssertEqual(links[0].strength, .strong) + XCTAssertEqual(links[0].processIdentity.birthToken, Fixtures.runningBackup.birthToken) + XCTAssertEqual( + links[0].evidence.map(\.detail), + ["Exact executable and arguments with direct PID 1 compatibility"] + ) + } + + func testWeakResemblancePartialArgumentsAndNilBirthFailClosed() { + let cases = [ + DevProcess( + pid: 44, parentPID: 1, executable: "/tmp/backup-helper", + command: "/tmp/backup-helper 14400", + birthToken: ProcessBirthToken(seconds: 4, microseconds: 4) + ), + DevProcess( + pid: 45, parentPID: 1, executable: "/bin/sleep", + command: "/bin/sleep 144000", + birthToken: ProcessBirthToken(seconds: 4, microseconds: 5) + ), + DevProcess( + pid: 46, parentPID: 1, executable: "/bin/sleep", + command: "/bin/sleep 14400" + ), + ] + + XCTAssertTrue(AutomationProcessCorrelator.links( + records: [Fixtures.userAgent], processes: cases, + now: Date(timeIntervalSince1970: 10_000) + ).isEmpty) + } + + func testExactExecutableFailsClosedWithoutExactArgumentVector() { + let process = DevProcess( + pid: 48, parentPID: 1, executable: "/bin/sleep", + command: "/bin/sleep 14400", argumentVector: nil, + birthToken: ProcessBirthToken(seconds: 4, microseconds: 8) + ) + + XCTAssertTrue(AutomationProcessCorrelator.links( + records: [Fixtures.userAgent], processes: [process], + now: Date(timeIntervalSince1970: 10_000) + ).isEmpty) + } + + func testExactExecutableAndArgumentsRejectManualNonLaunchAncestry() { + let manuallyInvoked = DevProcess( + pid: 54, parentPID: 9_001, executable: "/bin/sleep", + command: "/bin/sleep 14400", argumentVector: ["/bin/sleep", "14400"], + birthToken: ProcessBirthToken(seconds: 5, microseconds: 4) + ) + + XCTAssertTrue(AutomationProcessCorrelator.links( + records: [Fixtures.userAgent], processes: [manuallyInvoked], + now: Date(timeIntervalSince1970: 10_000) + ).isEmpty) + } + + func testExactLaunchLabelIsStrongerProvenanceThanDirectPIDOneParentage() { + let exactLabel = DevProcess( + pid: 55, parentPID: 9_002, executable: "/bin/sleep", + command: "/bin/sleep 14400", argumentVector: ["/bin/sleep", "14400"], + birthToken: ProcessBirthToken(seconds: 5, microseconds: 5), + launchLabel: Fixtures.userAgent.label + ) + + let links = AutomationProcessCorrelator.links( + records: [Fixtures.userAgent], processes: [exactLabel], + now: Date(timeIntervalSince1970: 10_000) + ) + + XCTAssertEqual(links.map(\.processIdentity.pid), [55]) + XCTAssertEqual(links[0].evidence.map(\.detail), ["Exact executable, arguments, and launch label"]) + } + + func testMismatchedLaunchLabelRejectsEvenWithDirectPIDOneParentage() { + let wrongLabel = DevProcess( + pid: 56, parentPID: 1, executable: "/bin/sleep", + command: "/bin/sleep 14400", argumentVector: ["/bin/sleep", "14400"], + birthToken: ProcessBirthToken(seconds: 5, microseconds: 6), + launchLabel: "com.example.different" + ) + + XCTAssertTrue(AutomationProcessCorrelator.links( + records: [Fixtures.userAgent], processes: [wrongLabel], + now: Date(timeIntervalSince1970: 10_000) + ).isEmpty) + } + + func testNilLaunchLabelDoesNotChooseBetweenIdenticalDefinitions() { + let first = automationRecord(Fixtures.userAgent, label: "com.example.first") + let second = automationRecord(Fixtures.userAgent, label: "com.example.second") + + let links = AutomationProcessCorrelator.links( + records: [first, second], + processes: [Fixtures.runningBackup], + now: Date(timeIntervalSince1970: 10_000) + ) + + XCTAssertTrue(links.isEmpty) + } + + func testExactLaunchLabelSelectsTheMatchingIdenticalDefinition() { + let first = automationRecord(Fixtures.userAgent, label: "com.example.first") + let second = automationRecord(Fixtures.userAgent, label: "com.example.second") + let labeledProcess = DevProcess( + pid: Fixtures.runningBackup.pid, + parentPID: Fixtures.runningBackup.parentPID, + executable: Fixtures.runningBackup.executable, + command: Fixtures.runningBackup.command, + argumentVector: Fixtures.runningBackup.argumentVector, + birthToken: Fixtures.runningBackup.birthToken, + launchLabel: second.label + ) + + let links = AutomationProcessCorrelator.links( + records: [first, second], + processes: [labeledProcess], + now: Date(timeIntervalSince1970: 10_000) + ) + + XCTAssertEqual(links.map(\.recordID), [second.id]) + } + + func testEmptyRecordArgumentsRejectAnExactExecutableWithExtraArguments() { + let definition = automationRecord(Fixtures.userAgent, arguments: []) + let process = DevProcess( + pid: 49, parentPID: 1, executable: "/bin/sleep", + command: "/bin/sleep --unexpected", + argumentVector: ["/bin/sleep", "--unexpected"], + birthToken: ProcessBirthToken(seconds: 4, microseconds: 9) + ) + + XCTAssertTrue(AutomationProcessCorrelator.links( + records: [definition], processes: [process], + now: Date(timeIntervalSince1970: 10_000) + ).isEmpty) + } + + func testEmbeddedSpaceArgumentDoesNotMatchTwoKernelArguments() { + let definition = automationRecord( + Fixtures.userAgent, executable: "/opt/jobs/exact", arguments: ["folder name"] + ) + let split = DevProcess( + pid: 52, parentPID: 1, executable: "/opt/jobs/exact", + command: "/opt/jobs/exact folder name", + argumentVector: ["/opt/jobs/exact", "folder", "name"], + birthToken: ProcessBirthToken(seconds: 5, microseconds: 2) + ) + let exact = DevProcess( + pid: 53, parentPID: 1, executable: "/opt/jobs/exact", + command: "/opt/jobs/exact folder name", + argumentVector: ["/opt/jobs/exact", "folder name"], + birthToken: ProcessBirthToken(seconds: 5, microseconds: 3) + ) + + XCTAssertEqual(AutomationProcessCorrelator.links( + records: [definition], processes: [split, exact], + now: Date(timeIntervalSince1970: 10_000) + ).map(\.processIdentity.pid), [53]) + } + + func testSingleQuotedBackslashRemainsLiteralDuringExactArgumentMatching() { + let definition = automationRecord( + Fixtures.userAgent, + executable: "/opt/jobs/exact", + arguments: ["folder\\name"] + ) + let process = DevProcess( + pid: 47, parentPID: 1, executable: "/opt/jobs/exact", + command: "/opt/jobs/exact 'folder\\name'", + argumentVector: ["/opt/jobs/exact", "folder\\name"], + birthToken: ProcessBirthToken(seconds: 4, microseconds: 7) + ) + + XCTAssertEqual(AutomationProcessCorrelator.links( + records: [definition], processes: [process], + now: Date(timeIntervalSince1970: 10_000) + ).map(\.processIdentity.pid), [47]) + } + + func testExactBundleIdentityLinksAndNameOrPathInferenceDoesNot() { + let exact = DevProcess( + pid: 50, parentPID: 1, + executable: "/Applications/Owner.app/Contents/MacOS/Other", + command: "/Applications/Owner.app/Contents/MacOS/Other", + argumentVector: ["/Applications/Owner.app/Contents/MacOS/Other"], + birthToken: ProcessBirthToken(seconds: 5, microseconds: 1), + bundleIdentifier: "com.example.devscope-fixture-owner" + ) + let inferredOnly = DevProcess( + pid: 51, parentPID: 1, + executable: "/Applications/com.example.devscope-fixture-owner.app/Contents/MacOS/Owner", + command: "com.example.devscope-fixture-owner", + birthToken: ProcessBirthToken(seconds: 5, microseconds: 2) + ) + + let links = AutomationProcessCorrelator.links( + records: [automationRecord(Fixtures.userAgent, arguments: [])], + processes: [exact, inferredOnly], + now: Date(timeIntervalSince1970: 10_000) + ) + + XCTAssertEqual(links.map(\.processIdentity.pid), [50]) + } + + func testRecycledPIDProducesOnlyTheCurrentBirthIdentity() { + let old = Fixtures.runningBackup + let recycled = DevProcess( + pid: old.pid, parentPID: old.parentPID, executable: old.executable, + command: old.command, argumentVector: old.argumentVector, + resourceUsage: old.resourceUsage, + birthToken: ProcessBirthToken(seconds: 20_000, microseconds: 1) + ) + + let links = AutomationProcessCorrelator.links( + records: [Fixtures.userAgent], processes: [recycled], + now: Date(timeIntervalSince1970: 20_100) + ) + + XCTAssertEqual(links.map(\.processIdentity.birthToken), [recycled.birthToken]) + } + + func testDuplicatePIDRowsUseNewestBirthRegardlessOfInputOrder() { + let old = Fixtures.runningBackup + let newest = DevProcess( + pid: old.pid, parentPID: 1, executable: "/bin/other", command: "/bin/other", + argumentVector: ["/bin/other"], + birthToken: ProcessBirthToken(seconds: 50_000, microseconds: 1) + ) + + XCTAssertTrue(AutomationProcessCorrelator.links( + records: [Fixtures.userAgent], processes: [old, newest], + now: Date(timeIntervalSince1970: 50_001) + ).isEmpty) + XCTAssertTrue(AutomationProcessCorrelator.links( + records: [Fixtures.userAgent], processes: [newest, old], + now: Date(timeIntervalSince1970: 50_001) + ).isEmpty) + } + + func testCronRequiresExactSignatureScheduleMinuteAndCronAncestry() { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + let now = date(2026, 7, 13, 10, 5, 30, calendar: calendar) + let cron = cronRecord(signature: "/bin/sleep 60", expression: "5 10 * * *") + let daemon = DevProcess( + pid: 100, parentPID: 1, executable: "/usr/sbin/cron", command: "/usr/sbin/cron", + argumentVector: ["/usr/sbin/cron"], + birthToken: ProcessBirthToken(seconds: 1, microseconds: 1) + ) + let shell = DevProcess( + pid: 101, parentPID: 100, executable: "/bin/sh", + command: "/bin/sh -c '/bin/sleep 60'", + argumentVector: ["/bin/sh", "-c", "/bin/sleep 60"], + resourceUsage: usage("00:00:30"), + birthToken: ProcessBirthToken(seconds: 2, microseconds: 1) + ) + let child = DevProcess( + pid: 102, parentPID: 101, executable: "/bin/sleep", command: "/bin/sleep 60", + argumentVector: ["/bin/sleep", "60"], + resourceUsage: usage("00:00:25"), + birthToken: ProcessBirthToken(seconds: 3, microseconds: 1) + ) + + let links = AutomationProcessCorrelator.links( + records: [cron], processes: [daemon, shell, child], now: now, calendar: calendar + ) + + XCTAssertEqual(links.map(\.processIdentity.pid), [101]) + XCTAssertTrue(links.allSatisfy { $0.recordID == cron.id }) + } + + func testCronFailsClosedForWrongMinuteWrongAncestryAndReboot() { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + let now = date(2026, 7, 13, 10, 6, 30, calendar: calendar) + let daemon = DevProcess( + pid: 199, parentPID: 1, executable: "/usr/sbin/cron", command: "/usr/sbin/cron", + argumentVector: ["/usr/sbin/cron"], + birthToken: ProcessBirthToken(seconds: 4, microseconds: 0) + ) + let shell = DevProcess( + pid: 200, parentPID: 1, executable: "/bin/sh", + command: "/bin/sh -c '/bin/sleep 60'", + argumentVector: ["/bin/sh", "-c", "/bin/sleep 60"], resourceUsage: usage("00:00:30"), + birthToken: ProcessBirthToken(seconds: 4, microseconds: 1) + ) + + XCTAssertTrue(AutomationProcessCorrelator.links( + records: [cronRecord(signature: "/bin/sleep 60", expression: "5 10 * * *")], + processes: [shell], now: now, calendar: calendar + ).isEmpty) + let wrongMinuteShell = DevProcess( + pid: 201, parentPID: 199, executable: "/bin/sh", + command: "/bin/sh -c '/bin/sleep 60'", + argumentVector: ["/bin/sh", "-c", "/bin/sleep 60"], resourceUsage: usage("00:00:30"), + birthToken: ProcessBirthToken(seconds: 4, microseconds: 2) + ) + XCTAssertTrue(AutomationProcessCorrelator.links( + records: [cronRecord(signature: "/bin/sleep 60", expression: "5 10 * * *")], + processes: [daemon, wrongMinuteShell], now: now, calendar: calendar + ).isEmpty) + XCTAssertTrue(AutomationProcessCorrelator.links( + records: [cronRecord(signature: "/bin/sleep 60", expression: "@reboot")], + processes: [daemon, wrongMinuteShell], now: now, calendar: calendar + ).isEmpty) + } + + func testCronSignatureSubstringAndShellMetacharactersNeverMatch() { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + let now = date(2026, 7, 13, 10, 5, 30, calendar: calendar) + let daemon = DevProcess( + pid: 300, parentPID: 1, executable: "/usr/sbin/cron", command: "/usr/sbin/cron", + argumentVector: ["/usr/sbin/cron"], + birthToken: ProcessBirthToken(seconds: 5, microseconds: 0) + ) + let shell = DevProcess( + pid: 301, parentPID: 300, executable: "/bin/sh", + command: "/bin/sh -c '/bin/sleep 600; echo unexpected'", + argumentVector: ["/bin/sh", "-c", "/bin/sleep 600; echo unexpected"], resourceUsage: usage("00:00:30"), + birthToken: ProcessBirthToken(seconds: 5, microseconds: 1) + ) + + XCTAssertTrue(AutomationProcessCorrelator.links( + records: [cronRecord(signature: "/bin/sleep 60", expression: "5 10 * * *")], + processes: [daemon, shell], now: now, calendar: calendar + ).isEmpty) + } + + func testCronRejectsDirectCommandTextWithoutExactShellCarrierArgv() { + let calendar = utcCalendar() + let now = date(2026, 7, 13, 10, 5, 30, calendar: calendar) + let daemon = DevProcess( + pid: 320, parentPID: 1, executable: "/usr/sbin/cron", command: "/usr/sbin/cron", + argumentVector: ["/usr/sbin/cron"], + birthToken: ProcessBirthToken(seconds: 5, microseconds: 0) + ) + let shortcut = DevProcess( + pid: 321, parentPID: 320, executable: "/bin/sleep", command: "/bin/sleep 60", + argumentVector: ["/bin/sleep", "60"], resourceUsage: usage("00:00:30"), + birthToken: ProcessBirthToken(seconds: 5, microseconds: 1) + ) + + XCTAssertTrue(AutomationProcessCorrelator.links( + records: [cronRecord(signature: "/bin/sleep 60", expression: "5 10 * * *")], + processes: [daemon, shortcut], now: now, calendar: calendar + ).isEmpty) + } + + func testCronCalendarRejectsMalformedListAndUsesWildcardOriginDOMDOWSemantics() { + let calendar = utcCalendar() + let monday = date(2026, 7, 13, 10, 5, 30, calendar: calendar) + let daemon = cronDaemon() + let carrier = cronCarrier(parentPID: daemon.pid, elapsed: "00:00:30") + + XCTAssertTrue(AutomationProcessCorrelator.links( + records: [cronRecord(signature: "/bin/sleep 60", expression: "5,bad 10 * * *")], + processes: [daemon, carrier], now: monday, calendar: calendar + ).isEmpty) + XCTAssertTrue(AutomationProcessCorrelator.links( + records: [cronRecord(signature: "/bin/sleep 60", expression: "5 10 */2 * 2")], + processes: [daemon, carrier], now: monday, calendar: calendar + ).isEmpty) + XCTAssertFalse(AutomationProcessCorrelator.links( + records: [cronRecord(signature: "/bin/sleep 60", expression: "5 10 14 * 1")], + processes: [daemon, carrier], now: monday, calendar: calendar + ).isEmpty) + } + + func testCronCalendarUsesInjectedTimezoneAtMinuteBoundary() { + var warsaw = Calendar(identifier: .gregorian) + warsaw.timeZone = TimeZone(identifier: "Europe/Warsaw")! + let now = date(2026, 7, 13, 0, 0, 1, calendar: warsaw) + let daemon = cronDaemon() + let carrier = cronCarrier(parentPID: daemon.pid, elapsed: "00:00:01") + + XCTAssertEqual(AutomationProcessCorrelator.links( + records: [cronRecord(signature: "/bin/sleep 60", expression: "0 0 * * *")], + processes: [daemon, carrier], now: now, calendar: warsaw + ).map(\.processIdentity.pid), [carrier.pid]) + } + + func testCronCalendarRejectsScalarAndNameStepBases() { + let calendar = utcCalendar() + let monday = date(2026, 7, 13, 10, 5, 30, calendar: calendar) + let daemon = cronDaemon() + let carrier = cronCarrier(parentPID: daemon.pid, elapsed: "00:00:30") + + for expression in ["5/10 10 * * *", "5 10 * * MON/2"] { + XCTAssertTrue(AutomationProcessCorrelator.links( + records: [cronRecord(signature: "/bin/sleep 60", expression: expression)], + processes: [daemon, carrier], now: monday, calendar: calendar + ).isEmpty, "Scalar step must fail closed: \(expression)") + } + XCTAssertEqual(AutomationProcessCorrelator.links( + records: [cronRecord(signature: "/bin/sleep 60", expression: "5-15/10 10 * * MON-FRI/2")], + processes: [daemon, carrier], now: monday, calendar: calendar + ).map(\.processIdentity.pid), [carrier.pid]) + } + + func testIndexedCorrelationHandlesFiveHundredRecordsAndOneThousandProcesses() { + let records = (0..<500).map { index in + automationRecord(Fixtures.userAgent, executable: "/opt/jobs/job-\(index)", arguments: ["--exact", "\(index)"]) + } + let processes = (0..<1_000).map { index in + DevProcess( + pid: Int32(10_000 + index), parentPID: 1, + executable: "/opt/jobs/job-\(index)", command: "/opt/jobs/job-\(index) --exact \(index)", + argumentVector: ["/opt/jobs/job-\(index)", "--exact", "\(index)"], + birthToken: ProcessBirthToken(seconds: UInt64(index + 1), microseconds: 1) + ) + } + let start = Date() + + let links = AutomationProcessCorrelator.links(records: records, processes: processes, now: start) + + XCTAssertEqual(links.count, 500) + XCTAssertLessThan(Date().timeIntervalSince(start), 2) + } + + func testCompositeNonCronIndexBoundsSharedExecutableCandidateWork() { + let records = (0..<500).map { index in + automationRecord(Fixtures.userAgent, executable: "/opt/jobs/shared", arguments: ["--job", "\(index)"]) + } + let processes = (0..<1_000).map { index in + DevProcess( + pid: Int32(20_000 + index), parentPID: 1, + executable: "/opt/jobs/shared", command: "display text is not evidence", + argumentVector: ["/opt/jobs/shared", "--job", "\(index)"], + birthToken: ProcessBirthToken(seconds: UInt64(index + 1), microseconds: 2) + ) + } + var work = AutomationCorrelationWork() + + let links = AutomationProcessCorrelator.links( + records: records, processes: processes, + now: Date(timeIntervalSince1970: 30_000), work: &work + ) + + XCTAssertEqual(links.count, 500) + XCTAssertEqual(work.indexedProcesses, 1_000) + XCTAssertEqual(work.nonCronRecordLookups, 1_000) + XCTAssertEqual(work.nonCronCandidateEvaluations, 500) + } + + func testCompositeNonCronIndexReturnsEverySameKeyProcessDeterministically() { + let first = DevProcess( + pid: 401, parentPID: 1, executable: "/bin/sleep", + command: "not evidence", argumentVector: ["/bin/sleep", "14400"], + birthToken: ProcessBirthToken(seconds: 40, microseconds: 1) + ) + let second = DevProcess( + pid: 402, parentPID: 1, executable: "/bin/sleep", + command: "also not evidence", argumentVector: ["/bin/sleep", "14400"], + birthToken: ProcessBirthToken(seconds: 40, microseconds: 2) + ) + + let forward = AutomationProcessCorrelator.links( + records: [Fixtures.userAgent], processes: [first, second], + now: Date(timeIntervalSince1970: 50_000) + ) + let reversed = AutomationProcessCorrelator.links( + records: [Fixtures.userAgent], processes: [second, first], + now: Date(timeIntervalSince1970: 50_000) + ) + + XCTAssertEqual(forward.map(\.processIdentity.pid), [401, 402]) + XCTAssertEqual(reversed, forward) + } + + func testIndexedCronWorkIsBoundedForFiveHundredRecordsAndOneThousandProcesses() { + let calendar = utcCalendar() + let now = date(2026, 7, 13, 10, 5, 30, calendar: calendar) + let records = (0..<500).map { index in + cronRecord(signature: "/bin/job-\(index)", expression: "5 10 * * *") + } + let daemon = cronDaemon() + let carriers = (0..<500).map { index in + DevProcess( + pid: Int32(1_000 + index), parentPID: daemon.pid, + executable: "/bin/sh", command: "display text is not evidence", + argumentVector: ["/bin/sh", "-c", "/bin/job-\(index)"], + resourceUsage: usage("00:00:30"), + birthToken: ProcessBirthToken(seconds: UInt64(100 + index), microseconds: 1) + ) + } + let noise = (0..<499).map { index in + DevProcess( + pid: Int32(2_000 + index), parentPID: 1, + executable: "/bin/noise-\(index)", command: "/bin/noise-\(index)", + argumentVector: ["/bin/noise-\(index)"], + birthToken: ProcessBirthToken(seconds: UInt64(1_000 + index), microseconds: 1) + ) + } + var work = AutomationCorrelationWork() + + let links = AutomationProcessCorrelator.links( + records: records, processes: [daemon] + carriers + noise, + now: now, calendar: calendar, work: &work + ) + + XCTAssertEqual(links.count, 500) + XCTAssertEqual(work.indexedProcesses, 1_000) + XCTAssertEqual(work.cronRecordLookups, 500) + XCTAssertEqual(work.cronCandidateEvaluations, 500) + XCTAssertLessThanOrEqual(work.ancestrySteps, 2_000) + } +} + +private func automationRecord( + _ source: AutomationRecord, + label: String? = nil, + executable: String? = nil, + arguments: [String]? = nil +) -> AutomationRecord { + let label = label ?? source.label + return AutomationRecord( + id: AutomationRecord.ID( + source: source.sourceKind, ownerUID: source.ownerUID ?? 0, + label: label, sourcePath: executable ?? source.sourceURL?.path ?? label + ), + kind: source.kind, sourceKind: source.sourceKind, label: label, + displayName: source.displayName, + providerBundleIdentifier: source.providerBundleIdentifier, + ownerUID: source.ownerUID, ownership: source.ownership, + executable: executable ?? source.executable, + arguments: arguments ?? source.arguments, + commandSignature: source.commandSignature, + environment: source.environment, workingDirectory: source.workingDirectory, + schedule: source.schedule, sourceURL: source.sourceURL, + sourceChecksum: source.sourceChecksum, enabledState: source.enabledState, + loadState: source.loadState, approvalState: source.approvalState, state: source.state, + evidence: source.evidence, capabilities: source.capabilities, + validationFindings: source.validationFindings + ) +} + +private func cronRecord(signature: String, expression: String) -> AutomationRecord { + AutomationRecord( + id: AutomationRecord.ID(source: .crontab, ownerUID: 501, label: expression, sourcePath: "/cron/\(expression)"), + kind: .cron, sourceKind: .crontab, label: "Cron", displayName: "Cron", + providerBundleIdentifier: nil, ownerUID: 501, ownership: .user, + executable: nil, arguments: [], commandSignature: signature, + environment: [:], workingDirectory: nil, + schedule: AutomationSchedule(triggers: [.cron(expression)], summary: expression), + sourceURL: nil, sourceChecksum: nil, enabledState: .enabled, loadState: .unknown, + approvalState: .notApplicable, state: .running, evidence: [], capabilities: [], + validationFindings: [] + ) +} + +private func usage(_ elapsed: String) -> DevProcessResourceUsage { + DevProcessResourceUsage(cpuPercent: 0, residentMemoryBytes: 0, elapsedTime: elapsed) +} + +private func utcCalendar() -> Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar +} + +private func cronDaemon() -> DevProcess { + DevProcess( + pid: 700, parentPID: 1, executable: "/usr/sbin/cron", command: "/usr/sbin/cron", + argumentVector: ["/usr/sbin/cron"], + birthToken: ProcessBirthToken(seconds: 7, microseconds: 0) + ) +} + +private func cronCarrier(parentPID: Int32, elapsed: String) -> DevProcess { + DevProcess( + pid: 701, parentPID: parentPID, executable: "/bin/sh", + command: "/bin/sh -c '/bin/sleep 60'", + argumentVector: ["/bin/sh", "-c", "/bin/sleep 60"], + resourceUsage: usage(elapsed), + birthToken: ProcessBirthToken(seconds: 7, microseconds: 1) + ) +} + +private func date( + _ year: Int, _ month: Int, _ day: Int, _ hour: Int, _ minute: Int, _ second: Int, + calendar: Calendar +) -> Date { + calendar.date(from: DateComponents( + year: year, month: month, day: day, hour: hour, minute: minute, second: second + ))! +} diff --git a/Tests/DevScopeCoreTests/AutomationTestFixtures.swift b/Tests/DevScopeCoreTests/AutomationTestFixtures.swift new file mode 100644 index 0000000..50fcd6d --- /dev/null +++ b/Tests/DevScopeCoreTests/AutomationTestFixtures.swift @@ -0,0 +1,864 @@ +import Foundation +@testable import DevScopeCore + +enum Fixtures { + static let inventoryGeneration7 = AutomationInventorySnapshot( + generation: 7, + records: [userAgent], + health: [ + .launchAgent: AutomationSourceHealth( + kind: .launchAgent, + state: .healthy, + message: nil, + refreshedAt: Date(timeIntervalSince1970: 7_000) + ), + ], + refreshedAt: Date(timeIntervalSince1970: 7_000) + ) + + static let userAgent = makeUserAgent( + idSource: .launchAgent, + kind: .launchAgent, + sourceKind: .launchAgent, + loadState: .unloaded, + state: .idle, + evidenceSource: "synthetic launchd fixture" + ) + + static let runningUserAgent = makeUserAgent( + idSource: .launchAgent, + kind: .launchAgent, + sourceKind: .launchAgent, + loadState: .loaded, + state: .running, + evidenceSource: "synthetic launchd fixture" + ) + + static let backgroundCopyOfUserAgent = makeUserAgent( + idSource: .serviceManagement, + kind: .backgroundItem, + sourceKind: .serviceManagement, + loadState: .loaded, + state: .running, + evidenceSource: "synthetic background fixture" + ) + + static let runningBackup = DevProcess( + pid: 42_001, + parentPID: 1, + executable: "/bin/sleep", + command: "/bin/sleep 14400", + argumentVector: ["/bin/sleep", "14400"], + currentDirectory: "/tmp/devscope-fixtures", + resourceUsage: DevProcessResourceUsage( + cpuPercent: 0.1, + residentMemoryBytes: 1_048_576, + elapsedTime: "04:00:00" + ), + birthToken: ProcessBirthToken(seconds: 10_000, microseconds: 42) + ) + + static let runningBackupWithUpdatedMetrics = DevProcess( + pid: runningBackup.pid, + parentPID: runningBackup.parentPID, + executable: runningBackup.executable, + command: runningBackup.command, + argumentVector: runningBackup.argumentVector, + currentDirectory: runningBackup.currentDirectory, + resourceUsage: DevProcessResourceUsage( + cpuPercent: 0.2, + residentMemoryBytes: 1_114_112, + elapsedTime: "04:00:02" + ), + birthToken: runningBackup.birthToken + ) + + static func presentation(elapsedTime: String) -> AutomationPresentationSnapshot { + let process = DevProcess( + pid: runningBackup.pid, + parentPID: runningBackup.parentPID, + executable: runningBackup.executable, + command: runningBackup.command, + argumentVector: runningBackup.argumentVector, + currentDirectory: runningBackup.currentDirectory, + resourceUsage: DevProcessResourceUsage( + cpuPercent: runningBackup.resourceUsage?.cpuPercent ?? 0, + residentMemoryBytes: runningBackup.resourceUsage?.residentMemoryBytes ?? 0, + elapsedTime: elapsedTime + ), + birthToken: runningBackup.birthToken + ) + return AutomationPresentationSnapshot.build( + inventory: inventoryGeneration7, + processes: [process], + longRunningThreshold: 14_400, + now: Date(timeIntervalSince1970: 10_000) + ) + } + + private static func makeUserAgent( + idSource: AutomationSourceKind, + kind: AutomationKind, + sourceKind: AutomationSourceKind, + loadState: AutomationLoadState, + state: AutomationState, + evidenceSource: String + ) -> AutomationRecord { + let label = "com.example.backup" + let sourcePath = "/tmp/devscope-fixtures/\(sourceKind.rawValue)-backup.plist" + + return AutomationRecord( + id: AutomationRecord.ID( + source: idSource, + ownerUID: 501, + label: label, + sourcePath: sourcePath + ), + kind: kind, + sourceKind: sourceKind, + label: label, + displayName: "Synthetic Backup Fixture", + providerBundleIdentifier: "com.example.devscope-fixture-owner", + ownerUID: 501, + ownership: .user, + executable: "/bin/sleep", + arguments: ["14400"], + environment: ["DEVSCOPE_FIXTURE": "synthetic"], + workingDirectory: "/tmp/devscope-fixtures", + schedule: AutomationSchedule( + triggers: [.runAtLoad, .interval(seconds: 14_400)], + summary: "At load and every four hours" + ), + sourceURL: URL(fileURLWithPath: sourcePath), + sourceChecksum: "synthetic-checksum", + enabledState: .enabled, + loadState: loadState, + approvalState: .notApplicable, + state: state, + evidence: [AutomationEvidence( + strength: .strong, + source: evidenceSource, + detail: "Exact synthetic label and /bin/sleep executable" + )], + capabilities: [.startNow, .disable, .disableAndStop, .edit, .exportRecord], + validationFindings: [] + ) + } +} + +struct FakeAutomationSource: AutomationSource { + let kind: AutomationSourceKind + private let value: AutomationSourceSnapshot + + init(snapshot: AutomationSourceSnapshot) { + kind = snapshot.health.kind + value = snapshot + } + + func snapshot() async -> AutomationSourceSnapshot { + value + } +} + +final class ControllableAutomationClock: @unchecked Sendable { + private let lock = NSLock() + private var value: Date + + init(_ value: Date) { + self.value = value + } + + func now() -> Date { + lock.withLock { value } + } + + func advance(by interval: TimeInterval) { + lock.withLock { + value = value.addingTimeInterval(interval) + } + } +} + +actor CountingAutomationSource: AutomationSource { + nonisolated let kind: AutomationSourceKind + private let value: AutomationSourceSnapshot + private let delayNanoseconds: UInt64 + private var count = 0 + + init(snapshot: AutomationSourceSnapshot, delayNanoseconds: UInt64 = 0) { + kind = snapshot.health.kind + value = snapshot + self.delayNanoseconds = delayNanoseconds + } + + func snapshot() async -> AutomationSourceSnapshot { + count += 1 + if delayNanoseconds > 0 { + try? await Task.sleep(nanoseconds: delayNanoseconds) + } + return value + } + + func invocationCount() -> Int { + count + } +} + +enum AutomationTestFixtureError: Error, Equatable, Sendable { + case commandFailed + case missingItem(URL) + case readFailed(URL) + case authorizationFailed(URL) + case removeFailed(URL) +} + +final class RecordingAutomationCommandRunner: AutomationCommandRunning, @unchecked Sendable { + private let lock = NSLock() + private var recordedInvocations: [AutomationCommand] = [] + private let result: Result + + init( + result: Result = .success( + AutomationCommandResult( + status: 0, + standardOutput: Data(), + standardError: Data() + ) + ) + ) { + self.result = result + } + + var invocations: [AutomationCommand] { + lock.withLock { recordedInvocations } + } + + func run(_ command: AutomationCommand) async throws -> AutomationCommandResult { + lock.withLock { + recordedInvocations.append(command) + } + return try result.get() + } +} + +final class InMemoryAutomationFileSystem: AutomationFileSystem, @unchecked Sendable { + enum Operation: Equatable { + case createDirectory(URL, permissions: Int) + case writeTemporary(URL, permissions: Int) + case replace(URL, URL) + case moveToTrash(URL, URL) + case remove(URL) + } + + private let lock = NSLock() + private var files: [URL: Data] + private var metadataByURL: [URL: AutomationFileMetadata] + private var directories: Set + private var failingReads: Set + private var metadataFailuresRemainingAfterReplace: [URL: Int] + private var metadataOverridesAfterReplace: [URL: AutomationFileMetadata] + private var dataOverridesAfterReplace: [URL: [Int: Data]] + private var partialReplaceOccurrences: [URL: Set] + private var partialReplaceRetainedStagedOccurrences: [URL: Set] + private var unchangedReplaceOccurrences: [URL: Set] + private let unchangedReplaceRetainsStaged: Bool + private var partialReplaceObservedOnly: [URL: [URL]] + private var replaceCounts: [URL: Int] = [:] + private var partialTrashSources: Set + private var partialRemoveSources: Set + private var failingRemoves: Set + private var unchangedRemoves: Set + private var failStagedRemoves: Bool + private var filesInstalledBeforeRemoveFailure: [URL: [URL: Data]] + private var readObserver: (@Sendable (URL, Data) -> Void)? + private var operations: [Operation] = [] + private var temporaryFileSequence = 0 + + init( + files: [URL: Data] = [:], + metadata: [URL: AutomationFileMetadata] = [:], + directories: Set = [], + failingReads: Set = [], + metadataFailuresAfterReplace: [URL: Int] = [:], + metadataAfterReplace: [URL: AutomationFileMetadata] = [:], + dataAfterReplace: [URL: Data] = [:], + dataAfterReplaceOccurrences: [URL: [Int: Data]] = [:], + partialReplaceAfterCommit: Set = [], + partialReplaceAfterCommitOccurrences: [URL: Set] = [:], + partialReplaceRetainsStagedOccurrences: [URL: Set] = [:], + unchangedReplaceOccurrences: [URL: Set] = [:], + unchangedReplaceRetainsStaged: Bool = false, + partialReplaceObservedOnly: [URL: [URL]] = [:], + partialTrashAfterCommit: Set = [], + partialRemoveAfterCommit: Set = [], + failingRemoves: Set = [], + unchangedRemoves: Set = [], + failStagedRemoves: Bool = false, + filesInstalledBeforeRemoveFailure: [URL: [URL: Data]] = [:] + ) { + self.files = Dictionary( + files.map { (Self.canonical($0.key), $0.value) }, + uniquingKeysWith: { _, latest in latest } + ) + metadataByURL = Dictionary( + metadata.map { (Self.canonical($0.key), $0.value) }, + uniquingKeysWith: { _, latest in latest } + ) + self.directories = Set( + directories.map(Self.canonical) + + files.keys.map { Self.canonical($0.deletingLastPathComponent()) } + ) + self.failingReads = Set(failingReads.map(Self.canonical)) + metadataFailuresRemainingAfterReplace = Dictionary( + metadataFailuresAfterReplace.map { (Self.canonical($0.key), $0.value) }, + uniquingKeysWith: + + ) + metadataOverridesAfterReplace = Dictionary( + metadataAfterReplace.map { (Self.canonical($0.key), $0.value) }, + uniquingKeysWith: { _, latest in latest } + ) + dataOverridesAfterReplace = Dictionary( + dataAfterReplaceOccurrences.map { (Self.canonical($0.key), $0.value) }, + uniquingKeysWith: { _, latest in latest } + ) + for (destination, data) in dataAfterReplace { + dataOverridesAfterReplace[Self.canonical(destination), default: [:]][1] = data + } + partialReplaceOccurrences = Dictionary( + partialReplaceAfterCommitOccurrences.map { (Self.canonical($0.key), $0.value) }, + uniquingKeysWith: { $0.union($1) } + ) + for destination in partialReplaceAfterCommit.map(Self.canonical) { + partialReplaceOccurrences[destination, default: []].insert(1) + } + partialReplaceRetainedStagedOccurrences = Dictionary( + partialReplaceRetainsStagedOccurrences.map { (Self.canonical($0.key), $0.value) }, + uniquingKeysWith: { $0.union($1) } + ) + self.unchangedReplaceOccurrences = Dictionary( + unchangedReplaceOccurrences.map { (Self.canonical($0.key), $0.value) }, + uniquingKeysWith: { $0.union($1) } + ) + self.unchangedReplaceRetainsStaged = unchangedReplaceRetainsStaged + self.partialReplaceObservedOnly = Dictionary( + partialReplaceObservedOnly.map { + (Self.canonical($0.key), $0.value.map(Self.canonical)) + }, + uniquingKeysWith: + + ) + partialTrashSources = Set(partialTrashAfterCommit.map(Self.canonical)) + partialRemoveSources = Set(partialRemoveAfterCommit.map(Self.canonical)) + self.failingRemoves = Set(failingRemoves.map(Self.canonical)) + self.unchangedRemoves = Set(unchangedRemoves.map(Self.canonical)) + self.failStagedRemoves = failStagedRemoves + self.filesInstalledBeforeRemoveFailure = Dictionary( + filesInstalledBeforeRemoveFailure.map { failureURL, installedFiles in + ( + Self.canonical(failureURL), + Dictionary( + installedFiles.map { (Self.canonical($0.key), $0.value) }, + uniquingKeysWith: { _, latest in latest } + ) + ) + }, + uniquingKeysWith: { _, latest in latest } + ) + } + + var recordedOperations: [Operation] { + lock.withLock { operations } + } + + func itemExists(at url: URL) -> Bool { + let url = Self.canonical(url) + return lock.withLock { files[url] != nil || directories.contains(url) } + } + + func storedData(at url: URL) -> Data? { + lock.withLock { files[Self.canonical(url)] } + } + + func setStoredData(_ data: Data, at url: URL) { + let url = Self.canonical(url) + lock.withLock { + files[url] = data + directories.insert(Self.canonical(url.deletingLastPathComponent())) + } + } + + func setMetadata(_ metadata: AutomationFileMetadata, for url: URL) { + lock.withLock { + metadataByURL[Self.canonical(url)] = metadata + } + } + + func setReadObserver(_ observer: (@Sendable (URL, Data) -> Void)?) { + lock.withLock { readObserver = observer } + } + + func plistURLs(in directory: URL) throws -> [URL] { + let directory = Self.canonical(directory) + return try lock.withLock { + guard directories.contains(directory) else { + throw AutomationTestFixtureError.missingItem(directory) + } + return files.keys + .filter { + $0.pathExtension == "plist" + && Self.canonical($0.deletingLastPathComponent()) == directory + } + .sorted { $0.path < $1.path } + } + } + + func read(_ url: URL) throws -> Data { + let url = Self.canonical(url) + let (data, observer) = try lock.withLock { + guard !failingReads.contains(url) else { + throw AutomationTestFixtureError.readFailed(url) + } + guard let data = files[url] else { + throw AutomationTestFixtureError.missingItem(url) + } + return (data, readObserver) + } + observer?(url, data) + return data + } + + func metadata(for url: URL) throws -> AutomationFileMetadata { + let url = Self.canonical(url) + return try lock.withLock { + let wasReplaced = operations.contains { operation in + if case .replace(let replacedURL, _) = operation { return replacedURL == url } + return false + } + if wasReplaced, let failures = metadataFailuresRemainingAfterReplace[url], failures > 0 { + metadataFailuresRemainingAfterReplace[url] = failures - 1 + throw AutomationTestFixtureError.readFailed(url) + } + guard files[url] != nil || directories.contains(url) else { + throw AutomationTestFixtureError.missingItem(url) + } + return metadataByURL[url] ?? Self.defaultMetadata(for: url) + } + } + + func createDirectory(_ url: URL, permissions: Int) throws { + let url = Self.canonical(url) + lock.withLock { + directories.insert(url) + let existing = metadataByURL[url] ?? Self.defaultMetadata(for: url) + metadataByURL[url] = AutomationFileMetadata( + canonicalURL: url, + ownerUID: existing.ownerUID, + isSymbolicLink: existing.isSymbolicLink, + modificationDate: existing.modificationDate, + resourceIdentifier: existing.resourceIdentifier, + permissions: permissions + ) + operations.append(.createDirectory(url, permissions: permissions)) + } + } + + func writeStagedFile( + nextTo url: URL, + data: Data, + permissions: Int, + authorization: AutomationDirectoryAuthorization + ) throws -> AutomationStagedFile { + let url = Self.canonical(url) + return try lock.withLock { + let directoryURL = Self.canonical(authorization.directoryURL) + guard directoryURL.path == Self.canonical(url.deletingLastPathComponent()).path, + directories.contains(directoryURL), + (metadataByURL[directoryURL] ?? Self.defaultMetadata(for: directoryURL)) + .resourceIdentifier == authorization.resourceIdentifier + else { throw AutomationTestFixtureError.authorizationFailed(directoryURL) } + + temporaryFileSequence += 1 + let temporaryURL = Self.canonical( + directoryURL.appendingPathComponent(".devscope-fixture-\(temporaryFileSequence).tmp") + ) + files[temporaryURL] = data + let metadata = Self.defaultMetadata(for: temporaryURL) + metadataByURL[temporaryURL] = AutomationFileMetadata( + canonicalURL: temporaryURL, + ownerUID: metadata.ownerUID, + isSymbolicLink: false, + modificationDate: metadata.modificationDate, + resourceIdentifier: metadata.resourceIdentifier, + permissions: permissions + ) + operations.append(.writeTemporary(temporaryURL, permissions: permissions)) + guard let fileAuthorization = AutomationFileAuthorization( + fileURL: temporaryURL, + directory: authorization, + expectation: .existing(resourceIdentifier: metadata.resourceIdentifier ?? "") + ) else { throw AutomationTestFixtureError.authorizationFailed(temporaryURL) } + return AutomationStagedFile( + url: temporaryURL, + authorization: fileAuthorization, + binding: AutomationStagedFileBinding( + data: data, + resourceIdentifier: metadata.resourceIdentifier ?? "", + ownerUID: metadata.ownerUID, + permissions: permissions, + linkCount: 1 + ) + ) + } + } + + func replaceItem( + at destination: AutomationFileAuthorization, + with staged: AutomationStagedFile + ) throws -> AutomationFileMutationOutcome { + let destinationURL = Self.canonical(destination.fileURL) + let stagedURL = Self.canonical(staged.url) + return try lock.withLock { + let directoryURL = Self.canonical(destination.directory.directoryURL) + let directoryMetadata = metadataByURL[directoryURL] ?? Self.defaultMetadata(for: directoryURL) + guard directories.contains(directoryURL), + directoryMetadata.resourceIdentifier == destination.directory.resourceIdentifier, + destinationURL.deletingLastPathComponent().path == directoryURL.path, + staged.authorization.directory == destination.directory, + Self.canonical(staged.authorization.fileURL) == stagedURL, + stagedURL.deletingLastPathComponent().path == directoryURL.path, + let data = files[stagedURL], + let stagedMetadata = metadataByURL[stagedURL], + case .existing(let stagedIdentity) = staged.authorization.expectation, + stagedMetadata.resourceIdentifier == stagedIdentity, + staged.binding.resourceIdentifier == stagedIdentity, + staged.binding.data == data, + staged.binding.ownerUID == stagedMetadata.ownerUID, + staged.binding.permissions == stagedMetadata.permissions, + staged.binding.linkCount == 1 + else { throw AutomationTestFixtureError.authorizationFailed(destinationURL) } + + switch destination.expectation { + case .absent: + guard files[destinationURL] == nil else { + throw AutomationTestFixtureError.authorizationFailed(destinationURL) + } + case .existing(let expectedIdentity): + guard files[destinationURL] != nil, + (metadataByURL[destinationURL] ?? Self.defaultMetadata(for: destinationURL)) + .resourceIdentifier == expectedIdentity + else { throw AutomationTestFixtureError.authorizationFailed(destinationURL) } + } + + let replaceCount = (replaceCounts[destinationURL] ?? 0) + 1 + replaceCounts[destinationURL] = replaceCount + if unchangedReplaceOccurrences[destinationURL]?.remove(replaceCount) != nil { + if !unchangedReplaceRetainsStaged { + files.removeValue(forKey: stagedURL) + metadataByURL.removeValue(forKey: stagedURL) + } + return .unchanged + } + + let displacedData = files[destinationURL] + let displacedMetadata = metadataByURL[destinationURL] + ?? Self.defaultMetadata(for: destinationURL) + + files[destinationURL] = data + metadataByURL[destinationURL] = AutomationFileMetadata( + canonicalURL: destinationURL, + ownerUID: stagedMetadata.ownerUID, + isSymbolicLink: false, + modificationDate: stagedMetadata.modificationDate, + resourceIdentifier: stagedMetadata.resourceIdentifier, + permissions: stagedMetadata.permissions + ) + if let override = metadataOverridesAfterReplace[destinationURL] { + metadataByURL[destinationURL] = override + } + if let override = dataOverridesAfterReplace[destinationURL]?.removeValue( + forKey: replaceCount + ) { + files[destinationURL] = override + } + files.removeValue(forKey: stagedURL) + metadataByURL.removeValue(forKey: stagedURL) + operations.append(.replace(destinationURL, stagedURL)) + let installed = AutomationFileAuthorization( + fileURL: destinationURL, + directory: destination.directory, + expectation: .existing(resourceIdentifier: staged.binding.resourceIdentifier) + ) + let shouldReportPartial = partialReplaceOccurrences[destinationURL]?.contains( + replaceCount + ) == true + partialReplaceOccurrences[destinationURL]?.remove(replaceCount) + if shouldReportPartial, let installed { + let retainsStagedRecovery = partialReplaceRetainedStagedOccurrences[destinationURL]? + .remove(replaceCount) != nil + if retainsStagedRecovery { + files[stagedURL] = data + metadataByURL[stagedURL] = stagedMetadata + } + let recovery: AutomationFileAuthorization + if let displacedData { + temporaryFileSequence += 1 + let recoveryURL = Self.canonical( + destination.directory.directoryURL.appendingPathComponent( + ".devscope-fixture-displaced-\(temporaryFileSequence)" + ) + ) + files[recoveryURL] = displacedData + metadataByURL[recoveryURL] = AutomationFileMetadata( + canonicalURL: recoveryURL, + ownerUID: displacedMetadata.ownerUID, + isSymbolicLink: false, + modificationDate: displacedMetadata.modificationDate, + resourceIdentifier: displacedMetadata.resourceIdentifier, + permissions: displacedMetadata.permissions + ) + guard let displacedIdentity = displacedMetadata.resourceIdentifier, + let displacedRecovery = AutomationFileAuthorization( + fileURL: recoveryURL, + directory: destination.directory, + expectation: .existing(resourceIdentifier: displacedIdentity) + ) + else { throw AutomationTestFixtureError.authorizationFailed(recoveryURL) } + recovery = displacedRecovery + } else { + recovery = installed + } + let observedOnly = (partialReplaceObservedOnly[destinationURL] ?? []).compactMap { + observedURL -> AutomationFileAuthorization? in + guard let observedIdentity = (metadataByURL[observedURL] + ?? Self.defaultMetadata(for: observedURL)).resourceIdentifier, + files[observedURL] != nil + else { return nil } + return AutomationFileAuthorization( + fileURL: observedURL, + directory: destination.directory, + expectation: .existing(resourceIdentifier: observedIdentity) + ) + } + let retainedStaged = retainsStagedRecovery ? [staged.authorization] : [] + throw AutomationFilePartialMutation( + kind: .replace, + commitState: .committed, + observedFiles: [installed, recovery] + retainedStaged + observedOnly, + recoveryHandle: recovery, + recoveryHandles: retainedStaged, + resultURL: destinationURL + ) + } + return .committed(AutomationFileMutationReceipt( + primaryFile: installed, + resultURL: destinationURL, + bindingVerified: true + )) + } + } + + func moveToTrash( + _ source: AutomationFileAuthorization + ) throws -> AutomationFileMutationOutcome { + let sourceURL = Self.canonical(source.fileURL) + return try lock.withLock { + let directoryURL = Self.canonical(source.directory.directoryURL) + guard directories.contains(directoryURL), + (metadataByURL[directoryURL] ?? Self.defaultMetadata(for: directoryURL)) + .resourceIdentifier == source.directory.resourceIdentifier, + sourceURL.deletingLastPathComponent().path == directoryURL.path, + case .existing(let expectedIdentity) = source.expectation, + files[sourceURL] != nil + else { throw AutomationTestFixtureError.authorizationFailed(sourceURL) } + let sourceMetadata = metadataByURL[sourceURL] ?? Self.defaultMetadata(for: sourceURL) + guard let data = files[sourceURL], + sourceMetadata.resourceIdentifier == expectedIdentity + else { throw AutomationTestFixtureError.authorizationFailed(sourceURL) } + + temporaryFileSequence += 1 + let trashURL = Self.canonical(URL(fileURLWithPath: + "/tmp/devscope-fixtures/trash/\(temporaryFileSequence)-\(sourceURL.lastPathComponent)" + )) + files[trashURL] = data + metadataByURL[trashURL] = AutomationFileMetadata( + canonicalURL: trashURL, + ownerUID: sourceMetadata.ownerUID, + isSymbolicLink: false, + modificationDate: sourceMetadata.modificationDate, + resourceIdentifier: sourceMetadata.resourceIdentifier, + permissions: sourceMetadata.permissions + ) + files.removeValue(forKey: sourceURL) + metadataByURL.removeValue(forKey: sourceURL) + operations.append(.moveToTrash(sourceURL, trashURL)) + if partialTrashSources.remove(sourceURL) != nil { + let trashDirectory = Self.canonical(trashURL.deletingLastPathComponent()) + directories.insert(trashDirectory) + let directoryMetadata = metadataByURL[trashDirectory] + ?? Self.defaultMetadata(for: trashDirectory) + metadataByURL[trashDirectory] = directoryMetadata + guard let directory = AutomationDirectoryAuthorization( + directoryURL: trashDirectory, + resourceIdentifier: directoryMetadata.resourceIdentifier + ), let identity = sourceMetadata.resourceIdentifier, + let recovery = AutomationFileAuthorization( + fileURL: trashURL, + directory: directory, + expectation: .existing(resourceIdentifier: identity) + ) else { throw AutomationTestFixtureError.authorizationFailed(trashURL) } + throw AutomationFilePartialMutation( + kind: .trash, + commitState: .committed, + observedFiles: [recovery], + recoveryHandle: recovery, + resultURL: trashURL + ) + } + return .committed(AutomationFileMutationReceipt( + primaryFile: nil, + resultURL: trashURL, + bindingVerified: true + )) + } + } + + func fileMatchesBinding( + _ file: AutomationFileAuthorization, + binding: AutomationStagedFileBinding + ) throws -> Bool { + let url = Self.canonical(file.fileURL) + return lock.withLock { + guard case .existing(let expectedIdentity) = file.expectation, + let data = files[url] + else { return false } + let metadata = metadataByURL[url] ?? Self.defaultMetadata(for: url) + return expectedIdentity == binding.resourceIdentifier + && metadata.resourceIdentifier == binding.resourceIdentifier + && metadata.ownerUID == binding.ownerUID + && metadata.permissions == binding.permissions + && data == binding.data + } + } + + func removeItem( + _ source: AutomationFileAuthorization + ) throws -> AutomationFileMutationOutcome { + let sourceURL = Self.canonical(source.fileURL) + return try lock.withLock { + let directoryURL = Self.canonical(source.directory.directoryURL) + guard directories.contains(directoryURL), + (metadataByURL[directoryURL] ?? Self.defaultMetadata(for: directoryURL)) + .resourceIdentifier == source.directory.resourceIdentifier, + sourceURL.deletingLastPathComponent().path == directoryURL.path, + case .existing(let expectedIdentity) = source.expectation, + files[sourceURL] != nil, + (metadataByURL[sourceURL] ?? Self.defaultMetadata(for: sourceURL)) + .resourceIdentifier == expectedIdentity + else { throw AutomationTestFixtureError.authorizationFailed(sourceURL) } + if unchangedRemoves.contains(sourceURL) { + return .unchanged + } + if failingRemoves.contains(sourceURL) + || failStagedRemoves && sourceURL.lastPathComponent.hasPrefix(".devscope-fixture-") { + for (installedURL, installedData) in filesInstalledBeforeRemoveFailure[sourceURL] ?? [:] { + temporaryFileSequence += 1 + files[installedURL] = installedData + let metadata = Self.defaultMetadata(for: installedURL) + metadataByURL[installedURL] = AutomationFileMetadata( + canonicalURL: installedURL, + ownerUID: metadata.ownerUID, + isSymbolicLink: false, + modificationDate: metadata.modificationDate, + resourceIdentifier: "fixture:remove-failure:\(temporaryFileSequence):\(installedURL.path)", + permissions: metadata.permissions + ) + } + throw AutomationTestFixtureError.removeFailed(sourceURL) + } + let removedData = files[sourceURL] ?? Data() + let removedMetadata = metadataByURL[sourceURL] ?? Self.defaultMetadata(for: sourceURL) + files.removeValue(forKey: sourceURL) + metadataByURL.removeValue(forKey: sourceURL) + operations.append(.remove(sourceURL)) + if partialRemoveSources.remove(sourceURL) != nil { + temporaryFileSequence += 1 + let recoveryURL = Self.canonical( + directoryURL.appendingPathComponent(".devscope-fixture-remove-\(temporaryFileSequence)") + ) + files[recoveryURL] = removedData + metadataByURL[recoveryURL] = removedMetadata + guard let identity = removedMetadata.resourceIdentifier, + let recovery = AutomationFileAuthorization( + fileURL: recoveryURL, + directory: source.directory, + expectation: .existing(resourceIdentifier: identity) + ) + else { throw AutomationTestFixtureError.authorizationFailed(recoveryURL) } + throw AutomationFilePartialMutation( + kind: .remove, + commitState: .committed, + observedFiles: [recovery], + recoveryHandle: recovery, + resultURL: nil + ) + } + return .committed(AutomationFileMutationReceipt( + primaryFile: nil, + resultURL: nil, + bindingVerified: true + )) + } + } + + private static func canonical(_ url: URL) -> URL { + URL(fileURLWithPath: url.standardizedFileURL.path) + } + + private static func defaultMetadata(for url: URL) -> AutomationFileMetadata { + AutomationFileMetadata( + canonicalURL: canonical(url), + ownerUID: 501, + isSymbolicLink: false, + modificationDate: Date(timeIntervalSince1970: 1_000), + resourceIdentifier: "fixture:\(canonical(url).path)" + ) + } +} + +final class ControllableClock: @unchecked Sendable { + private let lock = NSLock() + private var currentDate: Date + + init(now: Date = Date(timeIntervalSince1970: 1_000)) { + currentDate = now + } + + func now() -> Date { + lock.withLock { currentDate } + } + + func advance(by interval: TimeInterval) { + lock.withLock { + currentDate = currentDate.addingTimeInterval(interval) + } + } +} + +extension AutomationCapabilityContext { + static func fixture( + currentUID: uid_t, + canonicalPathIsApproved: Bool, + ownerUID: uid_t?, + isSymlink: Bool = false, + isManaged: Bool = false, + implementedCapabilities: Set = Set(AutomationCapability.allCases) + ) -> Self { + Self( + currentUID: currentUID, + canonicalPathIsApproved: canonicalPathIsApproved, + sourceOwnerUID: ownerUID, + isSymlink: isSymlink, + isManaged: isManaged, + implementedCapabilities: implementedCapabilities + ) + } +} diff --git a/Tests/DevScopeCoreTests/BoundedSystemCommandRunnerTests.swift b/Tests/DevScopeCoreTests/BoundedSystemCommandRunnerTests.swift new file mode 100644 index 0000000..274d272 --- /dev/null +++ b/Tests/DevScopeCoreTests/BoundedSystemCommandRunnerTests.swift @@ -0,0 +1,149 @@ +import XCTest +@testable import DevScopeCore + +final class BoundedSystemCommandRunnerTests: XCTestCase { + func testProcessScannerRoutesPSAndLsofThroughTheBoundedRunner() throws { + let runner = RecordingSystemCommandRunner(results: [ + "/bin/ps": BoundedSystemCommandResult( + status: 0, + standardOutput: Data("42 1 1.5 2048 00:10 /bin/sleep /bin/sleep 10\n".utf8), + standardError: Data() + ), + "/usr/sbin/lsof": BoundedSystemCommandResult( + status: 0, + standardOutput: Data("p42\nn/var/tmp\n".utf8), + standardError: Data() + ), + ]) + let scanner = SystemProcessScanner( + nativeMetadataProvider: EmptyNativeMetadataProvider(), + commandRunner: runner + ) + + let snapshot = try scanner.snapshot(includeCurrentDirectories: true) + + XCTAssertEqual(snapshot.map(\.pid), [42]) + XCTAssertEqual(snapshot.first?.currentDirectory, "/var/tmp") + XCTAssertEqual(runner.requests.map(\.executablePath), ["/bin/ps", "/usr/sbin/lsof"]) + } + + func testGPUMetricProviderRoutesIORegThroughTheBoundedRunner() throws { + let runner = RecordingSystemCommandRunner(results: [ + "/usr/sbin/ioreg": BoundedSystemCommandResult( + status: 0, + standardOutput: Data("\"Device Utilization %\" = 37\n\"model\" = \"Apple GPU\"\n".utf8), + standardError: Data() + ) + ]) + let provider = SystemGPUMetricProvider( + executableURL: URL(fileURLWithPath: "/usr/sbin/ioreg"), + commandRunner: runner + ) + + XCTAssertEqual( + try provider.snapshot(), + DevGPUMetric(utilizationPercent: 37, modelName: "Apple GPU") + ) + XCTAssertEqual(runner.requests.map(\.executablePath), ["/usr/sbin/ioreg"]) + } + + func testTimeoutTerminatesTheCompleteCommandGroupWithinBound() throws { + let runner = BoundedSystemCommandRunner( + maximumCapturedBytes: 1_024, + executionTimeout: 0.05, + terminationGraceInterval: 0.05 + ) + let startedAt = ContinuousClock.now + + XCTAssertThrowsError( + try runner.run( + executableURL: URL(fileURLWithPath: "/bin/sh"), + arguments: ["-c", "trap '' TERM; while :; do sleep 1; done"] + ) + ) { error in + XCTAssertEqual(error as? BoundedSystemCommandError, .executionTimedOut) + } + + XCTAssertLessThan(startedAt.duration(to: .now), .seconds(2)) + } + + func testOutputLimitDrainsBothStreamsAndFailsClosed() throws { + let runner = BoundedSystemCommandRunner( + maximumCapturedBytes: 1_024, + executionTimeout: 2, + terminationGraceInterval: 0.05 + ) + + XCTAssertThrowsError( + try runner.run( + executableURL: URL(fileURLWithPath: "/bin/sh"), + arguments: [ + "-c", + "i=0; while [ $i -lt 20000 ]; do printf x; printf y >&2; i=$((i+1)); done", + ] + ) + ) { error in + XCTAssertEqual(error as? BoundedSystemCommandError, .outputLimitExceeded) + } + } + + func testSuccessfulCommandReturnsExactStatusAndBothStreams() throws { + let result = try BoundedSystemCommandRunner().run( + executableURL: URL(fileURLWithPath: "/bin/sh"), + arguments: ["-c", "printf output; printf warning >&2; exit 7"] + ) + + XCTAssertEqual(result.status, 7) + XCTAssertEqual(String(data: result.standardOutput, encoding: .utf8), "output") + XCTAssertEqual(String(data: result.standardError, encoding: .utf8), "warning") + } + + func testCommandReceivesTheIntendedMinimalUserEnvironment() throws { + let result = try BoundedSystemCommandRunner().run( + executableURL: URL(fileURLWithPath: "/bin/sh"), + arguments: ["-c", "printf '%s\\n%s\\n%s' \"$HOME\" \"$USER\" \"$LC_ALL\""] + ) + let lines = String(decoding: result.standardOutput, as: UTF8.self) + .split(whereSeparator: \.isNewline) + .map(String.init) + + XCTAssertEqual(lines, [ + FileManager.default.homeDirectoryForCurrentUser.path, + NSUserName(), + "C", + ]) + } +} + +private struct SystemCommandRequest: Equatable, Sendable { + let executablePath: String + let arguments: [String] +} + +private final class RecordingSystemCommandRunner: SystemCommandRunning, @unchecked Sendable { + private let lock = NSLock() + private let results: [String: BoundedSystemCommandResult] + private var recordedRequests: [SystemCommandRequest] = [] + + init(results: [String: BoundedSystemCommandResult]) { + self.results = results + } + + var requests: [SystemCommandRequest] { + lock.withLock { recordedRequests } + } + + func run(executableURL: URL, arguments: [String]) throws -> BoundedSystemCommandResult { + try lock.withLock { + recordedRequests.append(SystemCommandRequest( + executablePath: executableURL.path, + arguments: arguments + )) + return try XCTUnwrap(results[executableURL.path]) + } + } +} + +private struct EmptyNativeMetadataProvider: NativeProcessMetadataProviding { + func metadata(for processID: Int32) -> DevProcess? { nil } +} diff --git a/Tests/DevScopeCoreTests/CronAutomationSourceTests.swift b/Tests/DevScopeCoreTests/CronAutomationSourceTests.swift new file mode 100644 index 0000000..3c9061a --- /dev/null +++ b/Tests/DevScopeCoreTests/CronAutomationSourceTests.swift @@ -0,0 +1,351 @@ +import XCTest +@testable import DevScopeCore + +final class CronAutomationSourceTests: XCTestCase { + func testParsesEnvironmentMacrosSchedulesAndDisabledDevScopeEntries() { + let document = CronParser.parse(""" + SHELL=/bin/zsh + PATH=/opt/homebrew/bin:/usr/bin:/bin + @reboot /Users/example/bin/start-sync + 0 9 * * 1 /Users/example/bin/report --weekly + # devscope-disabled: 30 2 * * * /Users/example/bin/cleanup + """) + + XCTAssertEqual(document.environment["SHELL"], "/bin/zsh") + XCTAssertEqual(document.entries.count, 3) + XCTAssertEqual(document.entries[0].schedule.summary, "At startup") + XCTAssertEqual(document.entries[1].schedule.summary, "Mondays at 09:00") + XCTAssertFalse(document.entries[2].isEnabled) + XCTAssertEqual(document.entries[2].scheduleExpression, "30 2 * * *") + XCTAssertEqual(document.entries[2].command, "/Users/example/bin/cleanup") + XCTAssertEqual( + document.originalLines[4], + "# devscope-disabled: 30 2 * * * /Users/example/bin/cleanup" + ) + XCTAssertEqual( + document.entries[2].id, + CronParser.parse("30 2 * * * /Users/example/bin/cleanup").entries[0].id + ) + } + + func testDisabledMarkerWithInvalidPayloadRemainsVisibleAndMakesSourcePartial() async { + let line = "# devscope-disabled: # not-a-cron-entry" + let document = CronParser.parse(line) + + XCTAssertTrue(document.entries.isEmpty) + XCTAssertEqual(document.invalidLines, [CronInvalidLine( + lineNumber: 1, + content: line, + reason: "Invalid crontab line" + )]) + + let snapshot = await CronAutomationSource( + commandRunner: RecordingAutomationCommandRunner(result: .success(AutomationCommandResult( + status: 0, + standardOutput: Data("\(line)\n".utf8), + standardError: Data() + ))), + currentUID: 501, + currentUsername: "test-user" + ).snapshot() + + XCTAssertEqual(snapshot.health.state, .partial) + XCTAssertEqual(snapshot.health.message, "1 invalid crontab line.") + XCTAssertEqual(snapshot.records.map(\.state), [.invalid]) + } + + func testPreservesOriginalTextEnvironmentOrderAndForwardOnlyContext() { + let source = """ + # retained comment + + TOKEN=first-secret + 0 1 * * * /bin/echo before%mail-body + TOKEN="second secret" + 0 2 * * * /bin/echo after\\%literal + + """ + + let document = CronParser.parse(source) + + XCTAssertEqual(document.originalText, source) + XCTAssertEqual(document.originalLines, source.components(separatedBy: "\n")) + XCTAssertEqual(document.environmentAssignments.map(\.name), ["TOKEN", "TOKEN"]) + XCTAssertEqual(document.environmentAssignments.map(\.lineNumber), [3, 5]) + XCTAssertEqual(document.entries[0].environment["TOKEN"], "first-secret") + XCTAssertEqual(document.entries[1].environment["TOKEN"], "second secret") + XCTAssertEqual(document.entries[0].command, "/bin/echo before%mail-body") + XCTAssertEqual(document.entries[1].command, "/bin/echo after\\%literal") + } + + func testValidatesOfficialFiveFieldFormsAndKeepsMalformedLinesVisible() { + let document = CronParser.parse(""" + */15 0-23/2 1,15 1-12 0-7 /bin/true + 5 4 * jan sun /bin/true + 30 4 1,15 * 5 /bin/true + 60 0 * * * /bin/echo minute-secret + 0 24 * * * /bin/echo hour-secret + 0 0 0 * * /bin/false + 0 0 * 13 * /bin/false + 0 0 * * 8 /bin/false + 0-10/0 * * * * /bin/false + 10-1 * * * * /bin/false + 1,,2 * * * * /bin/false + 0 0 * jan-mar * /bin/false + @annually /bin/false + @midnight /bin/false + not a cron line containing=secret + """) + + XCTAssertEqual(document.entries.count, 3) + XCTAssertEqual(document.invalidLines.map(\.lineNumber), Array(4...15)) + XCTAssertEqual( + document.entries[2].schedule.triggers, + [.cron("30 4 1,15 * 5")] + ) + XCTAssertTrue(document.invalidLines.allSatisfy { $0.reason == "Invalid crontab line" }) + } + + func testStableEntryIdentityExcludesEnvironmentAndCommandSecrets() { + let first = CronParser.parse(""" + API_TOKEN=alpha-secret + 0 6 * * * /bin/run --token command-alpha + 0 6 * * * /bin/run-second + """) + let second = CronParser.parse(""" + # an unrelated retained comment + API_TOKEN=beta-secret + EXTRA_SECRET=gamma-secret + 0 6 * * * /bin/run --token command-beta + 0 6 * * * /bin/run-second + """) + + XCTAssertEqual(first.entries.map(\.id), second.entries.map(\.id)) + XCTAssertEqual(Set(first.entries.map(\.id)).count, 2) + for id in first.entries.map(\.id) + second.entries.map(\.id) { + XCTAssertFalse(id.contains("secret")) + XCTAssertFalse(id.contains("token")) + XCTAssertFalse(id.contains("/bin/run")) + } + } + + func testSourceRunsCurrentUserCrontabWithoutShellAndMapsUIDAndSafeCapabilities() async { + let output = Data(""" + TOKEN=environment-secret + 0 6 * * * /bin/run --token command-secret + # devscope-disabled: @daily /bin/disabled + 61 * * * * /bin/invalid-secret + """.utf8) + let runner = RecordingAutomationCommandRunner(result: .success(AutomationCommandResult( + status: 0, + standardOutput: output, + standardError: Data() + ))) + let source = CronAutomationSource( + commandRunner: runner, + currentUID: 777, + currentUsername: "test-user" + ) + + let snapshot = await source.snapshot() + + XCTAssertEqual(runner.invocations, [AutomationCommand( + executable: "/usr/bin/crontab", + arguments: ["-l"], + environment: ["LC_ALL": "C"] + )]) + XCTAssertEqual(snapshot.health.state, .partial) + XCTAssertEqual(snapshot.health.message, "1 invalid crontab line.") + XCTAssertEqual(snapshot.records.count, 3) + XCTAssertTrue(snapshot.records.allSatisfy { $0.ownerUID == 777 }) + XCTAssertTrue(snapshot.records.allSatisfy { $0.ownership == .user }) + XCTAssertTrue(snapshot.records.allSatisfy { $0.sourceKind == .crontab }) + XCTAssertTrue(snapshot.records.allSatisfy { $0.kind == .cron }) + XCTAssertTrue(snapshot.records.allSatisfy { $0.capabilities.isEmpty }) + let expectedChecksum = CronDocumentChecksum.checksum(output) + XCTAssertTrue(snapshot.records.allSatisfy { $0.sourceChecksum == expectedChecksum }) + XCTAssertEqual(snapshot.records.map(\.state), [.idle, .disabled, .invalid]) + XCTAssertEqual(snapshot.records[0].environment["TOKEN"], "environment-secret") + XCTAssertEqual( + snapshot.records[0].commandSignature, + "/bin/run --token command-secret" + ) + + for record in snapshot.records { + let publicMetadata = [ + record.id.rawValue, + record.label, + record.displayName, + record.validationFindings.joined(separator: " "), + record.evidence.map(\.detail).joined(separator: " "), + ].joined(separator: " ").lowercased() + XCTAssertFalse(publicMetadata.contains("secret")) + XCTAssertFalse(publicMetadata.contains("/bin/")) + } + } + + func testFollowsOfficialCommentEnvironmentAndSupportedMacroGrammar() { + let document = CronParser.parse(""" + #TOKEN=ignored-comment + "SPACED NAME" = " value " + PLAIN = bare + @reboot /bin/one + @hourly /bin/two with args + @daily /bin/three + @weekly /bin/four + @monthly /bin/five + @yearly /bin/six + *,1 * * * * /bin/invalid + +1 * * * * /bin/invalid + UNFINISHED="unterminated + """) + + XCTAssertEqual(document.environmentAssignments.map(\.name), ["SPACED NAME", "PLAIN"]) + XCTAssertEqual(document.environment["SPACED NAME"], " value ") + XCTAssertNil(document.environment["#TOKEN"]) + XCTAssertEqual(document.entries.count, 6) + XCTAssertEqual(document.entries[1].command, "/bin/two with args") + XCTAssertEqual(document.entries.map(\.schedule.summary), [ + "At startup", "Hourly", "Daily", "Weekly", "Monthly", "Yearly", + ]) + XCTAssertEqual(document.invalidLines.map(\.lineNumber), [10, 11, 12]) + } + + func testSourceDistinguishesStandardNoCrontabFromAllOtherFailures() async { + let emptyRunner = RecordingAutomationCommandRunner(result: .success(AutomationCommandResult( + status: 1, + standardOutput: Data(), + standardError: Data("crontab: no crontab for ExampleUser\n".utf8) + ))) + let empty = await CronAutomationSource( + commandRunner: emptyRunner, + currentUID: 501, + currentUsername: "ExampleUser" + ).snapshot() + XCTAssertEqual(empty.health.state, .healthy) + XCTAssertNil(empty.health.message) + XCTAssertTrue(empty.records.isEmpty) + + let failures = [ + AutomationCommandResult( + status: 1, + standardOutput: Data(), + standardError: Data("crontab: permission denied for diagnostic-secret\n".utf8) + ), + AutomationCommandResult( + status: 2, + standardOutput: Data("0 0 * * * /bin/fabricated\n".utf8), + standardError: Data("diagnostic-secret\n".utf8) + ), + AutomationCommandResult( + status: 0, + standardOutput: Data([0xFF]), + standardError: Data() + ), + ] + + for result in failures { + let snapshot = await CronAutomationSource( + commandRunner: RecordingAutomationCommandRunner(result: .success(result)), + currentUID: 501, + currentUsername: "ExampleUser" + ).snapshot() + XCTAssertEqual(snapshot.health.kind, .crontab) + XCTAssertEqual(snapshot.health.state, .failed) + XCTAssertTrue(snapshot.records.isEmpty) + XCTAssertFalse(snapshot.health.message?.contains("secret") ?? false) + XCTAssertFalse(snapshot.health.message?.contains("/bin/fabricated") ?? false) + } + + let thrown = await CronAutomationSource( + commandRunner: RecordingAutomationCommandRunner(result: .failure(.commandFailed)), + currentUID: 501, + currentUsername: "ExampleUser" + ).snapshot() + XCTAssertEqual(thrown.health.state, .failed) + XCTAssertTrue(thrown.records.isEmpty) + } + + func testNoCrontabDiagnosticRejectsEveryNearMatchForInjectedUsername() async { + let diagnostic = "crontab: no crontab for ExampleUser" + let failures = [ + AutomationCommandResult( + status: 1, + standardOutput: Data(), + standardError: Data("crontab: no crontab for OtherUser\n".utf8) + ), + AutomationCommandResult( + status: 1, + standardOutput: Data(), + standardError: Data("\(diagnostic)-extra\n".utf8) + ), + AutomationCommandResult( + status: 1, + standardOutput: Data(), + standardError: Data("crontab: no crontab for exampleuser\n".utf8) + ), + AutomationCommandResult( + status: 1, + standardOutput: Data(), + standardError: Data(" \(diagnostic)\n".utf8) + ), + AutomationCommandResult( + status: 1, + standardOutput: Data(), + standardError: Data("\(diagnostic) \n".utf8) + ), + AutomationCommandResult( + status: 1, + standardOutput: Data(), + standardError: Data("\(diagnostic)\nextra line\n".utf8) + ), + AutomationCommandResult( + status: 1, + standardOutput: Data(), + standardError: Data("\(diagnostic)\n\n".utf8) + ), + AutomationCommandResult( + status: 1, + standardOutput: Data("unexpected stdout".utf8), + standardError: Data("\(diagnostic)\n".utf8) + ), + AutomationCommandResult( + status: 1, + standardOutput: Data(), + standardError: Data([0xFF]) + ), + AutomationCommandResult( + status: 2, + standardOutput: Data(), + standardError: Data("\(diagnostic)\n".utf8) + ), + ] + + for result in failures { + let snapshot = await CronAutomationSource( + commandRunner: RecordingAutomationCommandRunner(result: .success(result)), + currentUID: 501, + currentUsername: "ExampleUser" + ).snapshot() + + XCTAssertEqual(snapshot.health.state, .failed) + XCTAssertTrue(snapshot.records.isEmpty) + } + } + + func testNoCrontabDiagnosticAcceptsExactUsernameWithOneTerminalNewlineConvention() async { + for terminator in ["", "\n", "\r\n"] { + let snapshot = await CronAutomationSource( + commandRunner: RecordingAutomationCommandRunner(result: .success(AutomationCommandResult( + status: 1, + standardOutput: Data(), + standardError: Data("crontab: no crontab for ExampleUser\(terminator)".utf8) + ))), + currentUID: 501, + currentUsername: "ExampleUser" + ).snapshot() + + XCTAssertEqual(snapshot.health.state, .healthy) + XCTAssertTrue(snapshot.records.isEmpty) + } + } +} diff --git a/Tests/DevScopeCoreTests/FullDiskAccessGuidanceTests.swift b/Tests/DevScopeCoreTests/FullDiskAccessGuidanceTests.swift new file mode 100644 index 0000000..018c552 --- /dev/null +++ b/Tests/DevScopeCoreTests/FullDiskAccessGuidanceTests.swift @@ -0,0 +1,118 @@ +import XCTest + +@testable import DevScopeCore + +final class FullDiskAccessGuidanceTests: XCTestCase { + func testFullDiskAccessSettingsRouteStopsAfterAcceptedDeepLink() { + var openedURL: URL? + var fallbackCount = 0 + + let didOpen = FullDiskAccessSettingsRoute.open( + using: { url in + openedURL = url + return true + }, + fallback: { fallbackCount += 1 } + ) + + XCTAssertTrue(didOpen) + XCTAssertEqual( + openedURL?.absoluteString, + "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles" + ) + XCTAssertEqual(fallbackCount, 0) + } + + func testFullDiskAccessSettingsRouteFallsBackWhenDeepLinkIsRejected() { + var fallbackCount = 0 + + let didOpen = FullDiskAccessSettingsRoute.open( + using: { _ in false }, + fallback: { fallbackCount += 1 } + ) + + XCTAssertFalse(didOpen) + XCTAssertEqual(fallbackCount, 1) + } + + func testFullDiskAccessSettingsRouteFallsBackWhenDeepLinkIsInvalid() { + var openerCount = 0 + var fallbackCount = 0 + + let didOpen = FullDiskAccessSettingsRoute.open( + deepLink: "http://[", + using: { _ in + openerCount += 1 + return true + }, + fallback: { fallbackCount += 1 } + ) + + XCTAssertFalse(didOpen) + XCTAssertEqual(openerCount, 0) + XCTAssertEqual(fallbackCount, 1) + } + + func testFullInstalledBuildProvidesSixManualAddSteps() { + let guidance = FullDiskAccessGuidance( + appPath: "/Applications/DevScope.app", + isSandboxed: false + ) + + XCTAssertTrue(guidance.isInstalledInApplications) + XCTAssertEqual(guidance.title, "Add DevScope to Full Disk Access") + XCTAssertEqual(guidance.steps.count, 6) + XCTAssertTrue(guidance.steps[0].contains("Open Full Disk Access")) + XCTAssertTrue(guidance.steps[1].contains("+")) + XCTAssertTrue(guidance.steps[2].contains("/Applications/DevScope.app")) + XCTAssertTrue(guidance.steps[3].contains("Enable")) + XCTAssertTrue(guidance.steps[4].contains("reopen DevScope")) + XCTAssertTrue(guidance.steps[5].contains("Check Access")) + } + + func testFullBuildOutsideApplicationsWarnsBeforeManualStepsAndRetainsExactPath() { + let appPath = "/Users/test/Downloads/DevScope Preview.app" + let guidance = FullDiskAccessGuidance(appPath: appPath, isSandboxed: false) + + XCTAssertFalse(guidance.isInstalledInApplications) + XCTAssertTrue(guidance.detail.contains("Install DevScope in Applications first")) + XCTAssertEqual(guidance.steps.count, 6) + XCTAssertTrue(guidance.steps[2].contains(appPath)) + } + + func testSandboxBuildRefusesToDescribeFullDiskAccessAsFix() { + let guidance = FullDiskAccessGuidance( + appPath: "/tmp/DevScope.app", + isSandboxed: true + ) + + XCTAssertFalse(guidance.isInstalledInApplications) + XCTAssertEqual(guidance.title, "This sandbox build cannot use Full Disk Access") + XCTAssertTrue(guidance.detail.contains("Install the full DevScope build")) + XCTAssertTrue(guidance.steps.isEmpty) + } + + func testSandboxBuildOutsideApplicationsIsNamedAsValidationDevelopmentBuild() { + let appPath = "/tmp/DevScope.app" + let guidance = FullDiskAccessGuidance(appPath: appPath, isSandboxed: true) + + XCTAssertTrue(guidance.detail.contains("validation/development build")) + XCTAssertTrue(guidance.detail.contains(appPath)) + XCTAssertTrue(guidance.detail.contains("App Sandbox")) + } + + func testApplicationsPathDetectionRequiresDirectoryBoundary() { + XCTAssertTrue( + FullDiskAccessGuidance( + appPath: "/Applications/Utilities/DevScope.app", + isSandboxed: false + ).isInstalledInApplications + ) + XCTAssertFalse( + FullDiskAccessGuidance( + appPath: "/ApplicationsBackup/DevScope.app", + isSandboxed: false + ).isInstalledInApplications + ) + } +} diff --git a/Tests/DevScopeCoreTests/InterfacePresentationTests.swift b/Tests/DevScopeCoreTests/InterfacePresentationTests.swift new file mode 100644 index 0000000..ce6da81 --- /dev/null +++ b/Tests/DevScopeCoreTests/InterfacePresentationTests.swift @@ -0,0 +1,110 @@ +import XCTest +@testable import DevScopeCore + +final class InterfacePresentationTests: XCTestCase { + func testWorkspaceModeAndAutomationSettingKeysAreStableAcrossReleases() { + XCTAssertEqual(DevScopeWorkspaceMode.processes.rawValue, "processes") + XCTAssertEqual(DevScopeWorkspaceMode.automations.rawValue, "automations") + XCTAssertEqual( + AutomationPresentationSettings.selectedWorkspaceModeKey, + "selectedWorkspaceMode" + ) + XCTAssertEqual( + AutomationPresentationSettings.longRunningThresholdSecondsKey, + "longRunningThresholdSeconds" + ) + XCTAssertEqual( + AutomationPresentationSettings.includeAppleSystemServicesKey, + "includeAppleSystemServices" + ) + } + + func testLongRunningThresholdClampsAndRoundsToOneHourSteps() { + XCTAssertEqual(AutomationPresentationSettings.normalizedThreshold(-1), 3_600) + XCTAssertEqual(AutomationPresentationSettings.normalizedThreshold(5_399), 3_600) + XCTAssertEqual(AutomationPresentationSettings.normalizedThreshold(5_400), 7_200) + XCTAssertEqual(AutomationPresentationSettings.normalizedThreshold(999_999), 604_800) + XCTAssertEqual(AutomationPresentationSettings.normalizedThreshold(.nan), 14_400) + } + + func testFocusWorkflowsShowSixItemsAndRetainAnOutOfRangeSelection() { + let workflows = (1...10).map(workflow) + + XCTAssertEqual( + InterfacePresentation.visibleFocusWorkflows( + workflows, + selectedID: workflows[8].id, + showsAll: false + ).map(\.id), + workflows.prefix(6).map(\.id) + [workflows[8].id] + ) + } + + func testKeyboardSelectionMovesWithinVisibleRowsAndRecoversFromAStaleSelection() { + let visibleIDs: [Int32] = [11, 22, 33] + + XCTAssertEqual( + InterfacePresentation.movedSelection(in: visibleIDs, current: 22, direction: .next), + 33 + ) + XCTAssertEqual( + InterfacePresentation.movedSelection(in: visibleIDs, current: 33, direction: .next), + 33 + ) + XCTAssertEqual( + InterfacePresentation.movedSelection(in: visibleIDs, current: 999, direction: .previous), + 33 + ) + XCTAssertNil( + InterfacePresentation.movedSelection(in: [], current: 22, direction: .next) + ) + } + + func testFocusDisclosureReturnsEveryWorkflowWhenExpanded() { + let workflows = (1...10).map(workflow) + + XCTAssertEqual( + InterfacePresentation.visibleFocusWorkflows( + workflows, + selectedID: workflows[8].id, + showsAll: true + ), + workflows + ) + } + + func testKeyboardSelectionStartsAndClampsAtTheRequestedEdge() { + let visibleIDs: [Int32] = [11, 22, 33] + + XCTAssertEqual( + InterfacePresentation.movedSelection(in: visibleIDs, current: nil, direction: .next), + 11 + ) + XCTAssertEqual( + InterfacePresentation.movedSelection(in: visibleIDs, current: nil, direction: .previous), + 33 + ) + XCTAssertEqual( + InterfacePresentation.movedSelection(in: visibleIDs, current: 11, direction: .previous), + 11 + ) + } + + private func workflow(_ index: Int) -> DevWorkflow { + DevWorkflow( + id: "workflow:\(index)", + title: "Workflow \(index)", + subtitle: "1 process", + kind: .projectWorkspace, + processIDs: [Int32(index)], + primaryProject: nil, + tags: [], + totalCPU: 0, + totalMemoryBytes: 0, + risk: .normal, + confidence: 1, + summary: "Summary", + suggestedAction: "Inspect" + ) + } +} diff --git a/Tests/DevScopeCoreTests/LaunchdAutomationSourceTests.swift b/Tests/DevScopeCoreTests/LaunchdAutomationSourceTests.swift new file mode 100644 index 0000000..8116673 --- /dev/null +++ b/Tests/DevScopeCoreTests/LaunchdAutomationSourceTests.swift @@ -0,0 +1,871 @@ +import CryptoKit +import XCTest +@testable import DevScopeCore + +final class LaunchdAutomationSourceTests: XCTestCase { + func testSnapshotAppliesInjectedUserDomainEnabledAndLoadedState() async throws { + let root = LaunchdScanRoot( + url: URL(fileURLWithPath: "/fixtures/Library/LaunchAgents"), + kind: .launchAgent, + ownership: .user, + isMutable: true + ) + let enabledURL = root.url.appendingPathComponent("enabled.plist") + let disabledURL = root.url.appendingPathComponent("disabled.plist") + let provider = FakeLaunchdRuntimeStateProvider(states: [ + "com.example.enabled": LaunchdRuntimeState( + enabledState: .enabled, + loadState: .loaded + ), + "com.example.disabled": LaunchdRuntimeState( + enabledState: .disabled, + loadState: .loaded + ), + ]) + let source = LaunchdAutomationSource( + fileSystem: InMemoryAutomationFileSystem(files: [ + enabledURL: try plistData(["Label": "com.example.enabled", "Program": "/bin/echo"]), + disabledURL: try plistData(["Label": "com.example.disabled", "Program": "/bin/echo"]), + ]), + currentUID: 501, + roots: [root], + runtimeStateProvider: provider + ) + + let records = Dictionary( + uniqueKeysWithValues: await source.snapshot().records.map { ($0.label, $0) } + ) + + XCTAssertEqual(records["com.example.enabled"]?.enabledState, .enabled) + XCTAssertEqual(records["com.example.enabled"]?.loadState, .loaded) + XCTAssertEqual(records["com.example.enabled"]?.state, .idle) + XCTAssertEqual(records["com.example.disabled"]?.enabledState, .disabled) + XCTAssertEqual(records["com.example.disabled"]?.loadState, .loaded) + XCTAssertEqual(records["com.example.disabled"]?.state, .disabled) + let requestedLabels = await provider.requestedLabels() + XCTAssertEqual(requestedLabels, ["com.example.disabled", "com.example.enabled"]) + } + + func testHealthMessageBoundsLargeDiagnosticSetsAndKeepsRepresentativeIssues() { + let errors = (1...20).map { index in + "definition-\(index): " + String(repeating: "unreadable diagnostic detail ", count: 20) + } + + let message = LaunchdAutomationSource.healthMessage(for: errors) + + XCTAssertTrue(message.contains("Launchd inspection found 20 issues.")) + XCTAssertTrue(message.contains("definition-1")) + XCTAssertTrue(message.contains("definition-2")) + XCTAssertFalse(message.contains("definition-3")) + XCTAssertTrue(message.hasSuffix("Select an Invalid record for per-definition details.")) + XCTAssertLessThanOrEqual(message.count, 360) + } + + func testParsesLabelProgramArgumentsAndCalendarSchedule() throws { + let plist: [String: Any] = [ + "Label": "com.example.weekly-report", + "ProgramArguments": ["/usr/bin/python3", "/Users/example/report.py"], + "StartCalendarInterval": ["Weekday": 1, "Hour": 9, "Minute": 0], + ] + let data = try PropertyListSerialization.data( + fromPropertyList: plist, + format: .xml, + options: 0 + ) + + let record = try LaunchdPlistParser.parse( + data: data, + sourceURL: URL( + fileURLWithPath: "/Users/example/Library/LaunchAgents/com.example.weekly-report.plist" + ), + ownerUID: 501, + ownership: .user + ) + + XCTAssertEqual(record.label, "com.example.weekly-report") + XCTAssertEqual(record.executable, "/usr/bin/python3") + XCTAssertEqual(record.arguments, ["/Users/example/report.py"]) + XCTAssertEqual(record.schedule.summary, "Mondays at 09:00") + } + + func testParsesEnvironmentAndWorkingDirectoryWithoutDroppingDefinitionState() throws { + let record = try LaunchdPlistParser.parse( + data: plistData([ + "Label": "com.example.complete-definition", + "Program": "/bin/echo", + "EnvironmentVariables": ["MODE": "audit", "TOKEN": "local"], + "WorkingDirectory": "/Users/example/Workspace", + ]), + sourceURL: URL(fileURLWithPath: "/Users/example/Library/LaunchAgents/com.example.complete-definition.plist"), + ownerUID: 501, + ownership: .user + ) + + XCTAssertEqual(record.environment, ["MODE": "audit", "TOKEN": "local"]) + XCTAssertEqual(record.workingDirectory, "/Users/example/Workspace") + } + + func testRejectsNonStringEnvironmentValuesAndEmptyWorkingDirectory() throws { + XCTAssertThrowsError(try LaunchdPlistParser.parse( + data: plistData([ + "Label": "com.example.invalid-environment", + "Program": "/bin/echo", + "EnvironmentVariables": ["RETRIES": 3], + ]), + sourceURL: URL(fileURLWithPath: "/tmp/invalid-environment.plist"), + ownerUID: 501, + ownership: .user + )) { error in + XCTAssertEqual(error as? AutomationParseError, .invalidField("EnvironmentVariables")) + } + XCTAssertThrowsError(try LaunchdPlistParser.parse( + data: plistData([ + "Label": "com.example.invalid-directory", + "Program": "/bin/echo", + "WorkingDirectory": "", + ]), + sourceURL: URL(fileURLWithPath: "/tmp/invalid-directory.plist"), + ownerUID: 501, + ownership: .user + )) { error in + XCTAssertEqual(error as? AutomationParseError, .invalidField("WorkingDirectory")) + } + } + + func testProgramTakesExecutablePrecedenceAndProgramArgumentsRemainArgv() throws { + let data = try plistData([ + "Label": "com.example.explicit-program", + "Program": "/usr/local/bin/worker", + "ProgramArguments": ["custom-argv-zero", "--once"], + "StartCalendarInterval": ["Weekday": 1, "Hour": 9, "Minute": 0], + ]) + + let record = try LaunchdPlistParser.parse( + data: data, + sourceURL: URL(fileURLWithPath: "/Library/LaunchDaemons/com.example.explicit-program.plist"), + ownerUID: 0, + ownership: .thirdPartySystem + ) + + XCTAssertEqual(record.kind, .launchDaemon) + XCTAssertEqual(record.sourceKind, .launchDaemon) + XCTAssertEqual(record.executable, "/usr/local/bin/worker") + XCTAssertEqual(record.arguments, ["custom-argv-zero", "--once"]) + } + + func testParsesBundleRelativeProgramWithoutProgramArguments() throws { + let record = try LaunchdPlistParser.parse( + data: plistData([ + "Label": "com.example.bundle-worker", + "BundleProgram": "Contents/MacOS/Worker", + "StartCalendarInterval": ["Weekday": 1, "Hour": 9, "Minute": 0], + ]), + sourceURL: URL(fileURLWithPath: "/Library/LaunchAgents/com.example.bundle-worker.plist"), + ownerUID: 0, + ownership: .thirdPartySystem + ) + + XCTAssertEqual(record.executable, "Contents/MacOS/Worker") + XCTAssertEqual(record.arguments, []) + } + + func testParsesSupportedLaunchTriggersAndRelativeArgvZero() throws { + let cases: [(fields: [String: Any], triggers: [AutomationSchedule.Trigger], summary: String)] = [ + (["RunAtLoad": true], [.runAtLoad], "At load"), + (["KeepAlive": true], [.runAtLoad, .keepAlive], "At load, kept alive"), + ( + ["KeepAlive": ["SuccessfulExit": false]], + [.runAtLoad, .keepAlive], + "At load, kept alive conditionally" + ), + ( + ["RunAtLoad": true, "KeepAlive": true], + [.runAtLoad, .keepAlive], + "At load, kept alive" + ), + (["StartInterval": 300], [.interval(seconds: 300)], "Every 5 minutes"), + (["RunAtLoad": false, "KeepAlive": false], [.demand], "On demand"), + ([:], [.demand], "On demand"), + ] + + for (index, testCase) in cases.enumerated() { + var plist = testCase.fields + plist["Label"] = "com.example.trigger-\(index)" + plist["ProgramArguments"] = ["relative-tool", "--once"] + + let record = try LaunchdPlistParser.parse( + data: plistData(plist), + sourceURL: URL(fileURLWithPath: "/Library/LaunchAgents/com.example.trigger-\(index).plist"), + ownerUID: 0, + ownership: .thirdPartySystem + ) + + XCTAssertEqual(record.executable, "relative-tool", "case \(index)") + XCTAssertEqual(record.arguments, ["--once"], "case \(index)") + XCTAssertEqual(record.schedule.triggers, testCase.triggers, "case \(index)") + XCTAssertEqual(record.schedule.summary, testCase.summary, "case \(index)") + } + } + + func testKeepAliveAcceptsOnlyDocumentedConditionKeysAndTypes() throws { + let validConditions: [[String: Any]] = [ + ["SuccessfulExit": true], + ["NetworkState": false], + ["Crashed": true], + ["PathState": ["/tmp/ready": true, "/tmp/blocked": false]], + ["OtherJobEnabled": ["com.example.dependency": true]], + [ + "SuccessfulExit": false, + "PathState": ["/tmp/ready": true], + "OtherJobEnabled": ["com.example.dependency": false], + ], + ] + + for (index, conditions) in validConditions.enumerated() { + let record = try LaunchdPlistParser.parse( + data: plistData([ + "Label": "com.example.valid-keepalive-\(index)", + "Program": "/bin/echo", + "KeepAlive": conditions, + ]), + sourceURL: URL(fileURLWithPath: "/tmp/valid-keepalive-\(index).plist"), + ownerUID: 501, + ownership: .user + ) + + XCTAssertEqual(record.schedule.triggers, [.runAtLoad, .keepAlive], "case \(index)") + } + + let invalidConditions: [(name: String, conditions: [String: Any])] = [ + ("unsupported key", ["AfterInitialDemand": true]), + ("boolean as integer", ["SuccessfulExit": 1]), + ("boolean as string", ["Crashed": "true"]), + ("path state scalar", ["PathState": true]), + ("path state non-boolean value", ["PathState": ["/tmp/ready": 1]]), + ("path state nested value", ["PathState": ["/tmp/ready": ["nested": true]]]), + ("other job scalar", ["OtherJobEnabled": false]), + ("other job non-boolean value", ["OtherJobEnabled": ["com.example.job": "yes"]]), + ] + + for testCase in invalidConditions { + XCTAssertThrowsError( + try LaunchdPlistParser.parse( + data: plistData([ + "Label": "com.example.invalid-keepalive", + "Program": "/bin/echo", + "KeepAlive": testCase.conditions, + ]), + sourceURL: URL(fileURLWithPath: "/tmp/invalid-keepalive.plist"), + ownerUID: 501, + ownership: .user + ), + testCase.name + ) { error in + XCTAssertEqual( + error as? AutomationParseError, + .invalidField("KeepAlive"), + testCase.name + ) + } + } + } + + func testParsesCalendarIntervalArrayAsIndependentTriggers() throws { + let record = try LaunchdPlistParser.parse( + data: plistData([ + "Label": "com.example.calendar-array", + "Program": "/bin/echo", + "StartCalendarInterval": [ + ["Weekday": 7, "Hour": 7, "Minute": 15], + ["Month": 1, "Day": 1, "Hour": 0, "Minute": 0], + ], + ]), + sourceURL: URL(fileURLWithPath: "/Library/LaunchAgents/com.example.calendar-array.plist"), + ownerUID: 0, + ownership: .thirdPartySystem + ) + + XCTAssertEqual(record.schedule.triggers, [ + .calendar("Sundays at 07:15"), + .calendar("January 1 at 00:00"), + ]) + XCTAssertEqual(record.schedule.summary, "Sundays at 07:15; January 1 at 00:00") + } + + func testCalendarMonthScopesTheDayOrWeekdayAlternatives() throws { + let cases: [(fields: [String: Any], summary: String)] = [ + ( + ["Month": 1, "Weekday": 1, "Hour": 9, "Minute": 0], + "Mondays in January at 09:00" + ), + ( + ["Month": 1, "Day": 15, "Weekday": 1, "Hour": 9, "Minute": 0], + "January (day 15 or Mondays) at 09:00" + ), + ] + + for (index, testCase) in cases.enumerated() { + let record = try LaunchdPlistParser.parse( + data: plistData([ + "Label": "com.example.calendar-precedence-\(index)", + "Program": "/bin/echo", + "StartCalendarInterval": testCase.fields, + ]), + sourceURL: URL(fileURLWithPath: "/tmp/calendar-precedence-\(index).plist"), + ownerUID: 501, + ownership: .user + ) + + XCTAssertEqual(record.schedule.summary, testCase.summary, "case \(index)") + } + } + + func testCalendarWildcardsRemainIndependentFromStartInterval() throws { + let record = try LaunchdPlistParser.parse( + data: plistData([ + "Label": "com.example.calendar-wildcards", + "Program": "/bin/echo", + "StartInterval": 60, + "StartCalendarInterval": [ + ["Minute": 15], + [:], + ], + ]), + sourceURL: URL(fileURLWithPath: "/Library/LaunchAgents/com.example.calendar-wildcards.plist"), + ownerUID: 0, + ownership: .thirdPartySystem + ) + + XCTAssertEqual(record.schedule.triggers, [ + .interval(seconds: 60), + .calendar("Every day at minute 15 of every hour"), + .calendar("Every day every minute"), + ]) + XCTAssertEqual( + record.schedule.summary, + "Every 1 minute, Every day at minute 15 of every hour; Every day every minute" + ) + } + + func testRejectsMalformedOrContractViolatingPropertyListsWithTypedErrors() throws { + let cases: [(name: String, data: Data, error: AutomationParseError)] = [ + ("malformed XML", Data("".utf8), .unreadablePropertyList), + ("missing label", try plistData(["Program": "/bin/echo"]), .missingLabel), + ( + "invalid label type", + try plistData(["Label": 42, "Program": "/bin/echo"]), + .invalidField("Label") + ), + ("missing program", try plistData(["Label": "com.example.missing"]), .missingProgram), + ( + "relative Program", + try plistData(["Label": "com.example.relative", "Program": "bin/tool"]), + .invalidField("Program") + ), + ( + "absolute BundleProgram", + try plistData(["Label": "com.example.bundle", "BundleProgram": "/Contents/MacOS/Tool"]), + .invalidField("BundleProgram") + ), + ( + "non-string argv", + try plistData([ + "Label": "com.example.argv", + "Program": "/bin/echo", + "ProgramArguments": ["echo", 42], + ]), + .invalidField("ProgramArguments") + ), + ( + "non-boolean RunAtLoad", + try plistData(["Label": "com.example.run", "Program": "/bin/echo", "RunAtLoad": 1]), + .invalidField("RunAtLoad") + ), + ( + "invalid KeepAlive", + try plistData(["Label": "com.example.keep", "Program": "/bin/echo", "KeepAlive": "yes"]), + .invalidField("KeepAlive") + ), + ( + "non-positive interval", + try plistData(["Label": "com.example.interval", "Program": "/bin/echo", "StartInterval": 0]), + .invalidField("StartInterval") + ), + ( + "calendar value outside man-page range", + try plistData([ + "Label": "com.example.calendar", + "Program": "/bin/echo", + "StartCalendarInterval": ["Hour": 24], + ]), + .invalidField("StartCalendarInterval.Hour") + ), + ( + "empty calendar array", + try plistData([ + "Label": "com.example.empty-calendar", + "Program": "/bin/echo", + "StartCalendarInterval": [], + ]), + .invalidField("StartCalendarInterval") + ), + ] + + for testCase in cases { + XCTAssertThrowsError( + try LaunchdPlistParser.parse( + data: testCase.data, + sourceURL: URL(fileURLWithPath: "/tmp/\(testCase.name).plist"), + ownerUID: 501, + ownership: .user + ), + testCase.name + ) { error in + XCTAssertEqual(error as? AutomationParseError, testCase.error, testCase.name) + } + } + } + + func testPublicParserPreservesCanonicalIdentityButDefaultsToReadOnlyCapabilities() throws { + let data = try plistData([ + "Label": "com.example.mutable-user-agent", + "Program": "/bin/sleep", + "ProgramArguments": ["sleep", "60"], + ]) + let sourceURL = URL( + fileURLWithPath: "/Users/example/Library/LaunchAgents/folder/../com.example.mutable-user-agent.plist" + ) + + let record = try LaunchdPlistParser.parse( + data: data, + sourceURL: sourceURL, + ownerUID: 501, + ownership: .user + ) + + XCTAssertEqual(record.ownerUID, 501) + XCTAssertEqual(record.ownership, .user) + XCTAssertEqual(record.kind, .launchAgent) + XCTAssertEqual(record.sourceKind, .launchAgent) + XCTAssertEqual(record.sourceURL, sourceURL.standardizedFileURL) + XCTAssertEqual( + record.id, + AutomationRecord.ID( + source: .launchAgent, + ownerUID: 501, + label: "com.example.mutable-user-agent", + sourcePath: sourceURL.standardizedFileURL.path + ) + ) + XCTAssertEqual( + record.sourceChecksum, + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + ) + XCTAssertEqual(record.capabilities, [.exportRecord]) + XCTAssertEqual(record.evidence.first?.strength, .strong) + XCTAssertEqual(record.state, .unresolved) + } + + func testPublicParserInfersLaunchDaemonKindFromCanonicalDotSegmentPath() throws { + let data = try plistData([ + "Label": "com.example.daemon", + "Program": "/bin/echo", + ]) + let sourceURL = URL( + fileURLWithPath: "/Library/LaunchDaemons/folder/../job.plist" + ) + let canonicalSourceURL = sourceURL.standardizedFileURL + + let record = try LaunchdPlistParser.parse( + data: data, + sourceURL: sourceURL, + ownerUID: 0, + ownership: .thirdPartySystem + ) + + XCTAssertEqual(record.sourceURL, canonicalSourceURL) + XCTAssertEqual(record.kind, .launchDaemon) + XCTAssertEqual(record.sourceKind, .launchDaemon) + XCTAssertEqual( + record.id, + AutomationRecord.ID( + source: .launchDaemon, + ownerUID: 0, + label: "com.example.daemon", + sourcePath: canonicalSourceURL.path + ) + ) + } + + func testDefaultRootsInventoryUserThirdPartyAndAppleLocations() { + let home = URL(fileURLWithPath: "/Users/fixture") + let roots = LaunchdAutomationSource.defaultRoots(homeDirectory: home) + + XCTAssertEqual(roots.map(\.url), [ + URL(fileURLWithPath: "/Users/fixture/Library/LaunchAgents"), + URL(fileURLWithPath: "/Library/LaunchAgents"), + URL(fileURLWithPath: "/Library/LaunchDaemons"), + URL(fileURLWithPath: "/System/Library/LaunchAgents"), + URL(fileURLWithPath: "/System/Library/LaunchDaemons"), + ]) + XCTAssertEqual(roots.map(\.kind), [ + .launchAgent, .launchAgent, .launchDaemon, .launchAgent, .launchDaemon, + ]) + XCTAssertEqual(roots.map(\.ownership), [ + .user, .thirdPartySystem, .thirdPartySystem, .appleSystem, .appleSystem, + ]) + XCTAssertEqual(roots.map(\.isMutable), [true, false, false, false, false]) + } + + func testSnapshotInventoriesEveryRootAndKeepsDuplicateLabelsDistinct() async throws { + let roots = LaunchdAutomationSource.defaultRoots( + homeDirectory: URL(fileURLWithPath: "/Users/fixture") + ) + var files: [URL: Data] = [:] + var metadata: [URL: AutomationFileMetadata] = [:] + + for (index, root) in roots.enumerated() { + let url = root.url.appendingPathComponent("duplicate-\(index).plist") + files[url] = try plistData([ + "Label": "com.example.duplicate", + "Program": "/bin/echo", + ]) + metadata[url] = AutomationFileMetadata( + canonicalURL: url, + ownerUID: index == 0 ? 501 : 0, + isSymbolicLink: false, + modificationDate: Date(timeIntervalSince1970: 1_000), + resourceIdentifier: "file-\(index)" + ) + } + + let source = LaunchdAutomationSource( + fileSystem: InMemoryAutomationFileSystem(files: files, metadata: metadata), + currentUID: 501, + roots: roots + ) + let snapshot = await source.snapshot() + + XCTAssertEqual(snapshot.health.state, .healthy, snapshot.health.message ?? "no health message") + XCTAssertEqual(snapshot.records.count, 5) + XCTAssertEqual(Set(snapshot.records.map(\.id)).count, 5) + XCTAssertEqual(snapshot.records.map(\.ownership), [ + .user, .thirdPartySystem, .thirdPartySystem, .appleSystem, .appleSystem, + ]) + XCTAssertEqual(snapshot.records.map(\.ownerUID), [501, 0, 0, 0, 0]) + XCTAssertEqual(snapshot.records.map(\.kind), [ + .launchAgent, .launchAgent, .launchDaemon, .launchAgent, .launchDaemon, + ]) + XCTAssertEqual( + snapshot.records.first?.capabilities, + [.edit, .duplicate, .exportRecord, .remove] + ) + XCTAssertEqual(snapshot.records.dropFirst().first?.capabilities, [.exportRecord]) + XCTAssertTrue(snapshot.records.contains { $0.ownership == .appleSystem }) + } + + func testUserRootPreservesFileOwnerAndRequiresCurrentUIDForMutability() async throws { + let root = LaunchdScanRoot( + url: URL(fileURLWithPath: "/fixtures/Library/LaunchAgents"), + kind: .launchAgent, + ownership: .user, + isMutable: true + ) + let url = root.url.appendingPathComponent("other-owner.plist") + let fileSystem = InMemoryAutomationFileSystem( + files: [url: try plistData([ + "Label": "com.example.other-owner", + "Program": "/bin/echo", + ])], + metadata: [url: AutomationFileMetadata( + canonicalURL: url, + ownerUID: 777, + isSymbolicLink: false, + modificationDate: Date(timeIntervalSince1970: 1_000), + resourceIdentifier: "other-owner" + )] + ) + let source = LaunchdAutomationSource( + fileSystem: fileSystem, + currentUID: 501, + roots: [root] + ) + + let record = await source.snapshot().records.first + + XCTAssertEqual(record?.ownerUID, 777) + XCTAssertEqual(record?.ownership, .user) + XCTAssertEqual(record?.capabilities, [.exportRecord]) + } + + func testSourceGrantsMutabilityOnlyToSafeRegularFilesInsideApprovedUserRoot() async throws { + let root = LaunchdScanRoot( + url: URL(fileURLWithPath: "/fixtures/Library/LaunchAgents"), + kind: .launchAgent, + ownership: .user, + isMutable: true + ) + let cases: [( + name: String, + canonicalURL: URL, + isSymbolicLink: Bool, + expectedCapabilities: Set + )] = [ + ( + "safe", + root.url.appendingPathComponent("safe.plist"), + false, + [.edit, .duplicate, .exportRecord, .remove] + ), + ( + "symlink", + root.url.appendingPathComponent("symlink.plist"), + true, + [.exportRecord] + ), + ( + "canonical-escape", + URL(fileURLWithPath: "/fixtures/outside/canonical-escape.plist"), + false, + [.exportRecord] + ), + ( + "prefix-sibling", + URL(fileURLWithPath: "/fixtures/Library/LaunchAgents-escaped/prefix-sibling.plist"), + false, + [.exportRecord] + ), + ] + var files: [URL: Data] = [:] + var metadata: [URL: AutomationFileMetadata] = [:] + + for testCase in cases { + let enumeratedURL = root.url.appendingPathComponent("\(testCase.name).plist") + files[enumeratedURL] = try plistData([ + "Label": "com.example.\(testCase.name)", + "Program": "/bin/echo", + ]) + metadata[enumeratedURL] = AutomationFileMetadata( + canonicalURL: testCase.canonicalURL, + ownerUID: 501, + isSymbolicLink: testCase.isSymbolicLink, + modificationDate: Date(timeIntervalSince1970: 1_000), + resourceIdentifier: testCase.name + ) + } + let source = LaunchdAutomationSource( + fileSystem: InMemoryAutomationFileSystem(files: files, metadata: metadata), + currentUID: 501, + roots: [root] + ) + + let recordsByLabel = Dictionary( + uniqueKeysWithValues: await source.snapshot().records.map { ($0.label, $0) } + ) + + for testCase in cases { + let record = recordsByLabel["com.example.\(testCase.name)"] + XCTAssertEqual(record?.capabilities, testCase.expectedCapabilities, testCase.name) + XCTAssertEqual(record?.sourceURL, testCase.canonicalURL.standardizedFileURL, testCase.name) + } + } + + func testSnapshotSurfacesMalformedDefinitionsAndMissingDirectoriesAsPartialHealth() async throws { + let presentRoot = LaunchdScanRoot( + url: URL(fileURLWithPath: "/fixtures/Library/LaunchAgents"), + kind: .launchAgent, + ownership: .user, + isMutable: true + ) + let missingRoot = LaunchdScanRoot( + url: URL(fileURLWithPath: "/fixtures/System/Library/LaunchDaemons"), + kind: .launchDaemon, + ownership: .appleSystem, + isMutable: false + ) + let validURL = presentRoot.url.appendingPathComponent("valid.plist") + let malformedURL = presentRoot.url.appendingPathComponent("broken.plist") + let fileSystem = InMemoryAutomationFileSystem(files: [ + validURL: try plistData(["Label": "com.example.valid", "Program": "/bin/echo"]), + malformedURL: Data("not a property list".utf8), + ]) + let source = LaunchdAutomationSource( + fileSystem: fileSystem, + currentUID: 501, + roots: [presentRoot, missingRoot] + ) + + let snapshot = await source.snapshot() + + XCTAssertEqual(snapshot.health.state, .partial) + XCTAssertTrue(snapshot.health.message?.contains("broken.plist") == true) + XCTAssertTrue(snapshot.health.message?.contains(missingRoot.url.path) == true) + XCTAssertEqual(snapshot.records.count, 2) + let invalid = snapshot.records.first { $0.state == .invalid } + XCTAssertEqual(invalid?.label, "broken") + XCTAssertEqual(invalid?.ownership, .user) + XCTAssertEqual(invalid?.ownerUID, 501) + XCTAssertEqual(invalid?.sourceURL, malformedURL.standardizedFileURL) + XCTAssertTrue(invalid?.validationFindings.contains("Unreadable property list") == true) + XCTAssertEqual(invalid?.capabilities, []) + } + + func testReadFailurePreservesSuccessfulMetadataProvenanceAndCanonicalIdentity() async throws { + let root = LaunchdScanRoot( + url: URL(fileURLWithPath: "/fixtures/Library/LaunchAgents"), + kind: .launchAgent, + ownership: .user, + isMutable: true + ) + let enumeratedURL = root.url.appendingPathComponent("read-failure.plist") + let canonicalURL = root.url.appendingPathComponent("canonical-read-failure.plist") + let fileSystem = InMemoryAutomationFileSystem( + files: [enumeratedURL: try plistData([ + "Label": "com.example.unreadable", + "Program": "/bin/echo", + ])], + metadata: [enumeratedURL: AutomationFileMetadata( + canonicalURL: canonicalURL, + ownerUID: 777, + isSymbolicLink: false, + modificationDate: Date(timeIntervalSince1970: 1_000), + resourceIdentifier: "read-failure" + )], + failingReads: [enumeratedURL] + ) + let source = LaunchdAutomationSource( + fileSystem: fileSystem, + currentUID: 501, + roots: [root] + ) + + let snapshot = await source.snapshot() + let invalid = snapshot.records.first + + XCTAssertEqual(snapshot.health.state, .partial) + XCTAssertEqual(invalid?.state, .invalid) + XCTAssertEqual(invalid?.ownerUID, 777) + XCTAssertEqual(invalid?.sourceURL, canonicalURL.standardizedFileURL) + XCTAssertEqual(invalid?.label, "canonical-read-failure") + XCTAssertEqual( + invalid?.id, + AutomationRecord.ID( + source: .launchAgent, + ownerUID: 777, + label: "canonical-read-failure", + sourcePath: canonicalURL.standardizedFileURL.path + ) + ) + } + + func testCacheReusesOnlyTheCompleteCanonicalResourceDateAndChecksumIdentity() async throws { + let root = LaunchdScanRoot( + url: URL(fileURLWithPath: "/fixtures/Library/LaunchAgents"), + kind: .launchAgent, + ownership: .user, + isMutable: true + ) + let url = root.url.appendingPathComponent("cached.plist") + let originalData = try plistData([ + "Label": "com.example.cached", + "Program": "/bin/echo", + ]) + let initialDate = Date(timeIntervalSince1970: 1_000) + let fileSystem = InMemoryAutomationFileSystem( + files: [url: originalData], + metadata: [url: AutomationFileMetadata( + canonicalURL: url, + ownerUID: 501, + isSymbolicLink: false, + modificationDate: initialDate, + resourceIdentifier: "resource-a" + )] + ) + let parseCounter = LockedCounter() + let source = LaunchdAutomationSource( + fileSystem: fileSystem, + currentUID: 501, + roots: [root], + recordParser: { data, sourceURL, ownerUID, ownership, kind, isMutable in + parseCounter.increment() + return try LaunchdPlistParser.parse( + data: data, + canonicalSourceURL: sourceURL, + ownerUID: ownerUID, + ownership: ownership, + kind: kind, + isMutable: isMutable + ) + } + ) + + _ = await source.snapshot() + _ = await source.snapshot() + XCTAssertEqual(parseCounter.value, 1, "unchanged identity should reuse the parsed record") + + fileSystem.setMetadata(AutomationFileMetadata( + canonicalURL: url, + ownerUID: 501, + isSymbolicLink: false, + modificationDate: initialDate, + resourceIdentifier: "resource-b" + ), for: url) + _ = await source.snapshot() + XCTAssertEqual(parseCounter.value, 2, "resource identity must invalidate reuse") + + fileSystem.setMetadata(AutomationFileMetadata( + canonicalURL: url, + ownerUID: 501, + isSymbolicLink: false, + modificationDate: initialDate.addingTimeInterval(1), + resourceIdentifier: "resource-b" + ), for: url) + _ = await source.snapshot() + XCTAssertEqual(parseCounter.value, 3, "modification date must invalidate reuse") + + fileSystem.setStoredData(try plistData([ + "Label": "com.example.cached", + "Program": "/bin/echo", + "RunAtLoad": true, + ]), at: url) + _ = await source.snapshot() + XCTAssertEqual(parseCounter.value, 4, "checksum must invalidate reuse") + } + + private func plistData(_ plist: [String: Any]) throws -> Data { + try PropertyListSerialization.data( + fromPropertyList: plist, + format: .xml, + options: 0 + ) + } +} + +private actor FakeLaunchdRuntimeStateProvider: LaunchdRuntimeStateProviding { + private let values: [String: LaunchdRuntimeState] + private var labels: [String] = [] + + init(states: [String: LaunchdRuntimeState]) { + values = states + } + + func states(for requestedLabels: [String], guiUID: uid_t) async -> [String: LaunchdRuntimeState] { + labels = requestedLabels + return values + } + + func requestedLabels() -> [String] { labels } +} + +private final class LockedCounter: @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + + var value: Int { + lock.withLock { count } + } + + func increment() { + lock.withLock { count += 1 } + } +} diff --git a/Tests/DevScopeCoreTests/LiveActivityLayoutPolicyTests.swift b/Tests/DevScopeCoreTests/LiveActivityLayoutPolicyTests.swift new file mode 100644 index 0000000..e4097c8 --- /dev/null +++ b/Tests/DevScopeCoreTests/LiveActivityLayoutPolicyTests.swift @@ -0,0 +1,86 @@ +import XCTest + +@testable import DevScopeCore + +final class LiveActivityLayoutPolicyTests: XCTestCase { + func testUpdatedPreferredHeightPreservesCurrentValueInConstrainedWorkspace() { + XCTAssertEqual( + LiveActivityLayoutPolicy.updatedPreferredHeight( + currentPreferredHeight: 244, + measuredHeight: 132, + workspaceHeight: 500 + ), + 244 + ) + } + + func testUpdatedPreferredHeightAcceptsValidDragMeasurement() { + XCTAssertEqual( + LiveActivityLayoutPolicy.updatedPreferredHeight( + currentPreferredHeight: 190, + measuredHeight: 244, + workspaceHeight: 820 + ), + 244 + ) + } + + func testUpdatedPreferredHeightIgnoresSubpixelMeasurementNoise() { + XCTAssertEqual( + LiveActivityLayoutPolicy.updatedPreferredHeight( + currentPreferredHeight: 244, + measuredHeight: 244.25, + workspaceHeight: 820 + ), + 244 + ) + } + + func testHeightUsesDefaultAndClampsToSupportedRange() { + XCTAssertEqual( + LiveActivityLayoutPolicy.resolvedHeight(preferredHeight: .nan, workspaceHeight: 900), 190) + XCTAssertEqual( + LiveActivityLayoutPolicy.resolvedHeight(preferredHeight: 80, workspaceHeight: 900), 120) + XCTAssertEqual( + LiveActivityLayoutPolicy.resolvedHeight(preferredHeight: 420, workspaceHeight: 900), 360) + } + + func testShortWorkspaceProtectsProcessArea() { + XCTAssertEqual( + LiveActivityLayoutPolicy.resolvedHeight(preferredHeight: 300, workspaceHeight: 500), 132) + XCTAssertFalse( + LiveActivityLayoutPolicy.shouldPersist(measuredHeight: 132, workspaceHeight: 500)) + } + + func testComfortableWorkspacePersistsDraggedHeight() { + XCTAssertTrue(LiveActivityLayoutPolicy.shouldPersist(measuredHeight: 244, workspaceHeight: 820)) + } + + func testWidthChoosesWideStackedAndCompactModes() { + XCTAssertEqual(LiveActivityLayoutPolicy.mode(availableWidth: 1_200), .wide) + XCTAssertEqual(LiveActivityLayoutPolicy.mode(availableWidth: 820), .stacked) + XCTAssertEqual(LiveActivityLayoutPolicy.mode(availableWidth: 620), .compact) + } + + func testAttainableWorkspaceWidthChoosesCompactMode() { + XCTAssertEqual(LiveActivityLayoutPolicy.mode(availableWidth: 780), .compact) + } + + func testMinimumHeightUsesCondensedVerticalContent() { + XCTAssertEqual( + LiveActivityLayoutPolicy.verticalMode(availableHeight: 120, layoutMode: .compact), + .condensed + ) + } + + func testDefaultHeightCondensesStackedContentToAvoidClipping() { + XCTAssertEqual( + LiveActivityLayoutPolicy.verticalMode(availableHeight: 190, layoutMode: .stacked), + .condensed + ) + XCTAssertEqual( + LiveActivityLayoutPolicy.verticalMode(availableHeight: 240, layoutMode: .stacked), + .normal + ) + } +} diff --git a/Tests/DevScopeCoreTests/LoginBackgroundAutomationSourceTests.swift b/Tests/DevScopeCoreTests/LoginBackgroundAutomationSourceTests.swift new file mode 100644 index 0000000..a4ef7d7 --- /dev/null +++ b/Tests/DevScopeCoreTests/LoginBackgroundAutomationSourceTests.swift @@ -0,0 +1,459 @@ +import CryptoKit +import Foundation +import XCTest +@testable import DevScopeCore + +final class LoginBackgroundAutomationSourceTests: XCTestCase { + func testBackgroundDiagnosticPolicyFailsClosedOnMacOS27AndLater() { + XCTAssertEqual( + BackgroundTaskDiagnosticPolicy.forOperatingSystemMajorVersion(14), + .available + ) + XCTAssertEqual( + BackgroundTaskDiagnosticPolicy.forOperatingSystemMajorVersion(26), + .available + ) + XCTAssertEqual( + BackgroundTaskDiagnosticPolicy.forOperatingSystemMajorVersion(27), + .administratorApprovalRequired + ) + XCTAssertEqual( + BackgroundTaskDiagnosticPolicy.forOperatingSystemMajorVersion(28), + .administratorApprovalRequired + ) + } + + func testBackgroundDiagnosticRequiringAdministratorApprovalNeverInvokesRunner() async { + let runner = RecordingAutomationCommandRunner() + let source = BackgroundTaskAutomationSource( + runner: runner, + diagnosticPolicy: .administratorApprovalRequired, + now: { Date(timeIntervalSince1970: 3_000) } + ) + + let first = await source.snapshot() + let second = await source.snapshot() + let fingerprint = await source.currentRawOutputHash() + + XCTAssertTrue(first.records.isEmpty) + XCTAssertEqual(first, second) + XCTAssertEqual(first.health.kind, .serviceManagement) + XCTAssertEqual(first.health.state, .permissionRequired) + XCTAssertEqual( + first.health.message, + "macOS 27 or later requires administrator approval for this Background Items diagnostic. " + + "Review Background Items in System Settings; DevScope did not run the diagnostic." + ) + XCTAssertEqual(first.health.refreshedAt, Date(timeIntervalSince1970: 3_000)) + XCTAssertNil(fingerprint) + XCTAssertTrue(runner.invocations.isEmpty) + } + + func testLegacyLoginItemSuccessNormalizesCurrentUserEvidence() async { + let adapter = StubLegacyLoginItemAdapter(result: .success([ + LegacyLoginItemDescriptor( + name: "Synthetic Login Fixture", + path: "/tmp/devscope-fixtures/Synthetic Login Fixture.app", + isHidden: false + ), + ])) + let source = LegacyLoginItemAutomationSource( + adapter: adapter, + currentUID: 501, + now: { Date(timeIntervalSince1970: 1_000) } + ) + + let snapshot = await source.snapshot() + + XCTAssertEqual(snapshot.health.state, .healthy) + XCTAssertEqual(snapshot.records.count, 1) + XCTAssertEqual(snapshot.records[0].sourceKind, .legacyLoginItem) + XCTAssertEqual(snapshot.records[0].ownerUID, 501) + XCTAssertEqual(snapshot.records[0].ownership, .user) + XCTAssertEqual(snapshot.records[0].schedule.triggers, [.atLogin]) + XCTAssertEqual(snapshot.records[0].capabilities, [.exportRecord]) + let recoveryData = try! LegacyLoginItemRecoveryDocument.encode( + selectedRecord: snapshot.records[0], + descriptor: LegacyLoginItemDescriptor( + name: "Synthetic Login Fixture", + path: "/tmp/devscope-fixtures/Synthetic Login Fixture.app", + isHidden: false + ), + currentUID: 501 + ) + XCTAssertEqual(snapshot.records[0].sourceChecksum, sha256(recoveryData)) + } + + func testLegacyLoginItemChecksumBindsHiddenStateAndCanonicalPath() async { + let visible = await legacySnapshot(LegacyLoginItemDescriptor( + name: "Synthetic Login Fixture", + path: "/tmp/devscope-fixtures/Synthetic Login Fixture.app", + isHidden: false + )) + let hidden = await legacySnapshot(LegacyLoginItemDescriptor( + name: "Synthetic Login Fixture", + path: "/tmp/devscope-fixtures/Synthetic Login Fixture.app", + isHidden: true + )) + let changedPath = await legacySnapshot(LegacyLoginItemDescriptor( + name: "Synthetic Login Fixture", + path: "/tmp/devscope-fixtures/Changed Fixture.app", + isHidden: false + )) + + XCTAssertNotNil(visible.sourceChecksum) + XCTAssertNotEqual(visible.sourceChecksum, hidden.sourceChecksum) + XCTAssertNotEqual(visible.sourceChecksum, changedPath.sourceChecksum) + } + + func testLegacyLoginItemDenialRequiresExplicitUserActionWithoutLeakingDetails() async { + let source = LegacyLoginItemAutomationSource( + adapter: StubLegacyLoginItemAdapter(result: .failure(.permissionDenied)), + currentUID: 501, + now: { Date(timeIntervalSince1970: 1_000) } + ) + + let snapshot = await source.snapshot() + + XCTAssertTrue(snapshot.records.isEmpty) + XCTAssertEqual(snapshot.health.state, .permissionRequired) + XCTAssertEqual( + snapshot.health.message, + "Allow DevScope to inspect current-user login items in System Settings." + ) + } + + func testBackgroundDiagnosticUsesExactObservedBoundaryFieldsAndFingerprint() async { + let output = Data(""" + Background Task Management diagnostic + Items: 1 + #1: + Name: Synthetic Backup Fixture + Identifier: com.example.backup + URL: file:///tmp/devscope-fixtures/background-backup.plist + Bundle Identifier: com.example.devscope-fixture-owner + Executable Path: /bin/sleep + Disposition: enabled, allowed + Parent Identifier: com.example.devscope-fixture-owner + Team Identifier: SYNTHETIC1 + Developer Name: Synthetic Developer + Type: agent + """.utf8) + let runner = RecordingAutomationCommandRunner(result: .success( + AutomationCommandResult(status: 0, standardOutput: output, standardError: Data()) + )) + let source = BackgroundTaskAutomationSource( + runner: runner, + diagnosticPolicy: .available, + now: { Date(timeIntervalSince1970: 2_000) } + ) + + let snapshot = await source.snapshot() + let fingerprint = await source.currentRawOutputHash() + + XCTAssertEqual( + runner.invocations, + [AutomationCommand(executable: "/usr/bin/sfltool", arguments: ["dumpbtm"])] + ) + XCTAssertEqual(snapshot.health.state, .healthy) + XCTAssertEqual(snapshot.records.count, 1) + XCTAssertEqual(snapshot.records[0].label, "com.example.backup") + XCTAssertEqual(snapshot.records[0].providerBundleIdentifier, "com.example.devscope-fixture-owner") + XCTAssertEqual(snapshot.records[0].executable, "/bin/sleep") + XCTAssertEqual(snapshot.records[0].state, .unresolved) + XCTAssertEqual(snapshot.records[0].capabilities, [.exportRecord]) + XCTAssertEqual(snapshot.records[0].sourceChecksum, fingerprint) + XCTAssertEqual( + fingerprint, + SHA256.hash(data: output).map { String(format: "%02x", $0) }.joined() + ) + } + + func testBackgroundDiagnosticFormatDriftFailsWithoutGuessingOrLeakingRawOutput() async { + let privatePath = "/Users/example/Library/secret-token-value" + let output = Data(""" + Background Task Management changed format + Identifier: com.example.looks-valid + Executable Path: \(privatePath) + """.utf8) + let runner = RecordingAutomationCommandRunner(result: .success( + AutomationCommandResult(status: 0, standardOutput: output, standardError: Data()) + )) + let source = BackgroundTaskAutomationSource(runner: runner, diagnosticPolicy: .available) + + let snapshot = await source.snapshot() + let fingerprint = await source.currentRawOutputHash() + + XCTAssertTrue(snapshot.records.isEmpty) + XCTAssertEqual(snapshot.health.state, .failed) + XCTAssertFalse(snapshot.health.message?.contains(privatePath) ?? true) + XCTAssertEqual(fingerprint?.count, 64) + } + + func testBackgroundDiagnosticRejectsUnobservedExecutableAndParentURLAliases() async { + let output = Data(""" + #1: + Name: Alias Only + Identifier: com.example.alias-only + Executable URL: file:///tmp/devscope-fixtures/alias-helper + Parent URL: file:///tmp/devscope-fixtures/Alias.app + """.utf8) + let runner = RecordingAutomationCommandRunner(result: .success( + AutomationCommandResult(status: 0, standardOutput: output, standardError: Data()) + )) + let source = BackgroundTaskAutomationSource(runner: runner, diagnosticPolicy: .available) + + let snapshot = await source.snapshot() + + XCTAssertTrue(snapshot.records.isEmpty) + XCTAssertEqual(snapshot.health.state, .failed) + XCTAssertEqual( + snapshot.health.message, + "Some Background Task Management records were unresolved." + ) + } + + func testBackgroundDiagnosticKeepsUsableRecordsAndReportsUnresolvedBoundedRecords() async { + let output = Data(""" + #1: + Name: Bundle Identity + Bundle Identifier: com.example.bundle-identity + Unknown Secret: /Users/example/private-value + #2: + Name: Display Name Is Not Identity + Developer Name: Synthetic Developer + Type: agent + Disposition: allowed + """.utf8) + let runner = RecordingAutomationCommandRunner(result: .success( + AutomationCommandResult(status: 0, standardOutput: output, standardError: Data()) + )) + let source = BackgroundTaskAutomationSource(runner: runner, diagnosticPolicy: .available) + + let snapshot = await source.snapshot() + + XCTAssertEqual(snapshot.records.map(\.label), ["com.example.bundle-identity"]) + XCTAssertEqual(snapshot.health.state, .partial) + XCTAssertEqual( + snapshot.health.message, + "Some Background Task Management records were unresolved." + ) + XCTAssertFalse(snapshot.records[0].evidence.contains { + $0.detail.contains("private-value") + }) + } + + func testBackgroundDiagnosticBoundaryRequiresASCIIDigits() async { + let output = Data(""" + #١: + Identifier: com.example.unicode-boundary + Executable Path: /bin/sleep + """.utf8) + let runner = RecordingAutomationCommandRunner(result: .success( + AutomationCommandResult(status: 0, standardOutput: output, standardError: Data()) + )) + let source = BackgroundTaskAutomationSource(runner: runner, diagnosticPolicy: .available) + + let snapshot = await source.snapshot() + + XCTAssertTrue(snapshot.records.isEmpty) + XCTAssertEqual(snapshot.health.state, .failed) + XCTAssertEqual( + snapshot.health.message, + "Background Task Management record boundaries were not recognized." + ) + } + + func testBackgroundDiagnosticBoundaryDriftQuarantinesFollowingFields() async { + let privatePath = "/Users/example/private-drift-value" + let output = Data(""" + #1: + Identifier: com.example.valid-before-drift + Executable Path: /bin/sleep + #١: + Bundle Identifier: com.example.must-not-augment-first + #malformed: + Identifier: com.example.must-not-replace-first + Executable Path: \(privatePath) + """.utf8) + let runner = RecordingAutomationCommandRunner(result: .success( + AutomationCommandResult(status: 0, standardOutput: output, standardError: Data()) + )) + let source = BackgroundTaskAutomationSource(runner: runner, diagnosticPolicy: .available) + + let snapshot = await source.snapshot() + + XCTAssertEqual(snapshot.records.map(\.label), ["com.example.valid-before-drift"]) + let record = snapshot.records.first + XCTAssertEqual(record?.executable, "/bin/sleep") + XCTAssertNil(record?.providerBundleIdentifier) + XCTAssertEqual(snapshot.health.state, .partial) + XCTAssertFalse(snapshot.health.message?.contains(privatePath) ?? true) + XCTAssertFalse(snapshot.records.contains { record in + record.label.contains("must-not") + || record.executable?.contains("private-drift-value") == true + || record.evidence.contains { $0.detail.contains("must-not") } + }) + } + + func testBackgroundDiagnosticExactBoundaryRecoversAfterDriftQuarantine() async { + let output = Data(""" + #1: + Identifier: com.example.before-drift + Executable Path: /bin/sleep + #٢: + Identifier: com.example.quarantined-unicode + Executable Path: /bin/date + #not-a-number: + Bundle Identifier: com.example.quarantined-malformed + #2: + Identifier: com.example.after-drift + Executable Path: /usr/bin/true + """.utf8) + let runner = RecordingAutomationCommandRunner(result: .success( + AutomationCommandResult(status: 0, standardOutput: output, standardError: Data()) + )) + let source = BackgroundTaskAutomationSource(runner: runner, diagnosticPolicy: .available) + + let snapshot = await source.snapshot() + + XCTAssertEqual( + snapshot.records.map(\.label), + ["com.example.before-drift", "com.example.after-drift"] + ) + XCTAssertEqual(snapshot.records.map(\.executable), ["/bin/sleep", "/usr/bin/true"]) + XCTAssertEqual(snapshot.health.state, .partial) + XCTAssertFalse(snapshot.records.contains { record in + record.label.contains("quarantined") + || record.providerBundleIdentifier?.contains("quarantined") == true + }) + } + + func testLegacyProductionAdapterUsesOnlyFixedArgumentFreeProgramText() async throws { + let privatePath = "/tmp/devscope-fixtures/User Controlled.app" + let output = try JSONSerialization.data(withJSONObject: [[ + "name": "User Controlled", + "path": privatePath, + "hidden": false, + ]]) + let runner = RecordingAutomationCommandRunner(result: .success( + AutomationCommandResult(status: 0, standardOutput: output, standardError: Data()) + )) + let adapter = OSACommandLegacyLoginItemAdapter(runner: runner) + + let items = try await adapter.currentUserLoginItems() + + XCTAssertEqual(items.map(\.path), [privatePath]) + XCTAssertEqual(runner.invocations.count, 1) + XCTAssertEqual(runner.invocations[0].executable, "/usr/bin/osascript") + XCTAssertEqual(Array(runner.invocations[0].arguments.prefix(3)), ["-l", "JavaScript", "-e"]) + XCTAssertEqual(runner.invocations[0].arguments.count, 4) + XCTAssertFalse(runner.invocations[0].arguments[3].contains(privatePath)) + } + + func testBackgroundCommandAndEncodingFailuresRemainGenericAndSecretFree() async { + let privateDetail = "/Users/example/private-token" + let failedRunner = RecordingAutomationCommandRunner(result: .success( + AutomationCommandResult( + status: 1, + standardOutput: Data(privateDetail.utf8), + standardError: Data(privateDetail.utf8) + ) + )) + let invalidUTF8Runner = RecordingAutomationCommandRunner(result: .success( + AutomationCommandResult( + status: 0, + standardOutput: Data([0xFF, 0xFE]), + standardError: Data(privateDetail.utf8) + ) + )) + + let failedSource = BackgroundTaskAutomationSource( + runner: failedRunner, + diagnosticPolicy: .available + ) + let invalidUTF8Source = BackgroundTaskAutomationSource( + runner: invalidUTF8Runner, + diagnosticPolicy: .available + ) + let failed = await failedSource.snapshot() + let invalidUTF8 = await invalidUTF8Source.snapshot() + let failedFingerprint = await failedSource.currentRawOutputHash() + + for snapshot in [failed, invalidUTF8] { + XCTAssertTrue(snapshot.records.isEmpty) + XCTAssertEqual(snapshot.health.state, .failed) + XCTAssertFalse(snapshot.health.message?.contains(privateDetail) ?? true) + } + XCTAssertEqual( + failedFingerprint, + SHA256.hash(data: Data(privateDetail.utf8)) + .map { String(format: "%02x", $0) } + .joined() + ) + } + + func testLegacyProductionAdapterMapsAppleEventsFailureToPermissionDenial() async { + let runner = RecordingAutomationCommandRunner(result: .success( + AutomationCommandResult( + status: 1, + standardOutput: Data(), + standardError: Data("private Apple Events detail".utf8) + ) + )) + let adapter = OSACommandLegacyLoginItemAdapter(runner: runner) + + do { + _ = try await adapter.currentUserLoginItems() + XCTFail("Expected permission denial") + } catch { + XCTAssertEqual(error as? LegacyLoginItemAdapterError, .permissionDenied) + } + } + + func testBackgroundDiagnosticConflictingRecognizedFieldsAreUnresolved() async { + let output = Data(""" + #1: + Identifier: com.example.first + Identifier: com.example.second + Executable Path: /bin/sleep + """.utf8) + let runner = RecordingAutomationCommandRunner(result: .success( + AutomationCommandResult(status: 0, standardOutput: output, standardError: Data()) + )) + let source = BackgroundTaskAutomationSource(runner: runner, diagnosticPolicy: .available) + + let snapshot = await source.snapshot() + + XCTAssertTrue(snapshot.records.isEmpty) + XCTAssertEqual(snapshot.health.state, .failed) + XCTAssertEqual( + snapshot.health.message, + "Some Background Task Management records were unresolved." + ) + } +} + +private func legacySnapshot( + _ descriptor: LegacyLoginItemDescriptor +) async -> AutomationRecord { + let source = LegacyLoginItemAutomationSource( + adapter: StubLegacyLoginItemAdapter(result: .success([descriptor])), + currentUID: 501, + now: { Date(timeIntervalSince1970: 1_000) } + ) + let snapshot = await source.snapshot() + return try! XCTUnwrap(snapshot.records.first) +} + +private func sha256(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() +} + +private struct StubLegacyLoginItemAdapter: LegacyLoginItemListing { + let result: Result<[LegacyLoginItemDescriptor], LegacyLoginItemAdapterError> + + func currentUserLoginItems() async throws -> [LegacyLoginItemDescriptor] { + try result.get() + } +} diff --git a/Tests/DevScopeCoreTests/LongRunningAssessmentTests.swift b/Tests/DevScopeCoreTests/LongRunningAssessmentTests.swift new file mode 100644 index 0000000..d3408f0 --- /dev/null +++ b/Tests/DevScopeCoreTests/LongRunningAssessmentTests.swift @@ -0,0 +1,32 @@ +import XCTest +@testable import DevScopeCore + +final class LongRunningAssessmentTests: XCTestCase { + func testFourHourBoundaryIsInclusive() { + let below = DevProcessResourceUsage( + cpuPercent: 0, residentMemoryBytes: 1, elapsedTime: "03:59:59") + let boundary = DevProcessResourceUsage( + cpuPercent: 0, residentMemoryBytes: 1, elapsedTime: "04:00:00") + + XCTAssertFalse(LongRunningAssessment.isLongRunning(below, threshold: 14_400)) + XCTAssertTrue(LongRunningAssessment.isLongRunning(boundary, threshold: 14_400)) + } + + func testUnavailableElapsedTimeDoesNotBecomeLongRunning() { + let usage = DevProcessResourceUsage( + cpuPercent: 0, residentMemoryBytes: 1, elapsedTime: "-") + + XCTAssertFalse(LongRunningAssessment.isLongRunning(usage, threshold: 14_400)) + XCTAssertFalse(LongRunningAssessment.isLongRunning(nil, threshold: 14_400)) + } + + func testDefaultThresholdIsExactlyFourHoursAndInclusive() { + let below = DevProcessResourceUsage( + cpuPercent: 0, residentMemoryBytes: 1, elapsedTime: "03:59:59") + let boundary = DevProcessResourceUsage( + cpuPercent: 0, residentMemoryBytes: 1, elapsedTime: "04:00:00") + + XCTAssertFalse(LongRunningAssessment.isLongRunning(below)) + XCTAssertTrue(LongRunningAssessment.isLongRunning(boundary)) + } +} diff --git a/Tests/DevScopeCoreTests/PersistentCopyStoreTests.swift b/Tests/DevScopeCoreTests/PersistentCopyStoreTests.swift new file mode 100644 index 0000000..8de287d --- /dev/null +++ b/Tests/DevScopeCoreTests/PersistentCopyStoreTests.swift @@ -0,0 +1,107 @@ +import XCTest +@testable import DevScopeCore + +final class PersistentCopyStoreTests: XCTestCase { + func testSavesLoadsAndClearsLastCopiedPayload() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let store = PersistentCopyStore(directory: directory) + defer { try? FileManager.default.removeItem(at: directory) } + + let timestamp = Date() + let saved = try store.save(text: "pid\tcommand\n1\tnode", label: "Visible rows", timestamp: timestamp) + let loaded = try XCTUnwrap(store.load()) + + XCTAssertEqual(loaded.label, "Visible rows") + XCTAssertEqual(loaded.text, "pid\tcommand\n1\tnode") + XCTAssertEqual(loaded.isTruncated, saved.isTruncated) + XCTAssertEqual(loaded.timestamp.timeIntervalSince1970, timestamp.timeIntervalSince1970, accuracy: 1.0) + + try store.clear() + XCTAssertNil(try store.load()) + } + + func testBoundsLargePayloadsForRecoveryCache() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let store = PersistentCopyStore(directory: directory, maxStoredBytes: 64) + defer { try? FileManager.default.removeItem(at: directory) } + + let saved = try store.save(text: String(repeating: "a", count: 200), label: "Large export") + + XCTAssertTrue(saved.isTruncated) + XCTAssertLessThanOrEqual(Data(saved.text.utf8).count, 64) + XCTAssertTrue(saved.text.contains("[truncated")) + } + + func testBoundsPayloadWhenByteLimitIsSmallerThanTruncationMarker() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let store = PersistentCopyStore(directory: directory, maxStoredBytes: 8) + defer { try? FileManager.default.removeItem(at: directory) } + + let saved = try store.save(text: "sensitive-value", label: "Small cache") + + XCTAssertTrue(saved.isTruncated) + XCTAssertLessThanOrEqual(Data(saved.text.utf8).count, 8) + } + + func testMaximumSizedEscapedPayloadCanBeLoadedAfterSaving() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let maxStoredBytes = 70_000 + let store = PersistentCopyStore(directory: directory, maxStoredBytes: maxStoredBytes) + defer { try? FileManager.default.removeItem(at: directory) } + let text = String(repeating: "\"", count: maxStoredBytes) + + let saved = try store.save(text: text, label: "Escaped export") + let loaded = try XCTUnwrap(store.load()) + + XCTAssertFalse(saved.isTruncated) + XCTAssertEqual(loaded.label, saved.label) + XCTAssertEqual(loaded.text, saved.text) + XCTAssertEqual(loaded.isTruncated, saved.isTruncated) + XCTAssertEqual(loaded.timestamp.timeIntervalSince1970, saved.timestamp.timeIntervalSince1970, accuracy: 1.0) + } + + func testSaveRejectsPayloadWhoseEncodedMetadataExceedsReadableLimit() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let store = PersistentCopyStore(directory: directory, maxStoredBytes: 64) + defer { try? FileManager.default.removeItem(at: directory) } + let oversizedLabel = String(repeating: "x", count: 70_000) + + XCTAssertThrowsError(try store.save(text: "value", label: oversizedLabel)) { error in + XCTAssertEqual(error as? PersistentCopyStoreError, .payloadTooLarge) + } + } + + func testRecoveryCacheUsesOwnerOnlyPermissions() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let store = PersistentCopyStore(directory: directory) + defer { try? FileManager.default.removeItem(at: directory) } + + try store.save(text: "pid\tcommand\n1\tnode --token ", label: "Visible rows") + + let payloadURL = directory.appendingPathComponent("last-copy.json", isDirectory: false) + let directoryMode = try XCTUnwrap(FileManager.default.attributesOfItem(atPath: directory.path)[.posixPermissions] as? NSNumber) + let payloadMode = try XCTUnwrap(FileManager.default.attributesOfItem(atPath: payloadURL.path)[.posixPermissions] as? NSNumber) + + XCTAssertEqual(directoryMode.intValue & 0o777, 0o700) + XCTAssertEqual(payloadMode.intValue & 0o777, 0o600) + } + + func testLoadRejectsAnOversizedTamperedRecoveryCache() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let store = PersistentCopyStore(directory: directory, maxStoredBytes: 64) + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try Data(repeating: 0x41, count: 70_000).write( + to: directory.appendingPathComponent("last-copy.json") + ) + + XCTAssertThrowsError(try store.load()) + } +} diff --git a/Tests/DevScopeCoreTests/ProcessAccessAssessmentTests.swift b/Tests/DevScopeCoreTests/ProcessAccessAssessmentTests.swift new file mode 100644 index 0000000..77068ba --- /dev/null +++ b/Tests/DevScopeCoreTests/ProcessAccessAssessmentTests.swift @@ -0,0 +1,76 @@ +import DevScopeCore +import XCTest + +final class ProcessAccessAssessmentTests: XCTestCase { + func testSandboxedBuildDoesNotSuggestPrivacyPermissions() { + let assessment = ProcessAccessAssessment.assess( + isSandboxed: true, + processes: [], + errorDescription: "Native process scanning returned no visible processes" + ) + + XCTAssertTrue(assessment.hasBlockedRequirement) + XCTAssertFalse(assessment.hasNeededAction) + XCTAssertEqual(assessment.requirements.map(\.kind), [.processMetadata]) + XCTAssertEqual(assessment.requirements.first?.state, .blocked) + XCTAssertNil(assessment.requirements.first?.action) + } + + func testReadyProcessAndFolderAccessShowsNoNeededActions() { + let assessment = ProcessAccessAssessment.assess( + isSandboxed: false, + processes: [ + process(pid: 100, currentDirectory: "/Users/example/dev/sample-app"), + process(pid: 101, currentDirectory: "/Users/example/dev/sample-service") + ], + errorDescription: nil + ) + + XCTAssertFalse(assessment.hasBlockedRequirement) + XCTAssertFalse(assessment.hasNeededAction) + XCTAssertEqual(assessment.processCount, 2) + XCTAssertEqual(assessment.currentDirectoryCount, 2) + XCTAssertEqual(assessment.requirements.map(\.state), [.ready, .ready]) + } + + func testMissingFolderContextSuggestsFullDiskAccessOnly() { + let assessment = ProcessAccessAssessment.assess( + isSandboxed: false, + processes: [ + process(pid: 100, currentDirectory: nil), + process(pid: 101, currentDirectory: nil) + ], + errorDescription: nil + ) + + XCTAssertTrue(assessment.hasNeededAction) + XCTAssertEqual(assessment.requirements.map(\.kind), [.processMetadata, .workingDirectories]) + XCTAssertEqual(assessment.requirements.map(\.state), [.ready, .needed]) + XCTAssertEqual(assessment.requirements.last?.action, .fullDiskAccess) + } + + func testProcessScanFailureDoesNotSuggestUserGrantablePermission() { + let assessment = ProcessAccessAssessment.assess( + isSandboxed: false, + processes: nil, + errorDescription: "Process scanning is blocked by macOS" + ) + + XCTAssertTrue(assessment.hasBlockedRequirement) + XCTAssertFalse(assessment.hasNeededAction) + XCTAssertEqual(assessment.requirements.map(\.kind), [.processMetadata]) + XCTAssertEqual(assessment.requirements.first?.state, .blocked) + XCTAssertNil(assessment.requirements.first?.action) + } + + private func process(pid: Int32, currentDirectory: String?) -> DevProcess { + DevProcess( + pid: pid, + parentPID: 1, + executable: "python3.12", + command: "python train.py", + currentDirectory: currentDirectory, + resourceUsage: nil + ) + } +} diff --git a/Tests/DevScopeCoreTests/ProcessActionPolicyTests.swift b/Tests/DevScopeCoreTests/ProcessActionPolicyTests.swift new file mode 100644 index 0000000..5aa6195 --- /dev/null +++ b/Tests/DevScopeCoreTests/ProcessActionPolicyTests.swift @@ -0,0 +1,80 @@ +import Foundation +import XCTest + +@testable import DevScopeCore + +final class ProcessActionPolicyTests: XCTestCase { + func testProtectsPIDZero() { + let item = classified(pid: 0, executable: "/usr/bin/ordinary", kind: .other) + + let decision = ProcessActionPolicy.decision(for: item, currentProcessID: 9000) + + XCTAssertFalse(decision.isAllowed) + XCTAssertEqual(decision.reason, "macOS launch infrastructure is protected") + } + + func testProtectsPIDOneWithNonLaunchdExecutable() { + let item = classified(pid: 1, executable: "/usr/bin/ordinary", kind: .other) + + let decision = ProcessActionPolicy.decision(for: item, currentProcessID: 9000) + + XCTAssertFalse(decision.isAllowed) + XCTAssertEqual(decision.reason, "macOS launch infrastructure is protected") + } + + func testProtectsLaunchdAtAnOrdinaryPID() { + let item = classified(pid: 4200, executable: "/sbin/launchd", kind: .other) + + let decision = ProcessActionPolicy.decision(for: item, currentProcessID: 9000) + + XCTAssertFalse(decision.isAllowed) + XCTAssertEqual(decision.reason, "macOS launch infrastructure is protected") + } + + func testProtectsCriticalSystemService() { + let item = classified( + pid: 4200, executable: "/System/Library/CoreServices/WindowServer", kind: .systemService) + + let decision = ProcessActionPolicy.decision(for: item, currentProcessID: 9000) + + XCTAssertFalse(decision.isAllowed) + XCTAssertEqual(decision.reason, "Critical macOS system infrastructure is protected") + } + + func testProtectsCriticalExecutableEvenWhenClassificationDegrades() { + let item = classified( + pid: 4200, executable: "/System/Library/CoreServices/WindowServer", kind: .other) + + XCTAssertFalse(ProcessActionPolicy.decision(for: item, currentProcessID: 9000).isAllowed) + } + + func testProtectsTheRunningDevScopeProcess() { + let item = classified( + pid: 42, executable: "/Applications/DevScope.app/Contents/MacOS/DevScope", kind: .macApp) + + let decision = ProcessActionPolicy.decision(for: item, currentProcessID: 42) + + XCTAssertFalse(decision.isAllowed) + XCTAssertEqual(decision.reason, "DevScope cannot terminate itself") + } + + func testAllowsAUserOwnedDevelopmentProcess() { + let item = classified(pid: 4200, executable: "/opt/homebrew/bin/node", kind: .javascript) + + XCTAssertEqual( + ProcessActionPolicy.decision(for: item, currentProcessID: 42), + .allowed + ) + } + + private func classified(pid: Int32, executable: String, kind: DevRuntimeKind) + -> ClassifiedDevProcess + { + ClassifiedDevProcess( + process: DevProcess(pid: pid, parentPID: 1, executable: executable, command: executable), + classification: DevProcessClassification( + kind: kind, displayName: URL(fileURLWithPath: executable).lastPathComponent, + projectHint: nil) + ) + } +} diff --git a/Tests/DevScopeCoreTests/ProcessActionPresentationTests.swift b/Tests/DevScopeCoreTests/ProcessActionPresentationTests.swift new file mode 100644 index 0000000..ef5d84f --- /dev/null +++ b/Tests/DevScopeCoreTests/ProcessActionPresentationTests.swift @@ -0,0 +1,56 @@ +import XCTest +@testable import DevScopeCore + +final class ProcessActionPresentationTests: XCTestCase { + func testEndedProcessGuidanceWinsForEveryDestructiveControl() { + for control in ProcessActionControl.allCases { + let presentation = ProcessActionPresentation.control( + control, + isEnded: true, + actionDecision: .protected(reason: "policy reason must not win") + ) + + XCTAssertTrue(presentation.isDisabled) + XCTAssertEqual(presentation.help, "Unavailable: This process has ended.") + XCTAssertEqual( + presentation.accessibilityHint, + "Unavailable because the selected process has ended." + ) + } + } + + func testProtectedProcessDisablesEveryDestructiveControlWithPolicyGuidance() { + let reason = "Critical macOS system infrastructure is protected" + + for control in ProcessActionControl.allCases { + let presentation = ProcessActionPresentation.control( + control, + isEnded: false, + actionDecision: .protected(reason: reason) + ) + + XCTAssertTrue(presentation.isDisabled) + XCTAssertEqual(presentation.help, "Unavailable: \(reason)") + XCTAssertEqual(presentation.accessibilityHint, "Unavailable: \(reason)") + } + } + + func testTerminationConsequencesStaySpecificToTheSelectedAction() { + XCTAssertEqual( + ProcessTerminationAction.single.consequence(pid: 77, descendantCount: 2), + "Sends SIGTERM to PID 77. The process may save state before exiting." + ) + XCTAssertEqual( + ProcessTerminationAction.tree.consequence(pid: 77, descendantCount: 2), + "Sends SIGTERM to PID 77 after 2 descendant processes." + ) + XCTAssertEqual( + ProcessTerminationAction.forceSingle.consequence(pid: 77, descendantCount: 2), + "Sends SIGKILL to PID 77. The process cannot save state." + ) + XCTAssertEqual( + ProcessTerminationAction.forceTree.consequence(pid: 77, descendantCount: 1), + "Sends SIGKILL to PID 77 and 1 descendant process. None can save state." + ) + } +} diff --git a/Tests/DevScopeCoreTests/ProcessCacheIdentityTests.swift b/Tests/DevScopeCoreTests/ProcessCacheIdentityTests.swift new file mode 100644 index 0000000..a0fced2 --- /dev/null +++ b/Tests/DevScopeCoreTests/ProcessCacheIdentityTests.swift @@ -0,0 +1,89 @@ +import XCTest +@testable import DevScopeCore + +final class ProcessCacheIdentityTests: XCTestCase { + func testKnownBirthIdentityIgnoresMetadataEnrichment() { + let birthToken = ProcessBirthToken(seconds: 1_000, microseconds: 42) + let initial = DevProcess( + pid: 700, + parentPID: 1, + executable: "node", + command: "node", + birthToken: birthToken + ) + let enriched = DevProcess( + pid: 700, + parentPID: 99, + executable: "/opt/homebrew/bin/node", + command: "/opt/homebrew/bin/node server.js", + birthToken: birthToken + ) + + XCTAssertEqual( + ProcessCacheIdentity(process: initial), + ProcessCacheIdentity(process: enriched) + ) + } + + func testUnknownBirthUsesConservativeProcessMetadataFallback() { + let original = processWithoutBirth() + let originalIdentity = ProcessCacheIdentity(process: original) + + XCTAssertEqual( + originalIdentity, + ProcessCacheIdentity(process: processWithoutBirth()) + ) + XCTAssertNotEqual( + originalIdentity, + ProcessCacheIdentity(process: processWithoutBirth(parentPID: 2)) + ) + XCTAssertNotEqual( + originalIdentity, + ProcessCacheIdentity(process: processWithoutBirth(executable: "python")) + ) + XCTAssertNotEqual( + originalIdentity, + ProcessCacheIdentity(process: processWithoutBirth(command: "worker --other")) + ) + } + + func testKnownBirthChangeAndBirthAvailabilityTransitionChangeIdentity() { + let withoutBirth = processWithoutBirth() + let firstBirth = DevProcess( + pid: withoutBirth.pid, + parentPID: withoutBirth.parentPID, + executable: withoutBirth.executable, + command: withoutBirth.command, + birthToken: ProcessBirthToken(seconds: 1_000, microseconds: 1) + ) + let secondBirth = DevProcess( + pid: firstBirth.pid, + parentPID: firstBirth.parentPID, + executable: firstBirth.executable, + command: firstBirth.command, + birthToken: ProcessBirthToken(seconds: 1_000, microseconds: 2) + ) + + XCTAssertNotEqual( + ProcessCacheIdentity(process: firstBirth), + ProcessCacheIdentity(process: secondBirth) + ) + XCTAssertNotEqual( + ProcessCacheIdentity(process: withoutBirth), + ProcessCacheIdentity(process: firstBirth) + ) + } + + private func processWithoutBirth( + parentPID: Int32 = 1, + executable: String = "worker", + command: String = "worker --serve" + ) -> DevProcess { + DevProcess( + pid: 700, + parentPID: parentPID, + executable: executable, + command: command + ) + } +} diff --git a/Tests/DevScopeCoreTests/ProcessClassificationCacheTests.swift b/Tests/DevScopeCoreTests/ProcessClassificationCacheTests.swift new file mode 100644 index 0000000..dec87bf --- /dev/null +++ b/Tests/DevScopeCoreTests/ProcessClassificationCacheTests.swift @@ -0,0 +1,186 @@ +import XCTest +@testable import DevScopeCore + +final class ProcessClassificationCacheTests: XCTestCase { + func testBudgetedWorkspaceRevalidationProgressivelyReclassifiesChangedFacts() throws { + var cache = ProcessClassificationCache() + var classificationCalls = 0 + var flutterDirectories: Set = [] + let workspaceFactsCache = WorkspaceFactsCache { path in + flutterDirectories.contains(String(path.dropLast("/pubspec.yaml".count))) && + path.hasSuffix("/pubspec.yaml") + } + let processes = (1...9).map { index in + process( + pid: Int32(700 + index), + command: "workspace-runner serve", + currentDirectory: "/workspace/\(index)" + ) + } + + _ = cache.classified(processes, workspaceFactsCache: workspaceFactsCache) { process, workspaceFacts in + classificationCalls += 1 + return ProcessClassifier.classify(process, workspaceFacts: workspaceFacts) + } + flutterDirectories.insert("/workspace/9") + workspaceFactsCache.invalidateAll() + let firstCycle = cache.classified( + processes, + workspaceFactsCache: workspaceFactsCache + ) { process, workspaceFacts in + classificationCalls += 1 + return ProcessClassifier.classify(process, workspaceFacts: workspaceFacts) + } + let secondCycle = cache.classified( + processes, + workspaceFactsCache: workspaceFactsCache + ) { process, workspaceFacts in + classificationCalls += 1 + return ProcessClassifier.classify(process, workspaceFacts: workspaceFacts) + } + + XCTAssertEqual(try XCTUnwrap(firstCycle.last).classification.kind, .other) + XCTAssertEqual(try XCTUnwrap(secondCycle.last).classification.kind, .flutter) + XCTAssertEqual(classificationCalls, 10) + } + + func testReusesClassificationWhenInvalidatedWorkspaceFactsAreUnchanged() { + var cache = ProcessClassificationCache() + var classificationCalls = 0 + var workspaceProbeCount = 0 + let workspaceFactsCache = WorkspaceFactsCache { _ in + workspaceProbeCount += 1 + return false + } + let unchanged = process() + + _ = cache.classified( + [unchanged], + workspaceFactsCache: workspaceFactsCache + ) { process, workspaceFacts in + classificationCalls += 1 + return ProcessClassifier.classify(process, workspaceFacts: workspaceFacts) + } + let initialWorkspaceProbeCount = workspaceProbeCount + workspaceFactsCache.invalidateAll() + _ = cache.classified( + [unchanged], + workspaceFactsCache: workspaceFactsCache + ) { process, workspaceFacts in + classificationCalls += 1 + return ProcessClassifier.classify(process, workspaceFacts: workspaceFacts) + } + + XCTAssertEqual(classificationCalls, 1) + XCTAssertGreaterThan(workspaceProbeCount, initialWorkspaceProbeCount) + } + + func testReclassifiesImmediatelyWhenRefreshedWorkspaceFactsChange() throws { + var cache = ProcessClassificationCache() + var classificationCalls = 0 + var manifestExists = false + let workspaceFactsCache = WorkspaceFactsCache { path in + manifestExists && path.hasSuffix("/pubspec.yaml") + } + let process = process(command: "workspace-runner serve") + + let beforeManifest = cache.classified( + [process], + workspaceFactsCache: workspaceFactsCache + ) { process, workspaceFacts in + classificationCalls += 1 + return ProcessClassifier.classify(process, workspaceFacts: workspaceFacts) + } + manifestExists = true + workspaceFactsCache.invalidateAll() + let afterManifest = cache.classified( + [process], + workspaceFactsCache: workspaceFactsCache + ) { process, workspaceFacts in + classificationCalls += 1 + return ProcessClassifier.classify(process, workspaceFacts: workspaceFacts) + } + + XCTAssertEqual(classificationCalls, 2) + XCTAssertEqual(try XCTUnwrap(beforeManifest.first).classification.kind, .other) + XCTAssertEqual(try XCTUnwrap(afterManifest.first).classification.kind, .flutter) + } + + func testReusesClassificationWhenOnlyMetricsChange() throws { + var cache = ProcessClassificationCache() + var classificationCalls = 0 + let first = process(cpu: 1) + let second = process(cpu: 80) + + _ = cache.classified([first]) { process in + classificationCalls += 1 + return ProcessClassifier.classify(process) + } + let updated = cache.classified([second]) { process in + classificationCalls += 1 + return ProcessClassifier.classify(process) + } + + XCTAssertEqual(classificationCalls, 1) + XCTAssertEqual(try XCTUnwrap(updated.first).process.resourceUsage?.cpuPercent, 80) + } + + func testReclassifiesWhenCommandChangesAndDropsMissingPIDs() { + var cache = ProcessClassificationCache() + var calls = 0 + _ = cache.classified([process(command: "node vite")]) { process in + calls += 1 + return ProcessClassifier.classify(process) + } + let updated = cache.classified([process(command: "python worker.py")]) { process in + calls += 1 + return ProcessClassifier.classify(process) + } + let empty = cache.classified([]) { process in + calls += 1 + return ProcessClassifier.classify(process) + } + + XCTAssertEqual(calls, 2) + XCTAssertEqual(updated.first?.classification.kind, .python) + XCTAssertTrue(empty.isEmpty) + XCTAssertEqual(cache.cachedProcessIDs, []) + } + + func testReclassifiesRecycledPIDWhenBirthTokenChanges() throws { + var cache = ProcessClassificationCache() + var calls = 0 + let first = process(birthToken: ProcessBirthToken(seconds: 1_000, microseconds: 100)) + let recycled = process(birthToken: ProcessBirthToken(seconds: 1_000, microseconds: 101)) + + _ = cache.classified([first]) { process in + calls += 1 + return ProcessClassifier.classify(process) + } + let updated = cache.classified([recycled]) { process in + calls += 1 + return ProcessClassifier.classify(process) + } + + XCTAssertEqual(calls, 2) + XCTAssertEqual(try XCTUnwrap(updated.first).process.birthToken, recycled.birthToken) + } + + private func process( + pid: Int32 = 700, + cpu: Double = 1, + command: String = "node vite", + currentDirectory: String = NSHomeDirectory() + "/dev/example/devscope", + birthToken: ProcessBirthToken? = nil + ) -> DevProcess { + DevProcess( + pid: pid, + parentPID: 1, + executable: command.split(separator: " ").first.map(String.init) ?? command, + command: command, + currentDirectory: currentDirectory, + resourceUsage: DevProcessResourceUsage(cpuPercent: cpu, residentMemoryBytes: 100, elapsedTime: "00:10"), + birthToken: birthToken + ) + } +} diff --git a/Tests/DevScopeCoreTests/ProcessClassifierTests.swift b/Tests/DevScopeCoreTests/ProcessClassifierTests.swift new file mode 100644 index 0000000..8263788 --- /dev/null +++ b/Tests/DevScopeCoreTests/ProcessClassifierTests.swift @@ -0,0 +1,750 @@ +import XCTest +@testable import DevScopeCore + +final class ProcessClassifierTests: XCTestCase { + func testCachedFlutterWorkspaceFactsAvoidRepeatedFileProbe() { + var probeCount = 0 + let cache = WorkspaceFactsCache { path in + probeCount += 1 + return path.hasSuffix("/pubspec.yaml") + } + let directory = NSHomeDirectory() + "/dev/example/app" + + XCTAssertTrue(cache.isFlutterWorkspace(directory)) + XCTAssertTrue(cache.isFlutterWorkspace(directory)) + XCTAssertEqual(probeCount, 1) + } + + func testSuppliedWorkspaceFactsDriveFlutterClassification() throws { + let process = DevProcess( + pid: 99, + parentPID: 1, + executable: "workspace-runner", + command: "workspace-runner serve", + currentDirectory: NSHomeDirectory() + "/dev/example/app" + ) + + let classification = try XCTUnwrap( + ProcessClassifier.classify( + process, + workspaceFacts: WorkspaceFacts(isFlutterWorkspace: true) + ) + ) + + XCTAssertEqual(classification.kind, .flutter) + } + + func testSuppliedNonFlutterFactsOverridePathHeuristic() throws { + let process = DevProcess( + pid: 100, + parentPID: 1, + executable: "workspace-runner", + command: "workspace-runner serve", + currentDirectory: NSHomeDirectory() + "/dev/flutter/app" + ) + + let classification = try XCTUnwrap( + ProcessClassifier.classify( + process, + workspaceFacts: WorkspaceFacts(isFlutterWorkspace: false) + ) + ) + + XCTAssertEqual(classification.kind, .other) + } + + func testWorkspaceFactsInvalidationRefreshesFileProbe() { + var manifestExists = false + var probeCount = 0 + let cache = WorkspaceFactsCache { path in + probeCount += 1 + return manifestExists && path.hasSuffix("/pubspec.yaml") + } + let directory = NSHomeDirectory() + "/dev/example/app" + + XCTAssertFalse(cache.isFlutterWorkspace(directory)) + manifestExists = true + XCTAssertFalse(cache.isFlutterWorkspace(directory)) + cache.invalidateAll() + XCTAssertTrue(cache.isFlutterWorkspace(directory)) + XCTAssertEqual(probeCount, 3) + } + + func testWorkspaceFactsClassificationCacheReturnsFreshMetricsWithoutRepeatedProbe() throws { + var probeCount = 0 + let workspaceFactsCache = WorkspaceFactsCache { path in + probeCount += 1 + return path.hasSuffix("/pubspec.yaml") + } + var cache = ProcessClassificationCache() + let directory = NSHomeDirectory() + "/dev/example/app" + let first = DevProcess( + pid: 101, + parentPID: 1, + executable: "workspace-runner", + command: "workspace-runner serve", + currentDirectory: directory, + resourceUsage: DevProcessResourceUsage(cpuPercent: 1, residentMemoryBytes: 100, elapsedTime: "00:01") + ) + let second = DevProcess( + pid: 101, + parentPID: 1, + executable: "workspace-runner", + command: "workspace-runner serve", + currentDirectory: directory, + resourceUsage: DevProcessResourceUsage(cpuPercent: 90, residentMemoryBytes: 100, elapsedTime: "00:03") + ) + + _ = cache.classified([first], workspaceFactsCache: workspaceFactsCache) + let classified = cache.classified([second], workspaceFactsCache: workspaceFactsCache) + + XCTAssertEqual(probeCount, 1) + XCTAssertEqual(try XCTUnwrap(classified.first).classification.kind, .flutter) + XCTAssertEqual(classified.first?.process.resourceUsage?.cpuPercent, 90) + } + + func testParsesPsRowsWithCommandContainingSpaces() throws { + let row = " 4312 112 /opt/homebrew/bin/node node ./node_modules/.bin/next dev --port 3014" + + let process = try XCTUnwrap(ProcessScanner.parsePSLine(row)) + + XCTAssertEqual(process.pid, 4312) + XCTAssertEqual(process.parentPID, 112) + XCTAssertEqual(process.executable, "/opt/homebrew/bin/node") + XCTAssertEqual(process.command, "node ./node_modules/.bin/next dev --port 3014") + } + + func testParsesPsRowsWithResourceMetrics() throws { + let row = " 4312 112 12.5 65536 01:02:03 /opt/homebrew/bin/node node ./node_modules/.bin/vite --host 0.0.0.0" + + let process = try XCTUnwrap(ProcessScanner.parsePSLine(row)) + + XCTAssertEqual(process.pid, 4312) + XCTAssertEqual(process.parentPID, 112) + XCTAssertEqual(process.executable, "/opt/homebrew/bin/node") + XCTAssertEqual(process.command, "node ./node_modules/.bin/vite --host 0.0.0.0") + XCTAssertEqual(process.resourceUsage?.cpuPercent, 12.5) + XCTAssertEqual(process.resourceUsage?.residentMemoryBytes, 67_108_864) + XCTAssertEqual(process.resourceUsage?.elapsedTime, "01:02:03") + } + + func testClassifiesExpectedDevProcesses() throws { + let samples: [(String, DevRuntimeKind)] = [ + ("node ./node_modules/.bin/next dev --port 3014", .javascript), + ("npm run dev", .javascript), + ("pnpm vite --host 0.0.0.0", .javascript), + ("python scripts/smoke.py --watch", .python), + ("python3 -m uvicorn app.main:app --reload", .python), + ("pytest tests/test_api.py -v", .python), + ("swift build", .swift), + ("cargo watch -x run", .rust), + ("go run ./cmd/server", .go), + ("flutter run -d macos", .flutter), + ("dart run build_runner watch", .flutter), + ("basic-memory mcp --transport stdio", .mcp) + ] + + for (command, expectedKind) in samples { + let process = DevProcess(pid: 10, parentPID: 1, executable: command.split(separator: " ").first.map(String.init) ?? command, command: command) + + let classification = try XCTUnwrap(ProcessClassifier.classify(process)) + + XCTAssertEqual(classification.kind, expectedKind, command) + } + } + + func testClassifiesFlutterWorkspaceProcessesWithoutLeavingThemAsOther() throws { + let home = NSHomeDirectory() + let samples = [ + DevProcess( + pid: 11, + parentPID: 1, + executable: "/Applications/Xcode.app/Contents/MacOS/Xcode", + command: "/Applications/Xcode.app/Contents/MacOS/Xcode", + currentDirectory: "\(home)/dev/flutter/usafe" + ), + DevProcess( + pid: 12, + parentPID: 1, + executable: "dart", + command: "dart \(home)/dev/flutter/usafe/.dart_tool/flutter_build/frontend_server.dart.snapshot", + currentDirectory: "\(home)/dev/flutter/usafe" + ) + ] + + for process in samples { + let classification = try XCTUnwrap(ProcessClassifier.classify(process), process.command) + XCTAssertEqual(classification.kind, .flutter) + XCTAssertEqual(classification.projectHint, "usafe") + } + } + + func testClassifiesSandboxNativeScannerAppPaths() throws { + let samples: [(DevProcess, DevRuntimeKind)] = [ + ( + DevProcess( + pid: 13, + parentPID: 1, + executable: "Cursor", + command: "/Applications/Cursor.app/Contents/MacOS/Cursor" + ), + .javascript + ), + ( + DevProcess( + pid: 14, + parentPID: 1, + executable: "Xcode", + command: "/Applications/Xcode.app/Contents/MacOS/Xcode" + ), + .swift + ), + ( + DevProcess( + pid: 15, + parentPID: 1, + executable: "Ollama", + command: "/Applications/Ollama.app/Contents/MacOS/Ollama" + ), + .ai + ) + ] + + for (process, expectedKind) in samples { + let classification = try XCTUnwrap(ProcessClassifier.classify(process), process.command) + XCTAssertEqual(classification.kind, expectedKind) + } + } + + func testClassifiesWholeMachineActivityInsteadOfDroppingNonDevRows() throws { + let samples: [(DevProcess, DevRuntimeKind, String)] = [ + ( + DevProcess( + pid: 20, + parentPID: 1, + executable: "/System/Library/CoreServices/Finder.app/Contents/MacOS/Finder", + command: "/System/Library/CoreServices/Finder.app/Contents/MacOS/Finder" + ), + .macApp, + "Finder" + ), + ( + DevProcess( + pid: 21, + parentPID: 1, + executable: "/Applications/Go", + command: "/Applications/Google Chrome.app/Contents/Frameworks/Google Chrome Helper.app/Contents/MacOS/Google Chrome Helper --type=renderer", + currentDirectory: "/" + ), + .browser, + "Google Chrome Helper" + ), + ( + DevProcess( + pid: 22, + parentPID: 1, + executable: "/System/Library/PrivateFrameworks/CloudKitDaemon.framework/Support/cloudd", + command: "/System/Library/PrivateFrameworks/CloudKitDaemon.framework/Support/cloudd" + ), + .backgroundAgent, + "cloudd" + ), + ( + DevProcess( + pid: 23, + parentPID: 1, + executable: "/usr/libexec/logd", + command: "/usr/libexec/logd" + ), + .systemService, + "logd" + ), + ( + DevProcess( + pid: 24, + parentPID: 1, + executable: "/opt/custom/bin/worker", + command: "/opt/custom/bin/worker --foreground" + ), + .other, + "worker" + ) + ] + + for (process, expectedKind, expectedDisplayName) in samples { + let classification = try XCTUnwrap(ProcessClassifier.classify(process), process.command) + XCTAssertEqual(classification.kind, expectedKind) + XCTAssertEqual(classification.displayName, expectedDisplayName) + } + } + + func testDerivesSystemProcessNameFromCommandWhenExecutableIsGenericPathFragment() throws { + let samples: [(DevProcess, DevRuntimeKind, String)] = [ + ( + DevProcess( + pid: 25, + parentPID: 1, + executable: "Library", + command: "/System/Library/PrivateFrameworks/EmailDaemon.framework/Versions/A/maild" + ), + .backgroundAgent, + "maild" + ), + ( + DevProcess( + pid: 26, + parentPID: 1, + executable: "Contents", + command: "/System/Library/CoreServices/Spotlight.app/Contents/MacOS/Spotlight" + ), + .macApp, + "Spotlight" + ), + ( + DevProcess( + pid: 27, + parentPID: 1, + executable: "Versions", + command: "/System/Library/PrivateFrameworks/CloudKitDaemon.framework/Versions/A/Support/cloudd" + ), + .backgroundAgent, + "cloudd" + ) + ] + + for (process, expectedKind, expectedDisplayName) in samples { + let classification = try XCTUnwrap(ProcessClassifier.classify(process), process.command) + XCTAssertEqual(classification.kind, expectedKind) + XCTAssertEqual(classification.displayName, expectedDisplayName) + XCTAssertNotEqual(classification.displayName.lowercased(), process.executable.lowercased()) + } + } + + func testDerivesSystemProcessNameFromCommandWhenExecutableIsShortPrefix() throws { + let samples: [(DevProcess, String)] = [ + ( + DevProcess( + pid: 28, + parentPID: 1, + executable: "/usr/libexec/Use", + command: "/usr/libexec/UserEventAgent (System)" + ), + "UserEventAgent" + ), + ( + DevProcess( + pid: 29, + parentPID: 1, + executable: "/usr/libexec/con", + command: "/usr/libexec/configd" + ), + "configd" + ), + ( + DevProcess( + pid: 30, + parentPID: 1, + executable: "/usr/libexec/IOM", + command: "/usr/libexec/IOMFB_bics_daemon" + ), + "IOMFB_bics_daemon" + ), + ( + DevProcess( + pid: 31, + parentPID: 1, + executable: "/usr/libexec/log", + command: "/usr/libexec/logd" + ), + "logd" + ) + ] + + for (process, expectedDisplayName) in samples { + let classification = try XCTUnwrap(ProcessClassifier.classify(process), process.command) + XCTAssertEqual(classification.kind, .systemService) + XCTAssertEqual(classification.displayName, expectedDisplayName) + } + } + + func testClassifiedSnapshotIncludesAllScannedRows() { + let processes = [ + DevProcess(pid: 31, parentPID: 1, executable: "node", command: "node ./node_modules/.bin/vite"), + DevProcess(pid: 32, parentPID: 1, executable: "/System/Library/CoreServices/Finder.app/Contents/MacOS/Finder", command: "/System/Library/CoreServices/Finder.app/Contents/MacOS/Finder"), + DevProcess(pid: 33, parentPID: 1, executable: "/usr/libexec/logd", command: "/usr/libexec/logd"), + DevProcess(pid: 34, parentPID: 1, executable: "/opt/custom/bin/worker", command: "/opt/custom/bin/worker --foreground") + ] + + let classified = ProcessClassifier.classified(processes) + + XCTAssertEqual(classified.map(\.process.pid), [31, 32, 33, 34]) + XCTAssertEqual(classified.map(\.classification.kind), [.javascript, .macApp, .systemService, .other]) + } + + func testClassifiesSystemProcessesWhoseTruncatedExecutableLooksLikeADevToolByCommandPath() throws { + let samples = [ + ( + DevProcess( + pid: 25, + parentPID: 1, + executable: "/usr/libexec/Air", + command: "/usr/libexec/AirPlayXPCHelper", + currentDirectory: "/" + ), + DevRuntimeKind.systemService + ), + ( + DevProcess( + pid: 26, + parentPID: 1, + executable: "/Applications/Go", + command: "/Applications/Google Chrome.app/Contents/Frameworks/Google Chrome Helper.app/Contents/MacOS/Google Chrome Helper", + currentDirectory: "/" + ), + DevRuntimeKind.browser + ) + ] + + for (process, expectedKind) in samples { + let classification = try XCTUnwrap(ProcessClassifier.classify(process), process.command) + XCTAssertEqual(classification.kind, expectedKind) + } + } + + func testDerivesReadableDisplayNameAndProjectHint() throws { + let process = DevProcess(pid: 30, parentPID: 1, executable: "node", command: "node \(NSHomeDirectory())/dev/mission-control/node_modules/.bin/next dev --port 3010") + + let classification = try XCTUnwrap(ProcessClassifier.classify(process)) + + XCTAssertEqual(classification.displayName, "next dev") + XCTAssertEqual(classification.projectHint, "mission-control") + } + + func testDerivesCodexPluginProjectHintFromCachePath() throws { + let process = DevProcess( + pid: 30, + parentPID: 1, + executable: "\(NSHomeDirectory())/.codex/plugins/cache/openai-bundled/computer-use/1.0.829/Codex", + command: "\(NSHomeDirectory())/.codex/plugins/cache/openai-bundled/computer-use/1.0.829/Codex", + currentDirectory: "\(NSHomeDirectory())/.codex/plugins/cache/openai-bundled/computer-use/1.0.829" + ) + + let classification = try XCTUnwrap(ProcessClassifier.classify(process)) + + XCTAssertEqual(classification.projectHint, "computer-use") + } + + func testDerivesReadablePythonAndNodeProcessNamesFromCommands() throws { + let samples: [(DevProcess, String)] = [ + ( + DevProcess( + pid: 31, + parentPID: 1, + executable: "\(NSHomeDirectory())/dev/job_hunt/.venv/bin/python3.11", + command: "\(NSHomeDirectory())/dev/job_hunt/.venv/bin/python3.11 \(NSHomeDirectory())/dev/job_hunt/.venv/bin/uvicorn forge_app.app:app --host 127.0.0.1", + currentDirectory: "\(NSHomeDirectory())/dev/job_hunt" + ), + "uvicorn forge_app" + ), + ( + DevProcess( + pid: 32, + parentPID: 1, + executable: "\(NSHomeDirectory())/.hermes/hermes-agent/venv/bin/python", + command: "\(NSHomeDirectory())/.hermes/hermes-agent/venv/bin/python -m hermes_cli.main gateway run --replace", + currentDirectory: "\(NSHomeDirectory())/.hermes/hermes-agent" + ), + "python module hermes_cli.main" + ), + ( + DevProcess( + pid: 33, + parentPID: 1, + executable: "node", + command: "node ./mcp/server.bundle.mjs", + currentDirectory: "\(NSHomeDirectory())/.codex/plugins/cache/openai-curated" + ), + "MCP server" + ), + ( + DevProcess( + pid: 34, + parentPID: 1, + executable: "node", + command: "node \(NSHomeDirectory())/.npm/_npx/99336612077b7094/node_modules/.bin/xcodebuildmcp mcp", + currentDirectory: "\(NSHomeDirectory())/.codex" + ), + "xcodebuildmcp" + ), + ( + DevProcess( + pid: 35, + parentPID: 1, + executable: "/opt/homebrew/Cellar/python@3.14/3.14.6/Frameworks/Python.framework/Versions/3.14/Resources/Python.app/Contents/MacOS/Python", + command: "/opt/homebrew/Cellar/python@3.14/3.14.6/Frameworks/Python.framework/Versions/3.14/Resources/Python.app/Contents/MacOS/Python \(NSHomeDirectory())/.cursor/extensions/ms-python.python-2025.6.1/python_files/run-jedi-language-server.py", + currentDirectory: "\(NSHomeDirectory())/dev/example/sample-service" + ), + "python run-jedi-language-server" + ), + ( + DevProcess( + pid: 36, + parentPID: 1, + executable: "/opt/homebrew/Cellar/python@3.14/3.14.6/Frameworks/Python.framework/Versions/3.14/Resources/Python.app/Contents/MacOS/Python", + command: "/opt/homebrew/Cellar/python@3.14/3.14.6/Frameworks/Python.framework/Versions/3.14/Resources/Python.app/Contents/MacOS/Python -c from multiprocessing.resource_tracker import main", + currentDirectory: "\(NSHomeDirectory())/dev/example/sample-service" + ), + "python command" + ) + ] + + for (process, expectedDisplayName) in samples { + let classification = try XCTUnwrap(ProcessClassifier.classify(process), process.command) + XCTAssertEqual(classification.displayName, expectedDisplayName) + } + } + + func testDerivesReadableMCPAndToolLauncherNamesFromCommands() throws { + let samples: [(DevProcess, String)] = [ + ( + DevProcess( + pid: 38, + parentPID: 1, + executable: "npm", + command: "npm exec xcodebuildmcp@latest mcp", + currentDirectory: "\(NSHomeDirectory())/.codex" + ), + "xcodebuildmcp" + ), + ( + DevProcess( + pid: 39, + parentPID: 1, + executable: "npm", + command: "npm exec shadcn@latest mcp", + currentDirectory: "\(NSHomeDirectory())/.codex" + ), + "shadcn MCP" + ), + ( + DevProcess( + pid: 42, + parentPID: 1, + executable: "node", + command: "node \(NSHomeDirectory())/.npm/_npx/99336612077b7094/node_modules/.bin/xcodebuildmcp mcp", + currentDirectory: "\(NSHomeDirectory())/.codex" + ), + "xcodebuildmcp" + ), + ( + DevProcess( + pid: 43, + parentPID: 1, + executable: "node", + command: "node ./mcp/server.cjs --stdio", + currentDirectory: "\(NSHomeDirectory())/dev/example/devscope" + ), + "MCP server" + ), + ( + DevProcess( + pid: 44, + parentPID: 1, + executable: "basic-memory", + command: "basic-memory mcp --transport stdio", + currentDirectory: "\(NSHomeDirectory())/.codex" + ), + "basic-memory MCP" + ) + ] + + for (process, expectedDisplayName) in samples { + let classification = try XCTUnwrap(ProcessClassifier.classify(process), process.command) + XCTAssertEqual(classification.displayName, expectedDisplayName) + } + } + + func testDerivesReadableNamesForTruncatedMacAppExecutablesInDevWorkspaces() throws { + let samples: [(DevProcess, DevRuntimeKind, String)] = [ + ( + DevProcess( + pid: 45, + parentPID: 1, + executable: "/Applications/Cu", + command: "/Applications/Cursor.app/Contents/Frameworks/Cursor Helper.app/Contents/MacOS/Cursor Helper --type=renderer --clientProcessId=69096", + currentDirectory: "\(NSHomeDirectory())/dev/example/sample-service" + ), + .other, + "Cursor" + ), + ( + DevProcess( + pid: 47, + parentPID: 1, + executable: "./Codex", + command: "Computer ./Codex Computer Use.app/Contents/SharedSupport/SkyComputerUseClient.app/Contents/MacOS/SkyComputerUseClient mcp", + currentDirectory: "\(NSHomeDirectory())/.codex/plugins/cache/openai-bundled/computer-use/1.0.829" + ), + .mcp, + "Computer Use MCP" + ) + ] + + for (process, expectedKind, displayName) in samples { + let classification = try XCTUnwrap(ProcessClassifier.classify(process)) + + XCTAssertEqual(classification.kind, expectedKind) + XCTAssertEqual(classification.displayName, displayName) + } + } + + func testDerivesReadableNamesForShellWrappedPythonCommands() throws { + let process = DevProcess( + pid: 46, + parentPID: 1, + executable: "/bin/zsh", + command: "/bin/zsh -c snap=$(command cat <&3); builtin unalias -m '*' 2>/dev/null || true; cd \(NSHomeDirectory())/dev/example/sample-service/research-lab && PYTHONPATH=. conda run -n research-lab python3 -c \"print('pages')\"", + currentDirectory: "\(NSHomeDirectory())/dev/example/sample-service/research-lab" + ) + + let classification = try XCTUnwrap(ProcessClassifier.classify(process)) + + XCTAssertEqual(classification.kind, .shell) + XCTAssertEqual(classification.displayName, "python command") + } + + func testDerivesReadableAIProcessNamesWithoutModelGuessing() throws { + let samples: [(DevProcess, String)] = [ + ( + DevProcess( + pid: 35, + parentPID: 1, + executable: "/Applications/Ol", + command: "ollama serve" + ), + "Ollama server" + ), + ( + DevProcess( + pid: 36, + parentPID: 1, + executable: "/Applications/Ol", + command: "/Applications/Ollama.app/Contents/Resources/ollama serve" + ), + "Ollama server" + ), + ( + DevProcess( + pid: 37, + parentPID: 1, + executable: "lmstudio", + command: "lmstudio --server" + ), + "LM Studio" + ) + ] + + for (process, expectedDisplayName) in samples { + let classification = try XCTUnwrap(ProcessClassifier.classify(process), process.command) + XCTAssertEqual(classification.kind, .ai) + XCTAssertEqual(classification.displayName, expectedDisplayName) + } + } + + func testTagsMachineLearningAndLLMDeveloperProcesses() throws { + let samples: [(DevProcess, [DevProcessTag])] = [ + ( + DevProcess(pid: 50, parentPID: 1, executable: "python", command: "python -m ipykernel_launcher -f kernel.json"), + [.notebook] + ), + ( + DevProcess(pid: 51, parentPID: 1, executable: "torchrun", command: "torchrun train_llama.py --model llama"), + [.training, .inference, .llm] + ), + ( + DevProcess(pid: 52, parentPID: 1, executable: "uvicorn", command: "uvicorn fastapi_app:app --reload"), + [.api] + ), + ( + DevProcess(pid: 53, parentPID: 1, executable: "python", command: "python -m qdrant_client.local"), + [.vectorDB] + ), + ( + DevProcess(pid: 54, parentPID: 1, executable: "streamlit", command: "streamlit run dashboard.py"), + [.dataApp] + ) + ] + + for (process, expectedTags) in samples { + let classification = try XCTUnwrap(ProcessClassifier.classify(process), process.command) + for expectedTag in expectedTags { + XCTAssertTrue(classification.tags.contains(expectedTag), "\(process.command) missing \(expectedTag.title)") + } + } + } + + func testIncludesShellsWhoseCurrentDirectoryIsADevProject() throws { + let samples: [(DevProcess, String)] = [ + ( + DevProcess( + pid: 40, + parentPID: 1, + executable: "/bin/zsh", + command: "-zsh", + currentDirectory: "\(NSHomeDirectory())/dev/devscope" + ), + "devscope" + ), + ( + DevProcess( + pid: 41, + parentPID: 1, + executable: "/bin/zsh", + command: "-zsh", + currentDirectory: "\(NSHomeDirectory())/.codex" + ), + "Codex" + ) + ] + + for (process, projectHint) in samples { + let classification = try XCTUnwrap(ProcessClassifier.classify(process)) + + XCTAssertEqual(classification.kind, .shell) + XCTAssertEqual(classification.displayName, "zsh") + XCTAssertEqual(classification.projectHint, projectHint) + } + } + + func testDoesNotTreatCodexSiblingFoldersAsCodexWorkspaces() throws { + let process = DevProcess( + pid: 42, + parentPID: 1, + executable: "/bin/zsh", + command: "-zsh", + currentDirectory: "\(NSHomeDirectory())/.codex-backup" + ) + + let classification = try XCTUnwrap(ProcessClassifier.classify(process)) + + XCTAssertEqual(classification.kind, .other) + XCTAssertEqual(classification.displayName, "zsh") + XCTAssertNil(classification.projectHint) + } + + func testClassifiesShellsOutsideDevWorkspacesAsGenericActivity() throws { + let process = DevProcess( + pid: 41, + parentPID: 1, + executable: "/bin/zsh", + command: "-zsh", + currentDirectory: "\(NSHomeDirectory())/Downloads" + ) + + let classification = try XCTUnwrap(ProcessClassifier.classify(process)) + + XCTAssertEqual(classification.kind, .other) + XCTAssertEqual(classification.displayName, "zsh") + XCTAssertNil(classification.projectHint) + } +} diff --git a/Tests/DevScopeCoreTests/ProcessIntelligenceTests.swift b/Tests/DevScopeCoreTests/ProcessIntelligenceTests.swift new file mode 100644 index 0000000..a6ec4e1 --- /dev/null +++ b/Tests/DevScopeCoreTests/ProcessIntelligenceTests.swift @@ -0,0 +1,639 @@ +import XCTest +@testable import DevScopeCore + +final class ProcessIntelligenceTests: XCTestCase { + func testWorkflowSubtitlesUseExplicitOSProcessLanguage() throws { + let item = classified( + pid: 100, + kind: .javascript, + displayName: "vite", + project: "devscope", + command: "pnpm vite" + ) + + let workflow = try XCTUnwrap(ProcessIntelligence.workflows(for: [item]).first) + + XCTAssertTrue(workflow.subtitle.contains("1 OS process")) + XCTAssertFalse(workflow.subtitle.contains("proc ·")) + } + + func testBuildsAIMLLabAndProjectWorkflows() { + let items = [ + classified(pid: 101, kind: .python, displayName: "torchrun train", project: "research", command: "torchrun train_llama.py", cpu: 92, memory: 900_000_000, tags: [.training, .llm]), + classified(pid: 102, kind: .python, displayName: "ipykernel", project: "research", command: "python -m ipykernel_launcher", cpu: 5, memory: 300_000_000, tags: [.notebook]), + classified(pid: 103, kind: .ai, displayName: "Ollama server", project: "research", command: "ollama serve", cpu: 12, memory: 5_000_000_000, tags: [.llmServer]), + classified(pid: 201, kind: .javascript, displayName: "vite", project: "web", command: "pnpm vite", cpu: 3, memory: 200_000_000) + ] + + let workflows = ProcessIntelligence.workflows(for: items) + + XCTAssertTrue(workflows.contains { $0.id == "workflow:ai-ml-lab" }) + XCTAssertTrue(workflows.contains { $0.id == "workflow:local-llm-stack" }) + XCTAssertTrue(workflows.contains { $0.title == "Research Training" }) + XCTAssertEqual(workflows.first?.risk, .heavy) + } + + func testCanonicalizesNoisyCacheAndOwnerWorkspaceNames() { + let home = NSHomeDirectory() + let items = [ + classified( + pid: 401, + kind: .javascript, + displayName: "MCP server", + project: "0.1.23", + command: "node \(home)/.codex/plugins/cache/openai-curated-remote/computer-use/0.1.23/mcp/server.bundle.mjs", + currentDirectory: "\(home)/.codex/plugins/cache/openai-curated-remote/computer-use/0.1.23/mcp" + ), + classified( + pid: 402, + kind: .javascript, + displayName: "MCP server", + project: "3fdeeb49", + command: "node \(home)/.codex/plugins/cache/openai-curated/build-web-apps/3fdeeb49/skills/server.mjs", + currentDirectory: "\(home)/.codex/plugins/cache/openai-curated/build-web-apps/3fdeeb49/skills" + ), + classified( + pid: 403, + kind: .swift, + displayName: "swift build", + project: "example", + command: "swift build", + currentDirectory: "\(home)/dev/example/devscope" + ) + ] + + let workflows = ProcessIntelligence.workflows(for: items) + let titles = workflows.map(\.title) + + XCTAssertTrue(titles.contains("Computer Use Web")) + XCTAssertTrue(titles.contains("Build Web Apps Web")) + XCTAssertTrue(titles.contains("DevScope Build")) + XCTAssertFalse(titles.contains { $0.contains("0.1.23") }) + XCTAssertFalse(titles.contains { $0.contains("3fdeeb49") }) + XCTAssertFalse(titles.contains("example Build")) + } + + func testWorkflowOrderStaysStableWhenCpuChangesWithinSameRiskBand() { + let items = [ + classified(pid: 201, kind: .javascript, displayName: "vite", project: "alpha", command: "pnpm vite", cpu: 1, memory: 200_000_000), + classified(pid: 202, kind: .javascript, displayName: "vite", project: "beta", command: "pnpm vite", cpu: 19, memory: 200_000_000) + ] + + let workflows = ProcessIntelligence.workflows(for: items) + + XCTAssertEqual(workflows.map(\.title), ["Alpha Web", "Beta Web"]) + } + + func testPunctuationCollidingProjectsHaveDistinctDeterministicWorkflows() { + let home = NSHomeDirectory() + let dotted = classified( + pid: 220, + kind: .javascript, + displayName: "vite", + project: "foo.bar", + command: "pnpm vite", + currentDirectory: "\(home)/dev/foo.bar" + ) + let dashed = classified( + pid: 221, + kind: .javascript, + displayName: "vite", + project: "foo-bar", + command: "pnpm vite", + currentDirectory: "\(home)/dev/foo-bar" + ) + let dottedWorker = classified( + pid: 222, + kind: .javascript, + displayName: "vite worker", + project: "foo.bar", + command: "pnpm vite --worker", + currentDirectory: "\(home)/dev/foo.bar" + ) + + let items = [dotted, dashed, dottedWorker] + let forward = ProcessIntelligence.workflows(for: items) + let reversed = ProcessIntelligence.workflows(for: Array(items.reversed())) + + XCTAssertEqual(forward, reversed) + XCTAssertEqual(Set(forward.map(\.title)), Set(["Foo.bar Web", "Foo Bar Web"])) + XCTAssertEqual(Set(forward.map(\.id)).count, 2) + XCTAssertEqual(Set(forward.flatMap(\.processIDs)), Set([220, 221, 222])) + XCTAssertEqual(forward.first { $0.title == "Foo.bar Web" }?.processIDs, [220, 222]) + } + + func testProjectWorkflowAggregationIsDeterministicAcrossInputOrder() throws { + let items = [ + classified( + pid: 903, + kind: .javascript, + displayName: "experiment worker", + project: "determinism", + command: "node experiment.js", + cpu: 10_000_000_000_000_000, + tags: [.experiment] + ), + classified( + pid: 901, + kind: .javascript, + displayName: "API worker", + project: "determinism", + command: "node api.js", + cpu: 1, + tags: [.api] + ), + classified( + pid: 902, + kind: .javascript, + displayName: "MCP worker", + project: "determinism", + command: "node mcp.js", + cpu: 1, + tags: [.mcp] + ), + ] + + let forward = try XCTUnwrap( + ProcessIntelligence.workflows(for: items).first { $0.title == "Determinism API" } + ) + let reversed = try XCTUnwrap( + ProcessIntelligence.workflows(for: Array(items.reversed())).first { $0.title == "Determinism API" } + ) + + XCTAssertEqual(forward, reversed) + XCTAssertEqual(forward.tags.map(\.id), ["api", "experiment", "mcp"]) + } + + func testAIMLAggregationIsDeterministicAcrossInputOrder() throws { + let items = [ + classified( + pid: 913, + kind: .python, + displayName: "experiment worker", + project: "gamma", + command: "python experiment.py", + cpu: 10_000_000_000_000_000, + tags: [.experiment] + ), + classified( + pid: 911, + kind: .python, + displayName: "training worker", + project: "alpha", + command: "python train.py", + cpu: 1, + tags: [.training] + ), + classified( + pid: 912, + kind: .python, + displayName: "notebook worker", + project: "beta", + command: "python notebook.py", + cpu: 1, + tags: [.notebook] + ), + ] + + let forward = try XCTUnwrap( + ProcessIntelligence.workflows(for: items).first { $0.id == "workflow:ai-ml-lab" } + ) + let reversed = try XCTUnwrap( + ProcessIntelligence.workflows(for: Array(items.reversed())).first { $0.id == "workflow:ai-ml-lab" } + ) + + XCTAssertEqual(forward, reversed) + XCTAssertEqual(forward.tags.map(\.id), ["experiment", "notebook", "training"]) + } + + func testAvoidsDuplicateKindWordsAndNoisySingleProcessFocusGroups() { + let items = [ + classified( + pid: 301, + kind: .python, + displayName: "streamlit", + project: "data", + command: "streamlit run app.py", + cpu: 4, + memory: 200_000_000, + tags: [.dataApp], + currentDirectory: "\(NSHomeDirectory())/dev/example/data" + ), + classified( + pid: 302, + kind: .python, + displayName: "(python3.12)", + project: "(python3.12)", + command: "(python3.12)", + cpu: 0, + memory: 12_000_000, + currentDirectory: nil + ), + classified( + pid: 303, + kind: .swift, + displayName: "CalendarThumbnailExtension Launcher", + project: "CalendarThumbnailExtension Launcher", + command: "/System/Library/ExtensionKit/Extensions/CalendarThumbnailExtension.appex", + cpu: 0, + memory: 10_000_000, + currentDirectory: "/System/Library/ExtensionKit/Extensions" + ) + ] + + let workflows = ProcessIntelligence.workflows(for: items) + + XCTAssertTrue(workflows.contains { $0.title == "Data App" }) + XCTAssertFalse(workflows.contains { $0.title.contains("Data Data") }) + XCTAssertFalse(workflows.contains { $0.title.contains("python3.12") }) + XCTAssertFalse(workflows.contains { $0.title.contains("CalendarThumbnail") }) + } + + func testRejectsBrowserRendererCommandsAsFocusWorkflows() { + let command = "/Applications/Google Chrome.app/Contents/Frameworks/Google Chrome Helper.app/Contents/MacOS/Google Chrome Helper --type=renderer --origin-trial-disabled-features=CanvasTextNG --lang=en-US --renderer-client-id=3645" + let items = [ + classified(pid: 501, kind: .browser, displayName: "Google Chrome Helper", project: "Google Chrome Helper", command: command, cpu: 45, memory: 2_000_000_000, currentDirectory: "/"), + classified(pid: 502, kind: .browser, displayName: "Google Chrome Helper", project: "Google Chrome Helper", command: command + " --renderer-client-id=3646", cpu: 30, memory: 1_500_000_000, currentDirectory: "/") + ] + + let workflows = ProcessIntelligence.workflows(for: items) + + XCTAssertTrue(workflows.isEmpty) + XCTAssertFalse(workflows.contains { $0.title.contains("renderer") || $0.title.count > 72 }) + } + + func testCommandPathCandidateBuildsADevelopmentWorkflow() { + let home = NSHomeDirectory() + let command = "node \(home)/dev/example/devscope/node_modules/.bin/vite --host 127.0.0.1" + let item = ClassifiedDevProcess( + process: DevProcess( + pid: 503, + parentPID: 1, + executable: "node", + command: command, + currentDirectory: nil, + resourceUsage: DevProcessResourceUsage(cpuPercent: 0, residentMemoryBytes: 0, elapsedTime: "00:01") + ), + classification: DevProcessClassification( + kind: .javascript, + displayName: "vite", + projectHint: "devscope", + tags: [] + ) + ) + + XCTAssertTrue(ProcessIntelligence.workflows(for: [item]).contains { $0.title == "DevScope Web" }) + } + + func testConfiguredOwnerDirectoryKeepsDeepSiblingRepositoriesDistinct() { + let home = NSHomeDirectory() + let items = [ + classified( + pid: 504, + kind: .javascript, + displayName: "vite", + project: "fallback", + command: "pnpm vite", + currentDirectory: "\(home)/dev/acme-labs/alpha-app/feature-one/task" + ), + classified( + pid: 505, + kind: .javascript, + displayName: "vite", + project: "fallback", + command: "pnpm vite", + currentDirectory: "\(home)/dev/acme-labs/beta-app/feature-two/task" + ), + ] + + XCTAssertEqual( + ProcessIntelligence.workflows( + for: items, + workspaceOwnerComponents: ["acme-labs"] + ).map(\.title), + ["Alpha App Web", "Beta App Web"] + ) + } + + func testDirectProjectNestedDirectoryKeepsRepositoryIdentity() { + let item = classified( + pid: 506, + kind: .javascript, + displayName: "vite", + project: "devscope", + command: "pnpm vite", + currentDirectory: "\(NSHomeDirectory())/dev/devscope/feature-one" + ) + + XCTAssertEqual( + ProcessIntelligence.workflows(for: [item]).map(\.title), + ["DevScope Web"] + ) + } + + func testLexicalAbsolutePathNormalizationDoesNotDependOnFilesystemState() { + let home = NSHomeDirectory() + + XCTAssertEqual( + ProcessIntelligence.lexicallyNormalizedAbsolutePath( + "\(home)//dev/./example/../devscope/" + ), + "\(home)/dev/devscope" + ) + XCTAssertEqual( + ProcessIntelligence.lexicallyNormalizedAbsolutePath("/Users/example/../../tmp"), + "/tmp" + ) + XCTAssertNil(ProcessIntelligence.lexicallyNormalizedAbsolutePath("../../tmp")) + XCTAssertNil(ProcessIntelligence.lexicallyNormalizedAbsolutePath("/../../tmp")) + } + + func testLexicalProjectPathsPreserveDevRootsAndRejectHomeEscape() throws { + let home = NSHomeDirectory() + let homeName = try XCTUnwrap(home.split(separator: "/").last.map(String.init)) + let items = [ + classified( + pid: 550, + kind: .javascript, + displayName: "vite", + project: "fallback", + command: "pnpm vite", + currentDirectory: "\(home)//dev/./example/scratch/../devscope//" + ), + classified( + pid: 551, + kind: .javascript, + displayName: "vite", + project: "escaped", + command: "pnpm vite", + currentDirectory: "\(home)/dev/../../\(homeName)/dev/example/escaped" + ), + classified( + pid: 552, + kind: .javascript, + displayName: "vite", + project: "nested", + command: "pnpm vite", + currentDirectory: "\(home)/Downloads/../Documents/dev/nested" + ), + ] + + XCTAssertEqual(ProcessIntelligence.workflows(for: items).map(\.title), ["DevScope Web"]) + } + + func testRejectsHomeLibraryPathsAsDevelopmentWorkflows() { + let directory = "\(NSHomeDirectory())/Library/Application Support/Acme Helper" + let items = [ + classified( + pid: 601, + kind: .javascript, + displayName: "helper", + project: "Acme Helper", + command: "/usr/bin/node helper.js", + currentDirectory: directory + ), + classified( + pid: 602, + kind: .javascript, + displayName: "helper", + project: "Acme Helper", + command: "/usr/bin/node helper.js --worker", + currentDirectory: directory + ), + ] + + XCTAssertTrue(ProcessIntelligence.workflows(for: items).isEmpty) + } + + func testAcceptsKnownDevelopmentRootsAndSkipsNestedRootBuckets() { + let home = NSHomeDirectory() + let fixtures = [ + ("dev/apps/meeting_vault", "Meeting Vault Web"), + ("apps/clip_vault", "Clip Vault Web"), + ("projects/alpha", "Alpha Web"), + ("workspaces/beta", "Beta Web"), + ("source/gamma", "Gamma Web"), + ("src/delta", "Delta Web"), + ] + let items = fixtures.enumerated().map { index, fixture in + classified( + pid: Int32(620 + index), + kind: .javascript, + displayName: "vite", + project: "fallback-\(index)", + command: "pnpm vite", + currentDirectory: "\(home)/\(fixture.0)" + ) + } + + let titles = Set(ProcessIntelligence.workflows(for: items).map(\.title)) + + XCTAssertEqual(titles, Set(fixtures.map(\.1))) + } + + func testRejectsDevelopmentMarkerNestedUnderDownloads() { + let item = classified( + pid: 640, + kind: .javascript, + displayName: "vite", + project: "helper", + command: "pnpm vite", + currentDirectory: "\(NSHomeDirectory())/Downloads/dev/helper" + ) + + XCTAssertTrue(ProcessIntelligence.workflows(for: [item]).isEmpty) + } + + func testRejectsProjectsMarkerNestedUnderDesktop() { + let item = classified( + pid: 641, + kind: .javascript, + displayName: "vite", + project: "tool", + command: "pnpm vite", + currentDirectory: "\(NSHomeDirectory())/Desktop/projects/tool" + ) + + XCTAssertTrue(ProcessIntelligence.workflows(for: [item]).isEmpty) + } + + func testRejectsCodexMarkerNestedUnderDocuments() { + let item = classified( + pid: 642, + kind: .mcp, + displayName: "MCP server", + project: "codex", + command: "node server.mjs", + currentDirectory: "\(NSHomeDirectory())/Documents/.codex/plugins/tool" + ) + + XCTAssertTrue(ProcessIntelligence.workflows(for: [item]).isEmpty) + } + + func testQuotedCommandPathWithSpacesBuildsADevelopmentWorkflow() { + let home = NSHomeDirectory() + let command = "node \"\(home)/dev/My Great App/node_modules/.bin/vite\" --host 127.0.0.1" + let item = ClassifiedDevProcess( + process: DevProcess( + pid: 603, + parentPID: 1, + executable: "node", + command: command, + resourceUsage: DevProcessResourceUsage(cpuPercent: 0, residentMemoryBytes: 0, elapsedTime: "00:01") + ), + classification: DevProcessClassification( + kind: .javascript, + displayName: "vite", + projectHint: "fallback", + tags: [] + ) + ) + + XCTAssertTrue( + ProcessIntelligence.workflows(for: [item]).contains { $0.title == "My Great App Web" } + ) + } + + func testRejectsMalformedQuotedAssignmentsAndRawCommandIdentifiers() { + let home = NSHomeDirectory() + let items = [ + classifiedWithoutCurrentDirectory( + pid: 604, + command: "node \"\(home)/dev/alpha/server.js" + ), + classifiedWithoutCurrentDirectory( + pid: 605, + command: "node PROJECT=\(home)/dev/beta/server.js" + ), + classifiedWithoutCurrentDirectory( + pid: 606, + command: "node devscope-worker" + ), + classifiedWithoutCurrentDirectory( + pid: 607, + command: "node \"\(home)/dev/gamma/server.js\"suffix" + ), + ] + + XCTAssertTrue(ProcessIntelligence.workflows(for: items).isEmpty) + } + + func testCapsWorkflowsWhilePreservingAIMLAggregates() { + var items = (0..<15).map { index in + classified( + pid: Int32(700 + index), + kind: .javascript, + displayName: "vite", + project: "project-\(index)", + command: "pnpm vite" + ) + } + items.append( + classified( + pid: 799, + kind: .ai, + displayName: "Ollama server", + project: "research", + command: "ollama serve", + tags: [.llmServer] + ) + ) + + let workflows = ProcessIntelligence.workflows(for: items) + + XCTAssertLessThanOrEqual(workflows.count, 12) + XCTAssertTrue(workflows.contains { $0.id == "workflow:ai-ml-lab" }) + XCTAssertTrue(workflows.contains { $0.id == "workflow:local-llm-stack" }) + XCTAssertEqual(workflows.map(\.id), ProcessIntelligence.workflows(for: items).map(\.id)) + } + + func testProcessInsightExplainsRoleAndSafeAction() { + let item = classified(pid: 101, kind: .python, displayName: "torchrun train", project: "research", command: "torchrun train_llama.py", cpu: 92, memory: 900_000_000, tags: [.training, .llm]) + let workflow = ProcessIntelligence.workflows(for: [item]).first + let family = ProcessFamilySummary(parentPID: 1, childCount: 1, descendantCount: 2) + + let insight = ProcessIntelligence.insight( + for: item, + workflow: workflow, + familySummary: family, + metricHistory: [] + ) + + XCTAssertEqual(insight.title, "Training workload") + XCTAssertEqual(insight.role, "Training or fine-tuning workload") + XCTAssertTrue(insight.resourceBehavior.contains("High CPU")) + XCTAssertTrue(insight.safeAction.contains("TERM Tree")) + } + + func testProtectedProcessInsightNeverRecommendsTermTree() { + let item = classified( + pid: 1, + kind: .systemService, + displayName: "launchd", + project: "system", + command: "/sbin/launchd" + ) + let family = ProcessFamilySummary(parentPID: 0, childCount: 700, descendantCount: 890) + + let insight = ProcessIntelligence.insight( + for: item, + workflow: nil, + familySummary: family, + metricHistory: [], + actionDecision: .protected(reason: "macOS launch infrastructure is protected") + ) + + XCTAssertEqual(insight.safeAction, "Protected: macOS launch infrastructure is protected.") + XCTAssertFalse(insight.safeAction.contains("TERM")) + } + + private func classified( + pid: Int32, + kind: DevRuntimeKind, + displayName: String, + project: String, + command: String, + cpu: Double = 0, + memory: Int64 = 0, + tags: [DevProcessTag] = [], + currentDirectory: String? = nil + ) -> ClassifiedDevProcess { + ClassifiedDevProcess( + process: DevProcess( + pid: pid, + parentPID: 1, + executable: command.split(separator: " ").first.map(String.init) ?? command, + command: command, + currentDirectory: currentDirectory ?? "\(NSHomeDirectory())/dev/example/\(project)", + resourceUsage: DevProcessResourceUsage(cpuPercent: cpu, residentMemoryBytes: memory, elapsedTime: "00:01") + ), + classification: DevProcessClassification( + kind: kind, + displayName: displayName, + projectHint: project, + tags: tags + ) + ) + } + + private func classifiedWithoutCurrentDirectory( + pid: Int32, + command: String + ) -> ClassifiedDevProcess { + ClassifiedDevProcess( + process: DevProcess( + pid: pid, + parentPID: 1, + executable: "node", + command: command, + resourceUsage: DevProcessResourceUsage(cpuPercent: 0, residentMemoryBytes: 0, elapsedTime: "00:01") + ), + classification: DevProcessClassification( + kind: .javascript, + displayName: "node", + projectHint: "fallback", + tags: [] + ) + ) + } +} diff --git a/Tests/DevScopeCoreTests/ProcessKillerTests.swift b/Tests/DevScopeCoreTests/ProcessKillerTests.swift new file mode 100644 index 0000000..9ec5dbd --- /dev/null +++ b/Tests/DevScopeCoreTests/ProcessKillerTests.swift @@ -0,0 +1,538 @@ +import Darwin +import Foundation +import XCTest +@testable import DevScopeCore + +final class ProcessKillerTests: XCTestCase { + func testTerminateSignalsWhenLiveIdentityMatchesExpectedBirth() throws { + let process = process(pid: 42, birthToken: token(100)) + let recorder = SignalRecorder() + let killer = ProcessKiller( + identityResolver: { _ in .identity(ProcessIdentity(process: process)) }, + signalSender: { pid, signal in recorder.record(pid: pid, signal: signal) } + ) + + let targets = try killer.terminate(classified(process), currentProcessID: 9_000) + + XCTAssertEqual(targets, [ProcessIdentity(process: process)]) + XCTAssertEqual(recorder.signals, [RecordedSignal(pid: 42, signal: SIGTERM)]) + } + + func testForceTerminateUsesKillSignalAfterIdentityValidation() throws { + let process = process(pid: 42, birthToken: token(100)) + let recorder = SignalRecorder() + let killer = matchingKiller(for: [process], recorder: recorder) + + let targets = try killer.forceTerminate( + classified(process), + currentProcessID: 9_000 + ) + + XCTAssertEqual(targets, [ProcessIdentity(process: process)]) + XCTAssertEqual(recorder.signals, [RecordedSignal(pid: 42, signal: SIGKILL)]) + } + + func testTerminateRejectsMissingExpectedBirthWithoutSendingSignal() { + let process = process(pid: 42, birthToken: nil) + let recorder = SignalRecorder() + let killer = ProcessKiller( + identityResolver: { _ in .identity(ProcessIdentity(process: process)) }, + signalSender: { pid, signal in recorder.record(pid: pid, signal: signal) } + ) + + XCTAssertThrowsError( + try killer.terminate(classified(process), currentProcessID: 9_000) + ) { error in + XCTAssertEqual(error as? ProcessKillError, .expectedIdentityUnavailable(pid: 42)) + } + XCTAssertTrue(recorder.signals.isEmpty) + } + + func testTerminateRejectsLiveIdentityWithMissingBirthWithoutSendingSignal() { + let process = process(pid: 42, birthToken: token(100)) + let liveWithoutBirth = self.process(pid: 42, birthToken: nil) + let recorder = SignalRecorder() + let killer = ProcessKiller( + identityResolver: { _ in .identity(ProcessIdentity(process: liveWithoutBirth)) }, + signalSender: { pid, signal in recorder.record(pid: pid, signal: signal) } + ) + + XCTAssertThrowsError( + try killer.terminate(classified(process), currentProcessID: 9_000) + ) { error in + XCTAssertEqual(error as? ProcessKillError, .liveIdentityUnavailable(pid: 42)) + } + XCTAssertTrue(recorder.signals.isEmpty) + } + + func testTerminateRejectsReusedPIDWithoutSendingSignal() { + let process = process(pid: 42, birthToken: token(100)) + let replacement = self.process(pid: 42, birthToken: token(101)) + let recorder = SignalRecorder() + let killer = ProcessKiller( + identityResolver: { _ in .identity(ProcessIdentity(process: replacement)) }, + signalSender: { pid, signal in recorder.record(pid: pid, signal: signal) } + ) + + XCTAssertThrowsError( + try killer.terminate(classified(process), currentProcessID: 9_000) + ) { error in + XCTAssertEqual(error as? ProcessKillError, .identityMismatch(pid: 42)) + } + XCTAssertTrue(recorder.signals.isEmpty) + } + + func testTerminateRejectsTargetThatEndedAfterConfirmationWithoutSendingSignal() { + let process = process(pid: 42, birthToken: token(100)) + let recorder = SignalRecorder() + let killer = ProcessKiller( + identityResolver: { _ in .notRunning }, + signalSender: { pid, signal in recorder.record(pid: pid, signal: signal) } + ) + + XCTAssertThrowsError( + try killer.terminate(classified(process), currentProcessID: 9_000) + ) { error in + XCTAssertEqual(error as? ProcessKillError, .targetNotRunning(pid: 42)) + } + XCTAssertTrue(recorder.signals.isEmpty) + } + + func testTerminateRechecksIdentityAfterGlobalPreflightBeforeSendingSignal() { + let process = process(pid: 42, birthToken: token(100)) + let replacement = self.process(pid: 42, birthToken: token(101)) + let resolver = IdentityResolutionScript(resolutions: [ + 42: [ + .identity(ProcessIdentity(process: process)), + .identity(ProcessIdentity(process: replacement)), + ] + ]) + let recorder = SignalRecorder() + let killer = ProcessKiller( + identityResolver: resolver.resolve, + signalSender: { pid, signal in recorder.record(pid: pid, signal: signal) } + ) + + XCTAssertThrowsError( + try killer.terminate(classified(process), currentProcessID: 9_000) + ) { error in + XCTAssertEqual(error as? ProcessKillError, .identityMismatch(pid: 42)) + } + XCTAssertTrue(recorder.signals.isEmpty) + } + + func testTerminateTreeSignalsDescendantsBeforeTheirAncestors() throws { + let root = process(pid: 10, parentPID: 1, birthToken: token(10)) + let child = process(pid: 11, parentPID: 10, birthToken: token(11)) + let grandchild = process(pid: 12, parentPID: 11, birthToken: token(12)) + let processes = [root, child, grandchild] + let liveByPID = Dictionary(uniqueKeysWithValues: processes.map { ($0.pid, $0) }) + let recorder = SignalRecorder() + let killer = ProcessKiller( + identityResolver: { pid in + liveByPID[pid].map { .identity(ProcessIdentity(process: $0)) } ?? .notRunning + }, + signalSender: { pid, signal in recorder.record(pid: pid, signal: signal) } + ) + + let targets = try killer.terminateTree( + root: classified(root), + processes: processes, + classifiedProcesses: processes.map { classified($0) }, + currentProcessID: 9_000 + ) + + XCTAssertEqual(targets.map(\.pid), [12, 11, 10]) + XCTAssertEqual( + recorder.signals, + [12, 11, 10].map { RecordedSignal(pid: $0, signal: SIGTERM) } + ) + } + + func testForceTerminateTreeSignalsDescendantsBeforeAncestorsWithKill() throws { + let root = process(pid: 10, parentPID: 1, birthToken: token(10)) + let child = process(pid: 11, parentPID: 10, birthToken: token(11)) + let grandchild = process(pid: 12, parentPID: 11, birthToken: token(12)) + let processes = [root, child, grandchild] + let recorder = SignalRecorder() + let killer = matchingKiller(for: processes, recorder: recorder) + + let targets = try killer.forceTerminateTree( + root: classified(root), + processes: processes, + classifiedProcesses: processes.map { classified($0) }, + currentProcessID: 9_000 + ) + + XCTAssertEqual(targets.map(\.pid), [12, 11, 10]) + XCTAssertEqual( + recorder.signals, + [12, 11, 10].map { RecordedSignal(pid: $0, signal: SIGKILL) } + ) + } + + func testTerminateTreeRejectsDevScopeDescendantWithoutSendingAnySignal() { + let root = process(pid: 10, parentPID: 1, birthToken: token(10)) + let devScope = process( + pid: 42, + parentPID: 10, + executable: "/Applications/DevScope.app/Contents/MacOS/DevScope", + birthToken: token(42) + ) + let processes = [root, devScope] + let recorder = SignalRecorder() + let killer = matchingKiller(for: processes, recorder: recorder) + + XCTAssertThrowsError( + try killer.terminateTree( + root: classified(root), + processes: processes, + classifiedProcesses: [classified(root), classified(devScope, kind: .macApp)], + currentProcessID: 42 + ) + ) { error in + XCTAssertEqual( + error as? ProcessKillError, + .protectedTarget(pid: 42, reason: "DevScope cannot terminate itself") + ) + } + XCTAssertTrue(recorder.signals.isEmpty) + } + + func testTerminateTreeRejectsCriticalSystemDescendantWithoutSendingAnySignal() { + let root = process(pid: 10, parentPID: 1, birthToken: token(10)) + let windowServer = process( + pid: 88, + parentPID: 10, + executable: "/System/Library/CoreServices/WindowServer", + birthToken: token(88) + ) + let processes = [root, windowServer] + let recorder = SignalRecorder() + let killer = matchingKiller(for: processes, recorder: recorder) + + XCTAssertThrowsError( + try killer.terminateTree( + root: classified(root), + processes: processes, + classifiedProcesses: [classified(root), classified(windowServer, kind: .systemService)], + currentProcessID: 9_000 + ) + ) { error in + XCTAssertEqual( + error as? ProcessKillError, + .protectedTarget( + pid: 88, + reason: "Critical macOS system infrastructure is protected" + ) + ) + } + XCTAssertTrue(recorder.signals.isEmpty) + } + + func testTerminateTreeRejectsUnclassifiedDescendantWithoutSendingAnySignal() { + let root = process(pid: 10, parentPID: 1, birthToken: token(10)) + let child = process(pid: 11, parentPID: 10, birthToken: token(11)) + let processes = [root, child] + let recorder = SignalRecorder() + let killer = matchingKiller(for: processes, recorder: recorder) + + XCTAssertThrowsError( + try killer.terminateTree( + root: classified(root), + processes: processes, + classifiedProcesses: [classified(root)], + currentProcessID: 9_000 + ) + ) { error in + XCTAssertEqual( + error as? ProcessKillError, + .targetClassificationUnavailable(pid: 11) + ) + } + XCTAssertTrue(recorder.signals.isEmpty) + } + + func testTerminateTreeRejectsDescendantWithUnknownBirthWithoutSendingAnySignal() { + let root = process(pid: 10, parentPID: 1, birthToken: token(10)) + let child = process(pid: 11, parentPID: 10, birthToken: nil) + let processes = [root, child] + let recorder = SignalRecorder() + let killer = matchingKiller(for: processes, recorder: recorder) + + XCTAssertThrowsError( + try killer.terminateTree( + root: classified(root), + processes: processes, + classifiedProcesses: processes.map { classified($0) }, + currentProcessID: 9_000 + ) + ) { error in + XCTAssertEqual( + error as? ProcessKillError, + .expectedIdentityUnavailable(pid: 11) + ) + } + XCTAssertTrue(recorder.signals.isEmpty) + } + + func testTerminateTreePreflightRejectsOneReusedDescendantWithoutSendingAnySignal() { + let root = process(pid: 10, parentPID: 1, birthToken: token(10)) + let firstChild = process(pid: 11, parentPID: 10, birthToken: token(11)) + let reusedChild = process(pid: 12, parentPID: 10, birthToken: token(12)) + let processes = [root, firstChild, reusedChild] + let replacement = process(pid: 11, parentPID: 10, birthToken: token(99)) + let liveByPID: [Int32: DevProcess] = [10: root, 11: replacement, 12: reusedChild] + let recorder = SignalRecorder() + let killer = ProcessKiller( + identityResolver: { pid in + liveByPID[pid].map { .identity(ProcessIdentity(process: $0)) } ?? .notRunning + }, + signalSender: { pid, signal in recorder.record(pid: pid, signal: signal) } + ) + + XCTAssertThrowsError( + try killer.terminateTree( + root: classified(root), + processes: processes, + classifiedProcesses: processes.map { classified($0) }, + currentProcessID: 9_000 + ) + ) { error in + XCTAssertEqual(error as? ProcessKillError, .identityMismatch(pid: 11)) + } + XCTAssertTrue(recorder.signals.isEmpty) + } + + func testTerminateTreeReportsAlreadySignaledDescendantWhenLaterIdentityChanges() { + let root = process(pid: 10, parentPID: 1, birthToken: token(10)) + let child = process(pid: 11, parentPID: 10, birthToken: token(11)) + let grandchild = process(pid: 12, parentPID: 11, birthToken: token(12)) + let replacement = process(pid: 11, parentPID: 10, birthToken: token(99)) + let processes = [root, child, grandchild] + let resolver = IdentityResolutionScript(resolutions: [ + 10: [.identity(ProcessIdentity(process: root))], + 11: [ + .identity(ProcessIdentity(process: child)), + .identity(ProcessIdentity(process: replacement)), + ], + 12: [ + .identity(ProcessIdentity(process: grandchild)), + .identity(ProcessIdentity(process: grandchild)), + ], + ]) + let recorder = SignalRecorder() + let killer = ProcessKiller( + identityResolver: resolver.resolve, + signalSender: { pid, signal in recorder.record(pid: pid, signal: signal) } + ) + + XCTAssertThrowsError( + try killer.terminateTree( + root: classified(root), + processes: processes, + classifiedProcesses: processes.map { classified($0) }, + currentProcessID: 9_000 + ) + ) { error in + guard let failure = error as? ProcessSignalExecutionFailure else { + return XCTFail("Expected structured partial execution failure, got \(error)") + } + XCTAssertEqual( + failure.signaledIdentities, + [ProcessIdentity(process: grandchild)] + ) + XCTAssertEqual(failure.failedTarget, ProcessIdentity(process: child)) + XCTAssertEqual( + failure.underlyingError as? ProcessKillError, + .identityMismatch(pid: 11) + ) + XCTAssertEqual( + failure.localizedDescription, + "Signaled 1 process before PID 11 failed: PID 11 no longer belongs to the selected process" + ) + } + XCTAssertEqual(recorder.signals, [RecordedSignal(pid: 12, signal: SIGTERM)]) + } + + func testTerminateTreeReportsAlreadySignaledDescendantWhenLaterSenderFails() { + let root = process(pid: 10, parentPID: 1, birthToken: token(10)) + let child = process(pid: 11, parentPID: 10, birthToken: token(11)) + let grandchild = process(pid: 12, parentPID: 11, birthToken: token(12)) + let processes = [root, child, grandchild] + let liveByPID = Dictionary(uniqueKeysWithValues: processes.map { ($0.pid, $0) }) + let recorder = SignalRecorder() + let senderFailure = ProcessKillError.signalFailed(pid: 11, errnoCode: EPERM) + let killer = ProcessKiller( + identityResolver: { pid in + liveByPID[pid].map { .identity(ProcessIdentity(process: $0)) } ?? .notRunning + }, + signalSender: { pid, signal in + guard pid != 11 else { throw senderFailure } + recorder.record(pid: pid, signal: signal) + } + ) + + XCTAssertThrowsError( + try killer.terminateTree( + root: classified(root), + processes: processes, + classifiedProcesses: processes.map { classified($0) }, + currentProcessID: 9_000 + ) + ) { error in + guard let failure = error as? ProcessSignalExecutionFailure else { + return XCTFail("Expected structured partial execution failure, got \(error)") + } + XCTAssertEqual( + failure.signaledIdentities, + [ProcessIdentity(process: grandchild)] + ) + XCTAssertEqual(failure.failedTarget, ProcessIdentity(process: child)) + XCTAssertEqual(failure.underlyingError as? ProcessKillError, senderFailure) + } + XCTAssertEqual(recorder.signals, [RecordedSignal(pid: 12, signal: SIGTERM)]) + } + + func testTerminateTreeFirstSenderFailureStaysOrdinaryAndStopsLaterTargets() { + let root = process(pid: 10, parentPID: 1, birthToken: token(10)) + let child = process(pid: 11, parentPID: 10, birthToken: token(11)) + let grandchild = process(pid: 12, parentPID: 11, birthToken: token(12)) + let processes = [root, child, grandchild] + let liveByPID = Dictionary(uniqueKeysWithValues: processes.map { ($0.pid, $0) }) + let recorder = SignalRecorder() + let senderFailure = ProcessKillError.signalFailed(pid: 12, errnoCode: EPERM) + let killer = ProcessKiller( + identityResolver: { pid in + liveByPID[pid].map { .identity(ProcessIdentity(process: $0)) } ?? .notRunning + }, + signalSender: { pid, signal in + guard pid != 12 else { throw senderFailure } + recorder.record(pid: pid, signal: signal) + } + ) + + XCTAssertThrowsError( + try killer.terminateTree( + root: classified(root), + processes: processes, + classifiedProcesses: processes.map { classified($0) }, + currentProcessID: 9_000 + ) + ) { error in + XCTAssertEqual(error as? ProcessKillError, senderFailure) + XCTAssertFalse(error is ProcessSignalExecutionFailure) + } + XCTAssertTrue(recorder.signals.isEmpty) + } + + func testTerminateTreeRejectsStaleDescendantClassificationWithoutSendingAnySignal() { + let root = process(pid: 10, parentPID: 1, birthToken: token(10)) + let child = process(pid: 11, parentPID: 10, birthToken: token(11)) + let staleClassifiedChild = process(pid: 11, parentPID: 10, birthToken: token(99)) + let processes = [root, child] + let recorder = SignalRecorder() + let killer = matchingKiller(for: processes, recorder: recorder) + + XCTAssertThrowsError( + try killer.terminateTree( + root: classified(root), + processes: processes, + classifiedProcesses: [classified(root), classified(staleClassifiedChild)], + currentProcessID: 9_000 + ) + ) { error in + XCTAssertEqual( + error as? ProcessKillError, + .targetClassificationMismatch(pid: 11) + ) + } + XCTAssertTrue(recorder.signals.isEmpty) + } + + private func process( + pid: Int32, + parentPID: Int32 = 1, + executable: String = "/opt/homebrew/bin/node", + birthToken: ProcessBirthToken? + ) -> DevProcess { + DevProcess( + pid: pid, + parentPID: parentPID, + executable: executable, + command: "node server.js", + birthToken: birthToken + ) + } + + private func classified( + _ process: DevProcess, + kind: DevRuntimeKind = .javascript + ) -> ClassifiedDevProcess { + ClassifiedDevProcess( + process: process, + classification: DevProcessClassification( + kind: kind, + displayName: "Node", + projectHint: nil + ) + ) + } + + private func token(_ microseconds: UInt64) -> ProcessBirthToken { + ProcessBirthToken(seconds: 1_000, microseconds: microseconds) + } + + private func matchingKiller( + for processes: [DevProcess], + recorder: SignalRecorder + ) -> ProcessKiller { + let liveByPID = Dictionary(uniqueKeysWithValues: processes.map { ($0.pid, $0) }) + return ProcessKiller( + identityResolver: { pid in + liveByPID[pid].map { .identity(ProcessIdentity(process: $0)) } ?? .notRunning + }, + signalSender: { pid, signal in recorder.record(pid: pid, signal: signal) } + ) + } +} + +private struct RecordedSignal: Equatable { + let pid: Int32 + let signal: Int32 +} + +private final class SignalRecorder: @unchecked Sendable { + private let lock = NSLock() + private var recordedSignals: [RecordedSignal] = [] + + var signals: [RecordedSignal] { + lock.withLock { recordedSignals } + } + + func record(pid: Int32, signal: Int32) { + lock.withLock { + recordedSignals.append(RecordedSignal(pid: pid, signal: signal)) + } + } +} + +private final class IdentityResolutionScript: @unchecked Sendable { + private let lock = NSLock() + private var resolutions: [Int32: [ProcessLiveIdentityResolution]] + + init(resolutions: [Int32: [ProcessLiveIdentityResolution]]) { + self.resolutions = resolutions + } + + func resolve(pid: Int32) -> ProcessLiveIdentityResolution { + lock.withLock { + guard var scripted = resolutions[pid], !scripted.isEmpty else { + return .notRunning + } + let resolution = scripted.removeFirst() + resolutions[pid] = scripted + return resolution + } + } +} diff --git a/Tests/DevScopeCoreTests/ProcessMetricHistoryStoreTests.swift b/Tests/DevScopeCoreTests/ProcessMetricHistoryStoreTests.swift new file mode 100644 index 0000000..c63a90d --- /dev/null +++ b/Tests/DevScopeCoreTests/ProcessMetricHistoryStoreTests.swift @@ -0,0 +1,213 @@ +import XCTest +@testable import DevScopeCore + +final class ProcessMetricHistoryStoreTests: XCTestCase { + func testRecordsMetricSamplesForAProcess() throws { + var store = ProcessMetricHistoryStore(limit: 3) + let timestamp = Date(timeIntervalSince1970: 100) + + store.record( + processes: [process(pid: 700, cpu: 12, memory: 256)], + gpuMetric: DevGPUMetric(utilizationPercent: 34), + timestamp: timestamp + ) + + let sample = try XCTUnwrap(store.history(for: 700).first) + XCTAssertEqual(sample.timestamp, timestamp) + XCTAssertEqual(sample.cpuPercent, 12) + XCTAssertEqual(sample.residentMemoryBytes, 256) + XCTAssertEqual(sample.gpuPercent, 34) + } + + func testCapsEachProcessHistoryAtTheConfiguredLimit() { + var store = ProcessMetricHistoryStore(limit: 2) + + for value in 1...3 { + store.record( + processes: [process(pid: 700, cpu: Double(value), memory: Int64(value))], + gpuMetric: nil, + timestamp: Date(timeIntervalSince1970: TimeInterval(value)) + ) + } + + XCTAssertEqual(store.history(for: 700).map(\.cpuPercent), [2, 3]) + } + + func testDropsHistoryForInactiveProcesses() { + var store = ProcessMetricHistoryStore(limit: 3) + store.record( + processes: [ + process(pid: 700, cpu: 1, memory: 100), + process(pid: 701, cpu: 2, memory: 200), + ], + gpuMetric: nil, + timestamp: Date(timeIntervalSince1970: 1) + ) + + store.record( + processes: [process(pid: 701, cpu: 3, memory: 300)], + gpuMetric: nil, + timestamp: Date(timeIntervalSince1970: 2) + ) + + XCTAssertTrue(store.history(for: 700).isEmpty) + XCTAssertEqual(store.history(for: 701).map(\.cpuPercent), [2, 3]) + } + + func testResetsHistoryWhenAPIDIsRecycled() { + var store = ProcessMetricHistoryStore(limit: 3) + store.record( + processes: [ + process( + pid: 700, + cpu: 1, + memory: 100, + birthToken: ProcessBirthToken(seconds: 1_000, microseconds: 100) + ) + ], + gpuMetric: nil, + timestamp: Date(timeIntervalSince1970: 1) + ) + + store.record( + processes: [ + process( + pid: 700, + cpu: 2, + memory: 200, + birthToken: ProcessBirthToken(seconds: 1_000, microseconds: 101) + ) + ], + gpuMetric: nil, + timestamp: Date(timeIntervalSince1970: 2) + ) + + XCTAssertEqual(store.history(for: 700).map(\.cpuPercent), [2]) + } + + func testResetsRecycledPIDBeforeNewMetricsArrive() { + var store = ProcessMetricHistoryStore(limit: 3) + store.record( + processes: [ + process( + pid: 700, + cpu: 1, + memory: 100, + birthToken: ProcessBirthToken(seconds: 1_000, microseconds: 100) + ) + ], + gpuMetric: nil, + timestamp: Date(timeIntervalSince1970: 1) + ) + + store.record( + processes: [ + processWithoutMetrics( + pid: 700, + birthToken: ProcessBirthToken(seconds: 1_000, microseconds: 101) + ) + ], + gpuMetric: nil, + timestamp: Date(timeIntervalSince1970: 2) + ) + + XCTAssertTrue(store.history(for: 700).isEmpty) + } + + func testResetsUnknownBirthHistoryWhenFallbackIdentityChanges() { + var store = ProcessMetricHistoryStore(limit: 3) + store.record( + processes: [process(pid: 700, cpu: 1, memory: 100)], + gpuMetric: nil, + timestamp: Date(timeIntervalSince1970: 1) + ) + + store.record( + processes: [ + process( + pid: 700, + cpu: 2, + memory: 200, + command: "worker --replacement" + ) + ], + gpuMetric: nil, + timestamp: Date(timeIntervalSince1970: 2) + ) + + XCTAssertEqual(store.history(for: 700).map(\.cpuPercent), [2]) + } + + func testRetainsHistoryWhenKnownBirthMetadataIsEnriched() { + var store = ProcessMetricHistoryStore(limit: 3) + let birthToken = ProcessBirthToken(seconds: 1_000, microseconds: 100) + store.record( + processes: [ + process( + pid: 700, + cpu: 1, + memory: 100, + executable: "node", + command: "node", + birthToken: birthToken + ) + ], + gpuMetric: nil, + timestamp: Date(timeIntervalSince1970: 1) + ) + + store.record( + processes: [ + process( + pid: 700, + cpu: 2, + memory: 200, + parentPID: 99, + executable: "/opt/homebrew/bin/node", + command: "/opt/homebrew/bin/node server.js", + birthToken: birthToken + ) + ], + gpuMetric: nil, + timestamp: Date(timeIntervalSince1970: 2) + ) + + XCTAssertEqual(store.history(for: 700).map(\.cpuPercent), [1, 2]) + } + + private func process( + pid: Int32, + cpu: Double, + memory: Int64, + parentPID: Int32 = 1, + executable: String = "worker", + command: String = "worker --serve", + birthToken: ProcessBirthToken? = nil + ) -> DevProcess { + DevProcess( + pid: pid, + parentPID: parentPID, + executable: executable, + command: command, + resourceUsage: DevProcessResourceUsage( + cpuPercent: cpu, + residentMemoryBytes: memory, + elapsedTime: "00:10" + ), + birthToken: birthToken + ) + } + + private func processWithoutMetrics( + pid: Int32, + birthToken: ProcessBirthToken + ) -> DevProcess { + DevProcess( + pid: pid, + parentPID: 1, + executable: "worker", + command: "worker --serve", + birthToken: birthToken + ) + } +} diff --git a/Tests/DevScopeCoreTests/ProcessPresentationTests.swift b/Tests/DevScopeCoreTests/ProcessPresentationTests.swift new file mode 100644 index 0000000..ce629bf --- /dev/null +++ b/Tests/DevScopeCoreTests/ProcessPresentationTests.swift @@ -0,0 +1,676 @@ +import XCTest + +@testable import DevScopeCore + +final class ProcessPresentationTests: XCTestCase { + func testFiltersByRuntimeAndSearchableFields() { + let items = sampleProcesses + + let filtered = ProcessPresentation.filtered( + items, + categoryID: DevRuntimeKind.javascript.id, + searchText: "web 3010" + ) + + XCTAssertEqual(filtered.map(\.process.pid), [101]) + } + + func testFiltersSavedCategoriesByStableIdentityKey() { + let items = sampleProcesses + let favoriteKey = ProcessPresentation.identityKey(for: items[1]) + let watchedKey = ProcessPresentation.identityKey(for: items[2]) + + XCTAssertEqual( + ProcessPresentation.filtered( + items, + categoryID: ProcessPresentation.favoritesCategoryID, + searchText: "", + favoriteKeys: [favoriteKey] + ).map(\.process.pid), + [102] + ) + XCTAssertEqual( + ProcessPresentation.filtered( + items, + categoryID: ProcessPresentation.watchedCategoryID, + searchText: "", + watchedKeys: [watchedKey] + ).map(\.process.pid), + [201] + ) + } + + func testEmptySavedKeysSkipIdentityHashing() { + var identityLookupCount = 0 + + let isSaved = ProcessPresentation.isSaved( + sampleProcesses[0], + in: [], + identityKeys: { _ in + identityLookupCount += 1 + return ["should-not-be-computed"] + } + ) + + XCTAssertFalse(isSaved) + XCTAssertEqual(identityLookupCount, 0) + } + + func testSavedCategorySurvivesWorkingDirectoryEnrichment() { + let initial = ClassifiedDevProcess( + process: DevProcess( + pid: 51963, + parentPID: 1, + executable: "/bin/sleep", + command: "/bin/sleep 317", + currentDirectory: nil + ), + classification: DevProcessClassification( + kind: .other, + displayName: "sleep", + projectHint: nil, + tags: [] + ) + ) + let enriched = ClassifiedDevProcess( + process: DevProcess( + pid: 51963, + parentPID: 1, + executable: "/bin/sleep", + command: "/bin/sleep 317", + currentDirectory: "/Users/example/dev/sample-app" + ), + classification: DevProcessClassification( + kind: .other, + displayName: "sleep", + projectHint: "devscope", + tags: [] + ) + ) + let savedKey = ProcessPresentation.identityKey(for: initial) + + let filtered = ProcessPresentation.filtered( + [enriched], + categoryID: ProcessPresentation.favoritesCategoryID, + searchText: "", + favoriteKeys: [savedKey] + ) + + XCTAssertEqual(filtered.map(\.process.pid), [51963]) + } + + func testSavedIdentityKeyDoesNotContainCommandOrWorkingDirectory() { + let item = ClassifiedDevProcess( + process: DevProcess( + pid: 98, + parentPID: 1, + executable: "/usr/local/bin/worker", + command: "worker --api-key sk-private-value", + currentDirectory: "/Users/example/Secret Project" + ), + classification: DevProcessClassification( + kind: .other, + displayName: "worker", + projectHint: "Secret Project", + tags: [] + ) + ) + + let key = ProcessPresentation.identityKey(for: item) + + XCTAssertTrue(key.hasPrefix("v2:")) + XCTAssertFalse(key.contains("sk-private-value")) + XCTAssertFalse(key.contains("Secret Project")) + XCTAssertFalse(key.contains("/Users/example")) + } + + func testSanitizedSavedIdentityKeysMigrateLegacyValuesWithoutRetainingRawData() { + let safeKey = "v2:" + String(repeating: "a", count: 64) + let legacyKey = + "worker\u{1F}/Users/example/Secret Project\u{1F}worker --api-key sk-private-value" + let keys = ProcessPresentation.sanitizedSavedIdentityKeys([ + safeKey, + legacyKey, + "v2:not-a-digest", + "malformed legacy value", + ]) + + XCTAssertEqual(keys.count, 2) + XCTAssertTrue(keys.contains(safeKey)) + XCTAssertTrue(keys.allSatisfy { $0.hasPrefix("v2:") }) + XCTAssertFalse(keys.joined().contains("sk-private-value")) + } + + func testRedactsSensitiveCommandValuesForExport() { + let command = """ + uvicorn app:app token=abc123 Authorization bearer secret-token password=hunter2 --api-key sk-test --client-secret client-value postgres://user:dbpass@localhost/db + """ + + let redacted = ProcessPresentation.redactedCommand(command) + + XCTAssertFalse(redacted.contains("abc123")) + XCTAssertFalse(redacted.contains("secret-token")) + XCTAssertFalse(redacted.contains("hunter2")) + XCTAssertFalse(redacted.contains("sk-test")) + XCTAssertFalse(redacted.contains("client-value")) + XCTAssertFalse(redacted.contains("dbpass")) + XCTAssertTrue(redacted.contains("token=")) + XCTAssertTrue(redacted.contains("bearer ")) + XCTAssertTrue(redacted.contains("password=")) + XCTAssertTrue(redacted.contains("--api-key ")) + XCTAssertTrue(redacted.contains("--client-secret ")) + XCTAssertTrue(redacted.contains("postgres://@localhost/db")) + } + + func testRedactsCommonShortCredentialFlags() { + let command = + "redis-cli -a hunter2; mysql -pdbsecret; curl -u user:curlsecret https://example.test" + + let redacted = ProcessPresentation.redactedCommand(command) + + XCTAssertFalse(redacted.contains("hunter2")) + XCTAssertFalse(redacted.contains("dbsecret")) + XCTAssertFalse(redacted.contains("curlsecret")) + } + + func testRedactsQuotedShortCredentialFlagValuesContainingSpaces() { + let command = + "redis-cli -a 'hunter 2'; mysql -p\"db secret\"; curl -u 'user:curl secret' https://example.test" + + let redacted = ProcessPresentation.redactedCommand(command) + + XCTAssertFalse(redacted.contains("hunter")) + XCTAssertFalse(redacted.contains("db secret")) + XCTAssertFalse(redacted.contains("curl secret")) + XCTAssertFalse(redacted.contains("2'")) + } + + func testRedactsReconstructedPSCredentialValuesThroughCommandBoundary() { + let commands = [ + "redis-cli -a hunter --tls-shaped secret;stillsecret", + "mysql -pdb --host-shaped secret;stillsecret", + "curl -u user:curl --header-shaped secret;stillsecret", + ] + + for command in commands { + let redacted = ProcessPresentation.redactedCommand(command) + + XCTAssertFalse(redacted.contains("hunter")) + XCTAssertFalse(redacted.contains("--tls-shaped")) + XCTAssertFalse(redacted.contains("--host-shaped")) + XCTAssertFalse(redacted.contains("--header-shaped")) + XCTAssertFalse(redacted.contains("stillsecret")) + XCTAssertEqual(redacted.components(separatedBy: "").count - 1, 1) + } + } + + func testRedactsCurlLongCredentialOptionsThroughEndOfRow() { + let commands = [ + "curl --user analyst:SYNTH_USER_SECRET --next-shaped value", + "curl --user=analyst:SYNTH_USER_EQUALS_SECRET;stillsecret", + "curl --proxy-user relay:SYNTH_PROXY_SECRET --proxy-shaped value", + "curl --proxy-user=relay:SYNTH_PROXY_EQUALS_SECRET;stillsecret", + ] + + for command in commands { + let redacted = ProcessPresentation.redactedCommand(command) + + XCTAssertFalse(redacted.contains("SYNTH_")) + XCTAssertFalse(redacted.contains("stillsecret")) + XCTAssertFalse(redacted.contains("--next-shaped")) + XCTAssertFalse(redacted.contains("--proxy-shaped")) + XCTAssertEqual(redacted.components(separatedBy: "").count - 1, 1) + } + } + + func testRedactsColonDelimitedCredentialHeaders() { + let cases = [ + ( + command: "curl -H 'X-API-Key: SYNTH_API_KEY' https://example.test/resource", + secret: "SYNTH_API_KEY", + expected: "X-API-Key: " + ), + ( + command: + "curl --header \"Authorization: Basic SYNTH_BASIC_TOKEN\" https://example.test/resource", + secret: "SYNTH_BASIC_TOKEN", + expected: "Authorization: Basic " + ), + ( + command: "curl -H 'x-auth-token : SYNTH_AUTH_TOKEN' https://example.test/resource", + secret: "SYNTH_AUTH_TOKEN", + expected: "x-auth-token : " + ), + ] + + for testCase in cases { + let redacted = ProcessPresentation.redactedCommand(testCase.command) + + XCTAssertFalse(redacted.contains(testCase.secret), testCase.command) + XCTAssertTrue(redacted.contains(testCase.expected), redacted) + XCTAssertTrue(redacted.contains("https://example.test/resource"), redacted) + } + + let nonSensitive = "curl -H 'Accept: application/json' https://example.test/resource" + XCTAssertEqual(ProcessPresentation.redactedCommand(nonSensitive), nonSensitive) + } + + func testRedactsCredentialHeaderAliasesInsteadOfOnlyAFixedHeaderList() { + let command = """ + curl -H 'PRIVATE-TOKEN: SYNTH_GITLAB_SECRET' -H 'X-Goog-Api-Key: SYNTH_GOOGLE_SECRET' -H 'X-Amz-Security-Token: SYNTH_AWS_SECRET' https://example.test + """ + + let redacted = ProcessPresentation.redactedCommand(command) + + XCTAssertFalse(redacted.contains("SYNTH_GITLAB_SECRET")) + XCTAssertFalse(redacted.contains("SYNTH_GOOGLE_SECRET")) + XCTAssertFalse(redacted.contains("SYNTH_AWS_SECRET")) + XCTAssertTrue(redacted.contains("PRIVATE-TOKEN: ")) + XCTAssertTrue(redacted.contains("X-Goog-Api-Key: ")) + XCTAssertTrue(redacted.contains("X-Amz-Security-Token: ")) + } + + func testRedactsCompleteSensitiveHeaderValuesAcrossAuthenticationSchemesAndCookies() { + let cases = [ + ( + command: + "curl -H 'Authorization: Digest username=alice, response=SYNTH_DIGEST_SECRET' https://example.test/digest", + secrets: ["alice", "SYNTH_DIGEST_SECRET"], + retainedURL: "https://example.test/digest" + ), + ( + command: + "curl --header \"Authorization: AWS4-HMAC-SHA256 Credential=fixture, Signature=SYNTH_AWS_SIGNATURE\" https://example.test/aws", + secrets: ["fixture", "SYNTH_AWS_SIGNATURE"], + retainedURL: "https://example.test/aws" + ), + ( + command: + "curl -H 'Proxy-Authorization: Negotiate SYNTH_PROXY_SECRET' -H 'Cookie: session=SYNTH_SESSION_COOKIE' https://example.test/cookie", + secrets: ["SYNTH_PROXY_SECRET", "SYNTH_SESSION_COOKIE"], + retainedURL: "https://example.test/cookie" + ), + ( + command: + "curl -H 'Authorization: Digest username=\"alice\", response=\"SYNTH_QUOTED_DIGEST\"' https://example.test/quoted-digest", + secrets: ["alice", "SYNTH_QUOTED_DIGEST"], + retainedURL: "https://example.test/quoted-digest" + ), + ( + command: + "curl -H 'Cookie: session=\"SYNTH_QUOTED_COOKIE\"; csrf=\"SYNTH_QUOTED_CSRF\"' https://example.test/quoted-cookie", + secrets: ["SYNTH_QUOTED_COOKIE", "SYNTH_QUOTED_CSRF"], + retainedURL: "https://example.test/quoted-cookie" + ), + ( + command: + #"curl -H "Authorization: Digest username=\"alice\", response=\"SYNTH_ESCAPED_DIGEST\"" https://example.test/escaped-digest"#, + secrets: ["alice", "SYNTH_ESCAPED_DIGEST"], + retainedURL: "https://example.test/escaped-digest" + ), + ( + command: + "curl -H X-API-Key:SYNTH_UNQUOTED_SECRET https://example.test/unquoted --verbose", + secrets: ["SYNTH_UNQUOTED_SECRET"], + retainedURL: "https://example.test/unquoted" + ), + ] + + for testCase in cases { + let redacted = ProcessPresentation.redactedCommand(testCase.command) + + for secret in testCase.secrets { + XCTAssertFalse(redacted.contains(secret), redacted) + } + XCTAssertTrue(redacted.contains(testCase.retainedURL), redacted) + XCTAssertTrue(redacted.contains(""), redacted) + } + } + + func testBulkExportRedactsColonDelimitedCredentialHeaders() { + let item = ClassifiedDevProcess( + process: DevProcess( + pid: 4_202, + parentPID: 1, + executable: "/usr/bin/curl", + command: "curl -H 'X-API-Key: SYNTH_EXPORT_HEADER_SECRET' https://example.test/private" + ), + classification: DevProcessClassification( + kind: .other, + displayName: "curl", + projectHint: nil, + tags: [] + ) + ) + + let export = ProcessPresentation.exportRows([item]) + + XCTAssertFalse(export.contains("SYNTH_EXPORT_HEADER_SECRET")) + XCTAssertTrue(export.contains("X-API-Key: ")) + } + + func testBulkExportRedactsCompleteCookieHeaderValue() { + let item = ClassifiedDevProcess( + process: DevProcess( + pid: 4_203, + parentPID: 1, + executable: "/usr/bin/curl", + command: + "curl -H 'Cookie: session=\"SYNTH_EXPORT_COOKIE\"; csrf=\"SYNTH_EXPORT_CSRF\"' https://example.test/private" + ), + classification: DevProcessClassification( + kind: .other, + displayName: "curl", + projectHint: nil, + tags: [] + ) + ) + + let export = ProcessPresentation.exportRows([item]) + + XCTAssertFalse(export.contains("SYNTH_EXPORT_COOKIE")) + XCTAssertFalse(export.contains("SYNTH_EXPORT_CSRF")) + XCTAssertTrue(export.contains("Cookie: ")) + XCTAssertTrue(export.contains("https://example.test/private")) + } + + func testBulkExportRedactsCurlLongCredentialOptions() { + let item = ClassifiedDevProcess( + process: DevProcess( + pid: 4_201, + parentPID: 1, + executable: "/usr/bin/curl", + command: "curl --user analyst:SYNTH_EXPORT_SECRET https://example.test/private" + ), + classification: DevProcessClassification( + kind: .other, + displayName: "curl", + projectHint: nil, + tags: [] + ) + ) + + let export = ProcessPresentation.exportRows([item]) + + XCTAssertFalse(export.contains("SYNTH_EXPORT_SECRET")) + XCTAssertTrue(export.contains("curl --user ")) + } + + func testExportRowsNeutralizesSpreadsheetFormulasAndCellBreaks() { + let item = ClassifiedDevProcess( + process: DevProcess( + pid: 1_234, + parentPID: 1, + executable: "/tmp/tool", + command: "=HYPERLINK(\"https://example.test\")\tspill\nrow" + ), + classification: DevProcessClassification( + kind: .other, + displayName: "+formula", + projectHint: "@project", + tags: [] + ) + ) + + let export = ProcessPresentation.exportRows([item]) + let rows = export.split(separator: "\n", omittingEmptySubsequences: false) + + XCTAssertEqual(rows.count, 2) + XCTAssertTrue(export.contains("'@project")) + XCTAssertTrue(export.contains("'+formula")) + XCTAssertTrue(export.contains("'=HYPERLINK")) + XCTAssertFalse(rows[1].contains("spill\trow")) + } + + func testContextLabelSuppressesTruncatedExecutableFragments() { + let systemItem = ClassifiedDevProcess( + process: DevProcess( + pid: 317, + parentPID: 1, + executable: "Library", + command: + "/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/FSEvents.framework/Versions/A/Support/fseventsd" + ), + classification: DevProcessClassification( + kind: .systemService, + displayName: "fseventsd", + projectHint: nil, + tags: [] + ) + ) + let shortPrefixItem = ClassifiedDevProcess( + process: DevProcess( + pid: 319, + parentPID: 1, + executable: "Use", + command: "/usr/libexec/UserEventAgent" + ), + classification: DevProcessClassification( + kind: .systemService, + displayName: "UserEventAgent", + projectHint: nil, + tags: [] + ) + ) + let projectItem = classified( + pid: 101, kind: .javascript, displayName: "next dev", project: "web", command: "node next dev" + ) + let regularItem = ClassifiedDevProcess( + process: DevProcess( + pid: 102, parentPID: 1, executable: "worker", command: "worker --foreground"), + classification: DevProcessClassification( + kind: .other, displayName: "worker", projectHint: nil, tags: []) + ) + + XCTAssertEqual(ProcessPresentation.contextLabel(for: systemItem), "System Service") + XCTAssertEqual(ProcessPresentation.contextLabel(for: shortPrefixItem), "System Service") + XCTAssertEqual(ProcessPresentation.contextLabel(for: projectItem), "web") + XCTAssertEqual(ProcessPresentation.contextLabel(for: regularItem), "worker") + } + + func testExecutablePathPrefersCommandPathForTruncatedExecutableFragments() { + let shortPrefixItem = ClassifiedDevProcess( + process: DevProcess( + pid: 319, + parentPID: 1, + executable: "/usr/libexec/Use", + command: "/usr/libexec/UserEventAgent (System)" + ), + classification: DevProcessClassification( + kind: .systemService, + displayName: "UserEventAgent", + projectHint: nil, + tags: [] + ) + ) + let regularItem = ClassifiedDevProcess( + process: DevProcess( + pid: 102, parentPID: 1, executable: "/opt/custom/bin/worker", + command: "/opt/custom/bin/worker --foreground"), + classification: DevProcessClassification( + kind: .other, displayName: "worker", projectHint: nil, tags: []) + ) + + XCTAssertEqual( + ProcessPresentation.executablePath(for: shortPrefixItem), "/usr/libexec/UserEventAgent") + XCTAssertEqual(ProcessPresentation.executablePath(for: regularItem), "/opt/custom/bin/worker") + } + + func testSummarizesVisibleProcessesByRuntimeAndProject() { + let summary = ProcessPresentation.summary(for: sampleProcesses) + + XCTAssertEqual(summary.totalCount, 4) + XCTAssertEqual(summary.count(for: .javascript), 2) + XCTAssertEqual(summary.count(for: .python), 1) + XCTAssertEqual(summary.count(for: .swift), 1) + XCTAssertEqual(summary.count(for: .shell), 0) + XCTAssertEqual(summary.projectCount, 3) + XCTAssertEqual(summary.primaryProject, "devscope") + } + + func testBuildsDynamicCategoriesOnlyForDetectedProcessTypes() { + let categories = ProcessPresentation.categories(for: sampleProcesses) + + XCTAssertEqual(categories.map(\.title), ["All", "JavaScript", "Python", "Swift"]) + XCTAssertEqual(categories.map(\.count), [4, 2, 1, 1]) + } + + func testSavedCategoryCountSkipsIdentityChecksWhenNoKeysAreConfigured() { + var identityCheckCount = 0 + + let count = ProcessPresentation.savedCount( + in: sampleProcesses, + keys: [] + ) { _, _ in + identityCheckCount += 1 + return true + } + + XCTAssertEqual(count, 0) + XCTAssertEqual(identityCheckCount, 0) + } + + func testSortsProcessesByActivityMonitorStyleMetrics() { + let items = [ + classified( + pid: 101, kind: .javascript, displayName: "next dev", project: "web", + command: "node next dev", cpu: 3, memory: 900), + classified( + pid: 102, kind: .python, displayName: "python worker", project: "api", + command: "python worker.py", cpu: 48, memory: 300), + classified( + pid: 103, kind: .swift, displayName: "swift build", project: "devscope", + command: "swift build", cpu: 12, memory: 1_600), + ] + + XCTAssertEqual( + ProcessPresentation.sorted(items, by: .cpuDescending).map(\.process.pid), [102, 103, 101]) + XCTAssertEqual( + ProcessPresentation.sorted(items, by: .memoryDescending).map(\.process.pid), [103, 101, 102]) + XCTAssertEqual( + ProcessPresentation.sorted(items, by: .processNameAscending).map(\.process.pid), + [101, 102, 103]) + } + + func testParsesElapsedTimeForChronologicalTableSorting() { + XCTAssertEqual(ProcessPresentation.elapsedSeconds("00:42"), 42) + XCTAssertEqual(ProcessPresentation.elapsedSeconds("02:03:04"), 7_384) + XCTAssertEqual(ProcessPresentation.elapsedSeconds("9-00:00:00"), 777_600) + XCTAssertEqual(ProcessPresentation.elapsedSeconds("10-00:00:00"), 864_000) + XCTAssertEqual(ProcessPresentation.elapsedSeconds("9223372036854775807-00:00:00"), -1) + XCTAssertEqual(ProcessPresentation.elapsedSeconds("-"), -1) + } + + func testSummarizesProcessFamilyForDetailPanels() { + let processes = [ + DevProcess(pid: 10, parentPID: 1, executable: "npm", command: "npm run dev"), + DevProcess(pid: 11, parentPID: 10, executable: "sh", command: "sh -c next dev"), + DevProcess(pid: 12, parentPID: 11, executable: "node", command: "node next dev"), + DevProcess(pid: 20, parentPID: 1, executable: "python", command: "python worker.py"), + ] + + let family = ProcessPresentation.familySummary(for: processes[0], in: processes) + + XCTAssertEqual(family.parentPID, 1) + XCTAssertEqual(family.childCount, 1) + XCTAssertEqual(family.descendantCount, 2) + } + + func testBuildsDashboardStatsForVisibleProcesses() { + let totalItems = [ + classified( + pid: 101, kind: .javascript, displayName: "next dev", project: "web", + command: "node next dev", cpu: 3, memory: 900), + classified( + pid: 102, kind: .python, displayName: "python train", project: "ml", + command: "python train.py", cpu: 48, memory: 300, tags: [.training]), + classified( + pid: 103, kind: .ai, displayName: "Ollama server", project: "llm", command: "ollama serve", + cpu: 12, memory: 1_600, tags: [.llmServer]), + classified( + pid: 104, kind: .swift, displayName: "swift build", project: "devscope", + command: "swift build", cpu: 80, memory: 2_400), + ] + let visibleItems = Array(totalItems.prefix(3)) + + let stats = ProcessPresentation.dashboardStats( + visibleItems: visibleItems, + totalItems: totalItems, + dashboardMetricHistory: [ + DevProcessMetricSample( + timestamp: Date(), + cpuPercent: 63, + residentMemoryBytes: 2_800, + gpuPercent: 42 + ) + ] + ) + + XCTAssertEqual(stats.visibleCount, 3) + XCTAssertEqual(stats.totalCount, 4) + XCTAssertEqual(stats.aiMLCount, 2) + XCTAssertEqual(stats.topCPU?.process.pid, 104) + XCTAssertEqual(stats.topMemory?.process.pid, 104) + XCTAssertEqual(stats.runtimeCounts[.python], 1) + XCTAssertEqual(stats.runtimeCounts[.swift], 1) + XCTAssertEqual(stats.latestGPUPercent, 42) + } + + func testParsesAppleAGXGPUUtilizationFromIORegistryOutput() { + let output = #""" + +-o AGXAcceleratorG15X + "PerformanceStatistics" = {"Renderer Utilization %"=82,"Device Utilization %"=83,"In use system memory"=3049406464} + "model" = "Apple M3 Max" + """# + + let metric = SystemGPUMetricProvider.parse(output) + + XCTAssertEqual(metric?.utilizationPercent, 83) + XCTAssertEqual(metric?.modelName, "Apple M3 Max") + } + + private var sampleProcesses: [ClassifiedDevProcess] { + [ + classified( + pid: 101, kind: .javascript, displayName: "next dev", project: "web", + command: "node next dev --port 3010"), + classified( + pid: 102, kind: .javascript, displayName: "vite", project: "devscope", command: "pnpm vite"), + classified( + pid: 201, kind: .python, displayName: "uvicorn", project: "api", + command: "uvicorn app.main:app --reload"), + classified( + pid: 301, kind: .swift, displayName: "swift build", project: "devscope", + command: "swift build"), + ] + } + + private func classified( + pid: Int32, + kind: DevRuntimeKind, + displayName: String, + project: String, + command: String, + cpu: Double = 0, + memory: Int64 = 0, + tags: [DevProcessTag] = [] + ) -> ClassifiedDevProcess { + ClassifiedDevProcess( + process: DevProcess( + pid: pid, + parentPID: 1, + executable: command.split(separator: " ").first.map(String.init) ?? command, + command: command, + currentDirectory: "\(NSHomeDirectory())/dev/example/\(project)", + resourceUsage: DevProcessResourceUsage( + cpuPercent: cpu, residentMemoryBytes: memory, elapsedTime: "00:01") + ), + classification: DevProcessClassification( + kind: kind, + displayName: displayName, + projectHint: project, + tags: tags + ) + ) + } +} diff --git a/Tests/DevScopeCoreTests/ProcessScopePresentationTests.swift b/Tests/DevScopeCoreTests/ProcessScopePresentationTests.swift new file mode 100644 index 0000000..20adc80 --- /dev/null +++ b/Tests/DevScopeCoreTests/ProcessScopePresentationTests.swift @@ -0,0 +1,209 @@ +import XCTest + +@testable import DevScopeCore + +final class ProcessScopePresentationTests: XCTestCase { + func testGroupsHostAppNestedHelperAndExternalWorkerWithoutOverlap() throws { + let items = [ + classified( + pid: 100, + parentPID: 1, + executable: "/Applications/ChatGPT.app/Contents/MacOS/ChatGPT", + cpu: 1, + memory: 100 + ), + classified( + pid: 101, + parentPID: 100, + executable: + "/Applications/ChatGPT.app/Contents/Frameworks/Codex Framework.framework/Helpers/Codex (Renderer).app/Contents/MacOS/Codex (Renderer)", + cpu: 2, + memory: 200 + ), + classified( + pid: 102, + parentPID: 101, + executable: "/usr/local/bin/npm", + command: "npm run dev", + cpu: 3, + memory: 300 + ), + classified( + pid: 200, + parentPID: 1, + executable: "/usr/bin/python3", + command: "python3 unrelated.py", + cpu: 50, + memory: 5_000 + ), + ] + + let family = try XCTUnwrap( + ProcessScopePresentation.applicationFamilies(for: items).first { + $0.bundlePath == "/Applications/ChatGPT.app" + } + ) + + XCTAssertEqual(family.title, "ChatGPT / Codex") + XCTAssertEqual(family.applicationCount, 1) + XCTAssertEqual(family.helperCount, 1) + XCTAssertEqual(family.workerCount, 1) + XCTAssertEqual(family.processIDs, [100, 101, 102]) + XCTAssertEqual(family.members.map(\.role), [.application, .helper, .worker]) + XCTAssertEqual(family.members.map(\.depth), [0, 1, 2]) + XCTAssertEqual(family.totalCPU, 6) + XCTAssertEqual(family.totalMemoryBytes, 600) + } + + func testApplicationFamiliesAreDeterministicAcrossInputOrder() { + let items = [ + classified( + pid: 10, + parentPID: 1, + executable: "/Applications/Alpha.app/Contents/MacOS/Alpha" + ), + classified( + pid: 11, + parentPID: 10, + executable: "/Applications/Alpha.app/Contents/Helpers/worker" + ), + classified( + pid: 20, + parentPID: 1, + executable: "/Applications/Beta.app/Contents/MacOS/Beta" + ), + ] + + XCTAssertEqual( + ProcessScopePresentation.applicationFamilies(for: items), + ProcessScopePresentation.applicationFamilies(for: items.reversed()) + ) + } + + func testApplicationOwnershipTerminatesAcrossMalformedParentCycle() throws { + let items = [ + classified( + pid: 300, + parentPID: 301, + executable: "/Applications/Cycle.app/Contents/Helpers/cycle-helper" + ), + classified( + pid: 301, + parentPID: 300, + executable: "/usr/local/bin/cycle-worker" + ), + classified( + pid: 302, + parentPID: 999, + executable: "/usr/local/bin/orphan" + ), + ] + + let family = try XCTUnwrap( + ProcessScopePresentation.applicationFamilies(for: items).first { + $0.bundlePath == "/Applications/Cycle.app" + } + ) + + XCTAssertEqual(family.processIDs, [300, 301]) + XCTAssertEqual(family.applicationCount, 0) + XCTAssertEqual(family.helperCount, 1) + XCTAssertEqual(family.workerCount, 1) + XCTAssertFalse(family.processIDs.contains(302)) + } + + func testDoesNotTruncateAppExtensionsIntoFakeApplicationBundles() { + let item = classified( + pid: 350, + parentPID: 1, + executable: + "/System/Library/ExtensionKit/Extensions/AccessibilitySettingsSearchExtension.appex/Contents/MacOS/AccessibilitySettingsSearchExtension" + ) + + XCTAssertTrue(ProcessScopePresentation.applicationFamilies(for: [item]).isEmpty) + } + + func testActivityScopeUsesExplicitUserFacingLabels() { + XCTAssertEqual(ProcessActivityScope.applications.title, "Applications") + XCTAssertEqual(ProcessActivityScope.processes.title, "Processes") + XCTAssertEqual(ProcessActivityScope.hierarchy.title, "Hierarchy") + XCTAssertEqual(ProcessActivityScope.workflows.title, "Workflows") + } + + func testHierarchySearchRetainsMatchingProcessAncestorPath() { + let items = [ + classified(pid: 100, parentPID: 1, executable: "/usr/bin/zsh"), + classified(pid: 101, parentPID: 100, executable: "/usr/local/bin/node"), + classified( + pid: 102, + parentPID: 101, + executable: "/usr/local/bin/node", + command: "node worker.js" + ), + classified(pid: 200, parentPID: 1, executable: "/usr/bin/python3"), + ] + + let filtered = ProcessScopePresentation.hierarchy(for: items, searchText: "worker.js") + + XCTAssertEqual(filtered.map(\.id), [100]) + XCTAssertEqual(filtered[0].children.map(\.id), [101]) + XCTAssertEqual(filtered[0].children[0].children.map(\.id), [102]) + XCTAssertEqual(ProcessScopePresentation.flattened(filtered).map(\.id), [100, 101, 102]) + } + + func testHierarchyKeepsMissingParentsAsRootsAndOrdersSiblingsByPID() { + let items = [ + classified(pid: 12, parentPID: 10, executable: "/usr/bin/child-b"), + classified(pid: 11, parentPID: 10, executable: "/usr/bin/child-a"), + classified(pid: 10, parentPID: 999, executable: "/usr/bin/root"), + ] + + let hierarchy = ProcessScopePresentation.hierarchy(for: items) + + XCTAssertEqual(hierarchy.map(\.id), [10]) + XCTAssertEqual(hierarchy[0].children.map(\.id), [11, 12]) + } + + func testHierarchyBreaksCyclesAndRepresentsEveryPIDOnce() { + let items = [ + classified(pid: 30, parentPID: 32, executable: "/usr/bin/thirty"), + classified(pid: 31, parentPID: 30, executable: "/usr/bin/thirty-one"), + classified(pid: 32, parentPID: 31, executable: "/usr/bin/thirty-two"), + classified(pid: 40, parentPID: 1, executable: "/usr/bin/forty"), + ] + + let hierarchy = ProcessScopePresentation.hierarchy(for: items) + let processIDs = ProcessScopePresentation.flattened(hierarchy).map(\.id) + + XCTAssertEqual(processIDs.sorted(), [30, 31, 32, 40]) + XCTAssertEqual(Set(processIDs).count, processIDs.count) + } + + private func classified( + pid: Int32, + parentPID: Int32, + executable: String, + command: String? = nil, + cpu: Double = 0, + memory: Int64 = 0 + ) -> ClassifiedDevProcess { + ClassifiedDevProcess( + process: DevProcess( + pid: pid, + parentPID: parentPID, + executable: executable, + command: command ?? executable, + resourceUsage: DevProcessResourceUsage( + cpuPercent: cpu, + residentMemoryBytes: memory, + elapsedTime: "00:01" + ) + ), + classification: DevProcessClassification( + kind: .macApp, + displayName: URL(fileURLWithPath: executable).lastPathComponent, + projectHint: nil + ) + ) + } +} diff --git a/Tests/DevScopeCoreTests/ProcessSignalObservationTests.swift b/Tests/DevScopeCoreTests/ProcessSignalObservationTests.swift new file mode 100644 index 0000000..096b77a --- /dev/null +++ b/Tests/DevScopeCoreTests/ProcessSignalObservationTests.swift @@ -0,0 +1,210 @@ +import XCTest +@testable import DevScopeCore + +final class ProcessSignalObservationTests: XCTestCase { + func testSameBirthWithMetadataEnrichmentIsStillRunning() { + let birthToken = ProcessBirthToken(seconds: 1_000, microseconds: 100) + let original = DevProcess( + pid: 42, + parentPID: 1, + executable: "node", + command: "node", + birthToken: birthToken + ) + let enriched = DevProcess( + pid: 42, + parentPID: 99, + executable: "/opt/homebrew/bin/node", + command: "/opt/homebrew/bin/node server.js", + currentDirectory: "/tmp/project", + birthToken: birthToken + ) + let target = ProcessIdentity(process: original) + + let summary = ProcessSignalObservation.observe( + targets: [target], + in: [enriched] + ) + + XCTAssertEqual( + summary.observations, + [ProcessSignalTargetObservation(target: target, state: .stillRunning)] + ) + XCTAssertFalse(summary.verifiesAllTargetsStopped) + } + + func testAbsentTargetIsExitedOrReplacedAndVerifiesStopped() { + let target = ProcessIdentity( + process: DevProcess( + pid: 42, + parentPID: 1, + executable: "node", + command: "node", + birthToken: ProcessBirthToken(seconds: 1_000, microseconds: 100) + ) + ) + + let summary = ProcessSignalObservation.observe(targets: [target], in: []) + + XCTAssertEqual( + summary.observations, + [ProcessSignalTargetObservation(target: target, state: .exitedOrReplaced)] + ) + XCTAssertTrue(summary.verifiesAllTargetsStopped) + } + + func testReusedPIDIsExitedOrReplacedAndVerifiesOriginalStopped() { + let target = ProcessIdentity( + process: DevProcess( + pid: 42, + parentPID: 1, + executable: "node", + command: "node", + birthToken: ProcessBirthToken(seconds: 1_000, microseconds: 100) + ) + ) + let replacement = DevProcess( + pid: 42, + parentPID: 1, + executable: "node", + command: "node", + birthToken: ProcessBirthToken(seconds: 1_000, microseconds: 101) + ) + + let summary = ProcessSignalObservation.observe( + targets: [target], + in: [replacement] + ) + + XCTAssertEqual(summary.exitedOrReplaced, [target]) + XCTAssertTrue(summary.verifiesAllTargetsStopped) + } + + func testUnknownExpectedOrCurrentBirthIsUnverifiableAndNeverSuccess() { + let unknownExpected = ProcessIdentity( + process: DevProcess( + pid: 41, + parentPID: 1, + executable: "node", + command: "node" + ) + ) + let knownExpected = ProcessIdentity( + process: DevProcess( + pid: 42, + parentPID: 1, + executable: "node", + command: "node", + birthToken: ProcessBirthToken(seconds: 1_000, microseconds: 100) + ) + ) + let currentProcesses = [ + DevProcess( + pid: 41, + parentPID: 1, + executable: "node", + command: "node", + birthToken: ProcessBirthToken(seconds: 1_000, microseconds: 99) + ), + DevProcess( + pid: 42, + parentPID: 1, + executable: "node", + command: "node" + ), + ] + + let summary = ProcessSignalObservation.observe( + targets: [unknownExpected, knownExpected], + in: currentProcesses + ) + + XCTAssertEqual(summary.unverifiable, [unknownExpected, knownExpected]) + XCTAssertFalse(summary.verifiesAllTargetsStopped) + } + + func testPartialAllStoppedPresentationNeverClaimsFullCompletion() { + let signaled = ProcessIdentity( + process: DevProcess( + pid: 12, + parentPID: 11, + executable: "node", + command: "node worker.js", + birthToken: ProcessBirthToken(seconds: 1_000, microseconds: 12) + ) + ) + let failed = ProcessIdentity( + process: DevProcess( + pid: 11, + parentPID: 10, + executable: "node", + command: "node server.js", + birthToken: ProcessBirthToken(seconds: 1_000, microseconds: 11) + ) + ) + let observation = ProcessSignalObservation.observe(targets: [signaled], in: []) + + let presentation = ProcessPartialSignalVerificationPresentation.make( + signalName: "TERM", + observation: observation, + failedTarget: failed, + reason: "PID 11 was reused" + ) + + XCTAssertEqual(presentation.title, "Partial result verified") + XCTAssertEqual( + presentation.detail, + "1 signaled process stopped. PID 11 failed: PID 11 was reused. The failed target and all remaining tree targets were not signaled." + ) + XCTAssertFalse(presentation.title.contains("Process stopped")) + } + + func testPartialPendingPresentationRetainsStillRunningAndUnverifiableStates() { + let token = ProcessBirthToken(seconds: 1_000, microseconds: 1) + let stopped = identity(pid: 12, birthToken: token) + let stillRunning = identity(pid: 13, birthToken: token) + let unverifiable = identity(pid: 14, birthToken: token) + let failed = identity(pid: 11, birthToken: token) + let currentProcesses = [ + process(pid: 13, birthToken: token), + process(pid: 14, birthToken: nil), + ] + let observation = ProcessSignalObservation.observe( + targets: [stopped, stillRunning, unverifiable], + in: currentProcesses + ) + + let presentation = ProcessPartialSignalVerificationPresentation.make( + signalName: "TERM", + observation: observation, + failedTarget: failed, + reason: "permission denied" + ) + + XCTAssertEqual(presentation.title, "Partial result not fully verified") + XCTAssertEqual( + presentation.detail, + "1 signaled process stopped. Still running: 13. Could not verify: 14. PID 11 failed: permission denied. The failed target and all remaining tree targets were not signaled." + ) + } + + private func identity( + pid: Int32, + birthToken: ProcessBirthToken? + ) -> ProcessIdentity { + ProcessIdentity(process: process(pid: pid, birthToken: birthToken)) + } + + private func process( + pid: Int32, + birthToken: ProcessBirthToken? + ) -> DevProcess { + DevProcess( + pid: pid, + parentPID: 1, + executable: "node", + command: "node worker.js", + birthToken: birthToken + ) + } +} diff --git a/Tests/DevScopeCoreTests/ProcessSnapshotBuilderTests.swift b/Tests/DevScopeCoreTests/ProcessSnapshotBuilderTests.swift new file mode 100644 index 0000000..d849450 --- /dev/null +++ b/Tests/DevScopeCoreTests/ProcessSnapshotBuilderTests.swift @@ -0,0 +1,130 @@ +import XCTest +@testable import DevScopeCore + +final class ProcessSnapshotBuilderTests: XCTestCase { + func testInvalidatingWorkspaceFactsReclassifiesAnUnchangedProcess() throws { + var manifestExists = false + var probeCount = 0 + let workspaceFactsCache = WorkspaceFactsCache { path in + probeCount += 1 + return manifestExists && path.hasSuffix("/pubspec.yaml") + } + var builder = ProcessSnapshotBuilder(workspaceFactsCache: workspaceFactsCache) + let process = DevProcess( + pid: 799, + parentPID: 1, + executable: "workspace-runner", + command: "workspace-runner serve", + currentDirectory: NSHomeDirectory() + "/dev/example/app", + birthToken: ProcessBirthToken(seconds: 1_000, microseconds: 41) + ) + let start = Date(timeIntervalSince1970: 100) + + let beforeManifest = builder.build( + processes: [process], + now: start, + graceInterval: 2.5 + ) + manifestExists = true + builder.invalidateWorkspaceFacts() + let afterManifest = builder.build( + processes: [process], + now: start.addingTimeInterval(2), + graceInterval: 2.5 + ) + + XCTAssertEqual(try XCTUnwrap(beforeManifest.classified.first).classification.kind, .other) + XCTAssertEqual(try XCTUnwrap(afterManifest.classified.first).classification.kind, .flutter) + XCTAssertEqual(probeCount, 3) + } + + func testUpdatesMetricsWithoutLosingClassificationOrLiveIdentity() throws { + var builder = ProcessSnapshotBuilder() + let start = Date(timeIntervalSince1970: 100) + let birthToken = ProcessBirthToken(seconds: 1_000, microseconds: 42) + + let first = builder.build( + processes: [process(cpu: 1, birthToken: birthToken)], + now: start, + graceInterval: 2.5 + ) + let second = builder.build( + processes: [process(cpu: 90, birthToken: birthToken)], + now: start.addingTimeInterval(2), + graceInterval: 2.5 + ) + + XCTAssertEqual(first.classified.first?.classification.kind, .javascript) + XCTAssertEqual(second.classified.first?.classification.kind, .javascript) + XCTAssertEqual(second.classified.first?.process.resourceUsage?.cpuPercent, 90) + XCTAssertEqual(second.classified.first?.process.birthToken, birthToken) + XCTAssertEqual(second.liveProcessIDs, Set([800])) + XCTAssertEqual(second.processes, second.classified.map(\.process)) + } + + func testRehydratesCachedWorkflowMetricsWithoutRebuildingStructure() throws { + var builder = ProcessSnapshotBuilder() + let start = Date(timeIntervalSince1970: 100) + let birthToken = ProcessBirthToken(seconds: 1_000, microseconds: 42) + + let first = builder.build( + processes: [process(cpu: 1, birthToken: birthToken)], + now: start, + graceInterval: 2.5 + ) + let metricTick = builder.build( + processes: [process(cpu: 90, birthToken: birthToken)], + now: start.addingTimeInterval(2), + graceInterval: 2.5 + ) + let workflowTick = builder.build( + processes: [process(cpu: 90, birthToken: birthToken)], + now: start.addingTimeInterval(10), + graceInterval: 2.5 + ) + + let firstWorkflow = try XCTUnwrap(first.workflows.first) + XCTAssertEqual(metricTick.classified.first?.process.resourceUsage?.cpuPercent, 90) + XCTAssertEqual(firstWorkflow.totalCPU, 1) + XCTAssertEqual(metricTick.workflows.first?.totalCPU, 90) + XCTAssertEqual(workflowTick.workflows.first?.totalCPU, 90) + } + + func testInvalidatingWorkspaceFactsRefreshesCachedWorkflowMetrics() throws { + var builder = ProcessSnapshotBuilder() + let start = Date(timeIntervalSince1970: 100) + let birthToken = ProcessBirthToken(seconds: 1_000, microseconds: 42) + + let first = builder.build( + processes: [process(cpu: 1, birthToken: birthToken)], + now: start, + graceInterval: 2.5 + ) + builder.invalidateWorkspaceFacts() + let afterInvalidation = builder.build( + processes: [process(cpu: 90, birthToken: birthToken)], + now: start.addingTimeInterval(2), + graceInterval: 2.5 + ) + + XCTAssertEqual(try XCTUnwrap(first.workflows.first).totalCPU, 1) + XCTAssertEqual(try XCTUnwrap(afterInvalidation.workflows.first).totalCPU, 90) + } + + private func process(cpu: Double, birthToken: ProcessBirthToken) -> DevProcess { + let workspace = NSHomeDirectory() + "/dev/devscope-fixture" + return DevProcess( + pid: 800, + parentPID: 1, + executable: "node", + command: "node \(workspace)/node_modules/.bin/vite", + currentDirectory: workspace, + resourceUsage: DevProcessResourceUsage( + cpuPercent: cpu, + residentMemoryBytes: 100, + elapsedTime: "00:10" + ), + birthToken: birthToken + ) + } +} diff --git a/Tests/DevScopeCoreTests/ProcessSnapshotStabilizerTests.swift b/Tests/DevScopeCoreTests/ProcessSnapshotStabilizerTests.swift new file mode 100644 index 0000000..1efd769 --- /dev/null +++ b/Tests/DevScopeCoreTests/ProcessSnapshotStabilizerTests.swift @@ -0,0 +1,99 @@ +import XCTest +@testable import DevScopeCore + +final class ProcessSnapshotStabilizerTests: XCTestCase { + func testRetainsRecentlyMissingProcessesToReduceRefreshChurn() { + var stabilizer = ClassifiedProcessSnapshotStabilizer() + let start = Date(timeIntervalSince1970: 1_000) + + let first = stabilizer.merge( + liveItems: [ + classified(pid: 10, displayName: "api"), + classified(pid: 20, displayName: "worker") + ], + now: start, + graceInterval: 2 + ) + + XCTAssertEqual(first.map(\.process.pid), [10, 20]) + XCTAssertEqual(stabilizer.liveProcessIDs, [10, 20]) + + let second = stabilizer.merge( + liveItems: [ + classified(pid: 20, displayName: "worker") + ], + now: start.addingTimeInterval(1), + graceInterval: 2 + ) + + XCTAssertEqual(second.map(\.process.pid), [10, 20]) + XCTAssertEqual(stabilizer.liveProcessIDs, [20]) + } + + func testDropsMissingProcessesAfterGraceInterval() { + var stabilizer = ClassifiedProcessSnapshotStabilizer() + let start = Date(timeIntervalSince1970: 1_000) + + _ = stabilizer.merge( + liveItems: [ + classified(pid: 10, displayName: "api"), + classified(pid: 20, displayName: "worker") + ], + now: start, + graceInterval: 2 + ) + + let result = stabilizer.merge( + liveItems: [ + classified(pid: 20, displayName: "worker") + ], + now: start.addingTimeInterval(3), + graceInterval: 2 + ) + + XCTAssertEqual(result.map(\.process.pid), [20]) + XCTAssertEqual(stabilizer.liveProcessIDs, [20]) + } + + func testPreservesFirstSeenOrderWhenNewProcessesArrive() { + var stabilizer = ClassifiedProcessSnapshotStabilizer() + let start = Date(timeIntervalSince1970: 1_000) + + _ = stabilizer.merge( + liveItems: [ + classified(pid: 20, displayName: "worker") + ], + now: start, + graceInterval: 2 + ) + + let result = stabilizer.merge( + liveItems: [ + classified(pid: 10, displayName: "api"), + classified(pid: 20, displayName: "worker") + ], + now: start.addingTimeInterval(1), + graceInterval: 2 + ) + + XCTAssertEqual(result.map(\.process.pid), [20, 10]) + } + + private func classified(pid: Int32, displayName: String) -> ClassifiedDevProcess { + ClassifiedDevProcess( + process: DevProcess( + pid: pid, + parentPID: 1, + executable: displayName, + command: displayName, + currentDirectory: "\(NSHomeDirectory())/dev/example/\(displayName)", + resourceUsage: DevProcessResourceUsage(cpuPercent: 0, residentMemoryBytes: 1, elapsedTime: "00:01") + ), + classification: DevProcessClassification( + kind: .javascript, + displayName: displayName, + projectHint: displayName + ) + ) + } +} diff --git a/Tests/DevScopeCoreTests/ProcessTreeTests.swift b/Tests/DevScopeCoreTests/ProcessTreeTests.swift new file mode 100644 index 0000000..e9aa85f --- /dev/null +++ b/Tests/DevScopeCoreTests/ProcessTreeTests.swift @@ -0,0 +1,29 @@ +import XCTest +@testable import DevScopeCore + +final class ProcessTreeTests: XCTestCase { + func testFindsAllDescendantsWithoutIncludingRootOrUnrelatedProcesses() { + let processes = [ + DevProcess(pid: 10, parentPID: 1, executable: "npm", command: "npm run dev"), + DevProcess(pid: 11, parentPID: 10, executable: "sh", command: "sh -c next dev"), + DevProcess(pid: 12, parentPID: 11, executable: "node", command: "node next dev"), + DevProcess(pid: 20, parentPID: 1, executable: "python", command: "python other.py") + ] + + let descendants = ProcessTree.descendants(of: 10, in: processes) + + XCTAssertEqual(descendants.map(\.pid).sorted(), [11, 12]) + } + + func testIgnoresCyclesInMalformedSnapshots() { + let processes = [ + DevProcess(pid: 10, parentPID: 12, executable: "npm", command: "npm run dev"), + DevProcess(pid: 11, parentPID: 10, executable: "sh", command: "sh -c next dev"), + DevProcess(pid: 12, parentPID: 11, executable: "node", command: "node next dev") + ] + + let descendants = ProcessTree.descendants(of: 10, in: processes) + + XCTAssertEqual(descendants.map(\.pid).sorted(), [11, 12]) + } +} diff --git a/Tests/DevScopeCoreTests/ProcessWorkflowCacheTests.swift b/Tests/DevScopeCoreTests/ProcessWorkflowCacheTests.swift new file mode 100644 index 0000000..23c3d0f --- /dev/null +++ b/Tests/DevScopeCoreTests/ProcessWorkflowCacheTests.swift @@ -0,0 +1,324 @@ +import XCTest +@testable import DevScopeCore + +final class ProcessWorkflowCacheTests: XCTestCase { + func testMetricOnlyUpdateRehydratesWorkflowsWithoutRebuilding() throws { + let buildCount = Counter() + var cache = ProcessWorkflowCache { items in + buildCount.value += 1 + return ProcessIntelligence.uncappedWorkflows(for: items) + } + let start = Date(timeIntervalSince1970: 100) + + let first = cache.workflows(for: [item(cpu: 1)], now: start) + let second = cache.workflows( + for: [item(cpu: 90, memory: 900, elapsedTime: "00:12")], + now: start.addingTimeInterval(2) + ) + + XCTAssertEqual(buildCount.value, 1) + XCTAssertEqual(try XCTUnwrap(first.first).totalCPU, 1) + XCTAssertEqual(try XCTUnwrap(second.first).totalCPU, 90) + XCTAssertEqual(try XCTUnwrap(second.first).totalMemoryBytes, 900) + } + + func testStableFingerprintReusesWorkflowsIndefinitely() throws { + let buildCount = Counter() + var cache = ProcessWorkflowCache { items in + buildCount.value += 1 + return ProcessIntelligence.uncappedWorkflows(for: items) + } + let start = Date(timeIntervalSince1970: 100) + + let first = cache.workflows(for: [item(cpu: 1)], now: start) + let afterTwoMinutes = cache.workflows( + for: [item(cpu: 90, memory: 5_000_000_000, elapsedTime: "00:20")], + now: start.addingTimeInterval(120) + ) + let muchLater = cache.workflows( + for: [item(cpu: 45, memory: 2_000_000_000, elapsedTime: "12:34")], + now: start.addingTimeInterval(10_000) + ) + + let firstWorkflow = try XCTUnwrap(first.first) + let afterTwoMinutesWorkflow = try XCTUnwrap(afterTwoMinutes.first) + let muchLaterWorkflow = try XCTUnwrap(muchLater.first) + XCTAssertEqual(buildCount.value, 1) + XCTAssertEqual(firstWorkflow.totalCPU, 1) + XCTAssertEqual(afterTwoMinutesWorkflow.totalCPU, 90) + XCTAssertEqual(afterTwoMinutesWorkflow.totalMemoryBytes, 5_000_000_000) + XCTAssertEqual(afterTwoMinutesWorkflow.risk, .heavy) + XCTAssertEqual(muchLaterWorkflow.totalCPU, 45) + XCTAssertEqual(muchLaterWorkflow.totalMemoryBytes, 2_000_000_000) + XCTAssertEqual(muchLaterWorkflow.risk, .busy) + } + + func testWorkflowRelevantIdentityAndClassificationChangesRecomputeImmediately() { + let baseline = item(cpu: 1) + let variants = [ + item(cpu: 1, pid: 801), + item(cpu: 1, birthToken: ProcessBirthToken(seconds: 1_001, microseconds: 43)), + item(cpu: 1, parentPID: 2), + item(cpu: 1, executable: "bun"), + item(cpu: 1, command: "node worker.js"), + item(cpu: 1, currentDirectory: "/tmp/other"), + item(cpu: 1, kind: .python), + item(cpu: 1, displayName: "worker.js"), + item(cpu: 1, projectHint: "other"), + item(cpu: 1, tags: [.api, .experiment]), + ] + + for variant in variants { + let buildCount = Counter() + var cache = ProcessWorkflowCache { items in + buildCount.value += 1 + return ProcessIntelligence.uncappedWorkflows(for: items) + } + let start = Date(timeIntervalSince1970: 100) + + _ = cache.workflows(for: [baseline], now: start) + _ = cache.workflows(for: [variant], now: start.addingTimeInterval(2)) + + XCTAssertEqual(buildCount.value, 2, "Expected an immediate rebuild for \(variant)") + } + } + + func testAddingAndRemovingAProcessRecomputesImmediately() { + let buildCount = Counter() + var cache = ProcessWorkflowCache { items in + buildCount.value += 1 + return ProcessIntelligence.uncappedWorkflows(for: items) + } + let start = Date(timeIntervalSince1970: 100) + let first = item(cpu: 1) + let second = item(cpu: 1, pid: 801) + + _ = cache.workflows(for: [first], now: start) + _ = cache.workflows(for: [first, second], now: start.addingTimeInterval(2)) + _ = cache.workflows(for: [first], now: start.addingTimeInterval(4)) + + XCTAssertEqual(buildCount.value, 3) + } + + func testIrrelevantMachineProcessChurnDoesNotRebuildWorkflows() { + let buildCount = Counter() + var cache = ProcessWorkflowCache { items in + buildCount.value += 1 + return ProcessIntelligence.uncappedWorkflows(for: items) + } + let start = Date(timeIntervalSince1970: 100) + let relevant = item(cpu: 1) + let firstSystemProcess = item( + cpu: 0, + pid: 900, + executable: "/usr/libexec/distnoted", + command: "/usr/libexec/distnoted agent", + currentDirectory: "/", + kind: .systemService, + displayName: "distnoted", + projectHint: nil, + tags: [] + ) + let replacementSystemProcess = item( + cpu: 0, + pid: 901, + executable: "/usr/libexec/distnoted", + command: "/usr/libexec/distnoted agent", + currentDirectory: "/", + kind: .systemService, + displayName: "distnoted", + projectHint: nil, + tags: [] + ) + + let first = cache.workflows(for: [relevant, firstSystemProcess], now: start) + let afterChurn = cache.workflows( + for: [relevant, replacementSystemProcess], + now: start.addingTimeInterval(2) + ) + + XCTAssertEqual(buildCount.value, 1) + XCTAssertEqual(afterChurn, first) + } + + func testProcessOrderChangeRecomputesImmediately() { + let buildCount = Counter() + var cache = ProcessWorkflowCache { items in + buildCount.value += 1 + return ProcessIntelligence.uncappedWorkflows(for: items) + } + let start = Date(timeIntervalSince1970: 100) + let first = item(cpu: 1) + let second = item(cpu: 1, pid: 801) + + _ = cache.workflows(for: [first, second], now: start) + _ = cache.workflows(for: [second, first], now: start.addingTimeInterval(2)) + + XCTAssertEqual(buildCount.value, 2) + } + + func testMetricRiskChangeReordersCachedWorkflowsWithoutRebuilding() { + let buildCount = Counter() + var cache = ProcessWorkflowCache { items in + buildCount.value += 1 + return ProcessIntelligence.uncappedWorkflows(for: items) + } + let start = Date(timeIntervalSince1970: 100) + let alpha = item( + cpu: 1, + currentDirectory: "\(NSHomeDirectory())/dev/example/alpha", + projectHint: "alpha", + tags: [] + ) + let beta = item( + cpu: 1, + pid: 801, + currentDirectory: "\(NSHomeDirectory())/dev/example/beta", + projectHint: "beta", + tags: [] + ) + let heavyBeta = item( + cpu: 90, + pid: 801, + currentDirectory: "\(NSHomeDirectory())/dev/example/beta", + projectHint: "beta", + tags: [] + ) + + let initial = cache.workflows(for: [alpha, beta], now: start) + let reordered = cache.workflows( + for: [alpha, heavyBeta], + now: start.addingTimeInterval(2) + ) + + XCTAssertEqual(initial.map(\.title), ["Alpha Web", "Beta Web"]) + XCTAssertEqual(reordered.map(\.title), ["Beta Web", "Alpha Web"]) + XCTAssertEqual(buildCount.value, 1) + } + + func testMetricRiskChangePromotesThirteenthCachedWorkflowWithoutRebuilding() throws { + let buildCount = Counter() + var cache = ProcessWorkflowCache { items in + buildCount.value += 1 + return ProcessIntelligence.uncappedWorkflows(for: items) + } + let start = Date(timeIntervalSince1970: 100) + let items = (0..<13).map { index in + item( + cpu: 1, + pid: Int32(1_000 + index), + currentDirectory: "\(NSHomeDirectory())/dev/example/project-\(index)", + projectHint: "project-\(index)", + tags: [] + ) + } + + let initial = cache.workflows(for: items, now: start) + let initialProcessIDs = Set(initial.flatMap(\.processIDs)) + let omittedPID = try XCTUnwrap( + items.map(\.process.pid).first { !initialProcessIDs.contains($0) } + ) + let updatedItems = (0..<13).map { index in + let pid = Int32(1_000 + index) + return item( + cpu: pid == omittedPID ? 90 : 1, + pid: pid, + currentDirectory: "\(NSHomeDirectory())/dev/example/project-\(index)", + projectHint: "project-\(index)", + tags: [] + ) + } + + let promoted = cache.workflows( + for: updatedItems, + now: start.addingTimeInterval(2) + ) + + XCTAssertEqual(initial.count, 12) + XCTAssertEqual(promoted.count, 12) + XCTAssertTrue(promoted.contains { $0.processIDs.contains(omittedPID) }) + XCTAssertEqual(buildCount.value, 1) + } + + func testStable850RowFingerprintPerformsNoSecondWorkflowBuild() { + let buildCount = Counter() + var cache = ProcessWorkflowCache { items in + buildCount.value += 1 + return ProcessIntelligence.uncappedWorkflows(for: items) + } + let items = (0..<850).map { index in + item( + cpu: 1, + pid: Int32(1_000 + index), + currentDirectory: "\(NSHomeDirectory())/dev/example/project-\(index)", + projectHint: "project-\(index)", + tags: [] + ) + } + let start = Date(timeIntervalSince1970: 100) + + let first = cache.workflows(for: items, now: start) + let cached = cache.workflows(for: items, now: start.addingTimeInterval(10_000)) + + XCTAssertEqual(buildCount.value, 1) + XCTAssertEqual(cached, first) + } + + func testExplicitInvalidationRecomputesImmediately() { + let buildCount = Counter() + var cache = ProcessWorkflowCache { items in + buildCount.value += 1 + return ProcessIntelligence.uncappedWorkflows(for: items) + } + let start = Date(timeIntervalSince1970: 100) + let process = item(cpu: 1) + + _ = cache.workflows(for: [process], now: start) + cache.invalidate() + _ = cache.workflows(for: [process], now: start.addingTimeInterval(2)) + + XCTAssertEqual(buildCount.value, 2) + } + + private func item( + cpu: Double, + memory: Int64 = 100, + elapsedTime: String = "00:10", + pid: Int32 = 800, + birthToken: ProcessBirthToken = ProcessBirthToken(seconds: 1_000, microseconds: 42), + parentPID: Int32 = 1, + executable: String = "node", + command: String = "node server.js", + currentDirectory: String = "/tmp/example", + kind: DevRuntimeKind = .javascript, + displayName: String = "server.js", + projectHint: String? = "example", + tags: [DevProcessTag] = [.api] + ) -> ClassifiedDevProcess { + ClassifiedDevProcess( + process: DevProcess( + pid: pid, + parentPID: parentPID, + executable: executable, + command: command, + currentDirectory: currentDirectory, + resourceUsage: DevProcessResourceUsage( + cpuPercent: cpu, + residentMemoryBytes: memory, + elapsedTime: elapsedTime + ), + birthToken: birthToken + ), + classification: DevProcessClassification( + kind: kind, + displayName: displayName, + projectHint: projectHint, + tags: tags + ) + ) + } +} + +private final class Counter: @unchecked Sendable { + var value = 0 +} diff --git a/Tests/DevScopeCoreTests/ReleaseScriptTests.swift b/Tests/DevScopeCoreTests/ReleaseScriptTests.swift new file mode 100644 index 0000000..d8fb04a --- /dev/null +++ b/Tests/DevScopeCoreTests/ReleaseScriptTests.swift @@ -0,0 +1,1851 @@ +import AppKit +import Foundation +import XCTest + +final class ReleaseScriptTests: XCTestCase { + func testMainInterfaceRetainsAutomationsAndTruthfulProcessScopes() throws { + let content = try appSource(at: "Sources/DevScope/Views/ContentView.swift") + let picker = try appSource(at: "Sources/DevScope/Views/ProcessScopePicker.swift") + let family = try appSource(at: "Sources/DevScope/Views/ApplicationFamilyListView.swift") + let hierarchy = try appSource(at: "Sources/DevScope/Views/ProcessHierarchyView.swift") + + XCTAssertTrue(content.contains("Text(\"Processes\").tag(DevScopeWorkspaceMode.processes)")) + XCTAssertTrue(content.contains("Text(\"Automations\").tag(DevScopeWorkspaceMode.automations)")) + XCTAssertTrue(content.contains("ProcessScopePicker(selection:")) + XCTAssertTrue(content.contains("ApplicationFamilyListView(")) + XCTAssertTrue(content.contains("ProcessHierarchyView(")) + XCTAssertTrue(picker.contains("ProcessActivityScope.allCases")) + XCTAssertTrue(family.contains("OS processes")) + XCTAssertTrue(hierarchy.contains("ProcessHierarchyNode")) + } + + func testAutomationWorkspaceUsesTheProcessProjectedInventoryWithItsLinks() throws { + let content = try appSource(at: "Sources/DevScope/Views/ContentView.swift") + let workspace = try appSource(at: "Sources/DevScope/Views/AutomationWorkspaceView.swift") + + XCTAssertTrue(content.contains("inventory: store.liveSnapshot.automationInventory")) + XCTAssertTrue(workspace.contains("AutomationPresentation.filtered(\n inventory.records,")) + XCTAssertFalse(workspace.contains("AutomationPresentation.filtered(\n store.snapshot.records,")) + } + + func testMainWorkspaceRetainsOnlyTheAutomationLayerAcrossWorkspaceSwitches() throws { + let content = try appSource(at: "Sources/DevScope/Views/ContentView.swift") + let sanitized = sanitizedSwiftSource(content) + let mainWorkspace = try sourceBlock(in: sanitized, startingWith: "private var mainWorkspace") + let workspaceLayers = try sourceBlock(in: mainWorkspace, startingWith: "ZStack") + let processConditional = try sourceBlock( + in: workspaceLayers, + startingWith: "if workspaceMode == .processes" + ) + let layerModifier = try sourceBlock(in: sanitized, startingWith: "func workspaceLayer") + let normalizedWorkspace = workspaceLayers.filter { !$0.isWhitespace } + let normalizedModifier = layerModifier.filter { !$0.isWhitespace } + let automationInitializer = try balancedRegion( + in: normalizedWorkspace, + startingWith: "AutomationWorkspaceView", + opening: "(", + closing: ")" + ) + let automationLayer = "AutomationWorkspaceView\(automationInitializer)" + + ".workspaceLayer(isActive:workspaceMode==.automations)" + + XCTAssertEqual(occurrenceCount(of: "ZStack", in: mainWorkspace), 1) + XCTAssertEqual(occurrenceCount(of: "processModeWorkspace", in: workspaceLayers), 1) + XCTAssertTrue(processConditional.filter { !$0.isWhitespace }.contains("processModeWorkspace")) + XCTAssertEqual( + topLevelOccurrenceCount(of: "AutomationWorkspaceView", in: workspaceLayers), + 1 + ) + XCTAssertEqual( + occurrenceCount(of: ".workspaceLayer(isActive:workspaceMode==.processes)", in: normalizedWorkspace), + 0 + ) + XCTAssertEqual(topLevelOccurrenceCount(of: automationLayer, in: normalizedWorkspace), 1) + XCTAssertEqual( + occurrenceCount( + of: ".workspaceLayer(isActive:workspaceMode==.automations)", + in: normalizedWorkspace + ), + 1 + ) + XCTAssertFalse(try containsRegex(#"\bswitch\b|\?"#, in: workspaceLayers)) + XCTAssertTrue(normalizedModifier.contains("opacity(isActive?1:0)")) + XCTAssertTrue(normalizedModifier.contains(".disabled(!isActive)")) + XCTAssertTrue(normalizedModifier.contains(".allowsHitTesting(isActive)")) + XCTAssertTrue(normalizedModifier.contains(".accessibilityHidden(!isActive)")) + } + + func testAutomationWorkspaceUsesAdaptiveNativePanesAndHorizontalContainment() throws { + let workspace = try appSource(at: "Sources/DevScope/Views/AutomationWorkspaceView.swift") + let table = try appSource(at: "Sources/DevScope/Views/AutomationTableView.swift") + let processTable = try appSource(at: "Sources/DevScope/Views/ProcessTableView.swift") + let workspaceCode = sanitizedSwiftSource(workspace) + let tableCode = sanitizedSwiftSource(table) + let processTableCode = sanitizedSwiftSource(processTable) + let geometryRegion = try sourceBlock(in: workspaceCode, startingWith: "GeometryReader") + let splitRegion = try sourceBlock(in: geometryRegion, startingWith: "HSplitView") + let horizontalScrollRegion = try sourceBlock( + in: tableCode, + startingWith: "ScrollView(.horizontal)" + ) + let normalizedGeometryCode = geometryRegion.filter { !$0.isWhitespace } + let normalizedSplitCode = splitRegion.filter { !$0.isWhitespace } + let normalizedHorizontalScrollCode = horizontalScrollRegion.filter { !$0.isWhitespace } + let processHorizontalScrollRegion = try sourceBlock( + in: processTableCode, + startingWith: "ScrollView(.horizontal)" + ) + let normalizedProcessHorizontalScrollCode = processHorizontalScrollRegion.filter { !$0.isWhitespace } + let paneFrameSignatures = [ + ".frame(minWidth:pane.railMinimum,idealWidth:pane.railPreferred)", + ".frame(minWidth:pane.tableMinimum,idealWidth:pane.tablePreferred)", + ".frame(minWidth:pane.detailMinimum,idealWidth:pane.detailPreferred)", + ] + let panePrioritySignatures = [ + ".layoutPriority(pane.tablePriority)", + ".layoutPriority(pane.detailPriority)", + ] + + XCTAssertTrue(normalizedGeometryCode.contains("AutomationWorkspaceLayoutPolicy.constraints")) + XCTAssertTrue(normalizedHorizontalScrollCode.contains("header")) + for scrollCode in [normalizedHorizontalScrollCode, normalizedProcessHorizontalScrollCode] { + XCTAssertTrue(scrollCode.contains(".scrollTargetLayout()")) + XCTAssertTrue(scrollCode.contains( + ".scrollPosition(id:$verticalScrollTargetID,anchor:.center)" + )) + XCTAssertFalse(scrollCode.contains("ScrollViewReader")) + XCTAssertFalse(scrollCode.contains(".focusable()")) + XCTAssertFalse(scrollCode.contains(".focused(")) + } + XCTAssertTrue(normalizedHorizontalScrollCode.contains("ScrollView(.vertical)")) + XCTAssertTrue(normalizedHorizontalScrollCode.contains( + "ForEach(Array(rows.enumerated()),id:\\.element.id)" + )) + XCTAssertTrue(normalizedHorizontalScrollCode.contains(".id(row.id)")) + for signature in paneFrameSignatures { + XCTAssertEqual(occurrenceCount(of: signature, in: normalizedSplitCode), 1) + } + XCTAssertEqual( + paneFrameSignatures.reduce(0) { + $0 + occurrenceCount(of: $1, in: normalizedSplitCode) + }, + 3 + ) + for signature in panePrioritySignatures { + XCTAssertEqual(occurrenceCount(of: signature, in: normalizedSplitCode), 1) + } + XCTAssertEqual( + panePrioritySignatures.reduce(0) { + $0 + occurrenceCount(of: $1, in: normalizedSplitCode) + }, + 2 + ) + XCTAssertFalse(normalizedSplitCode.contains("maxWidth:")) + XCTAssertFalse(try containsRegex( + #"idealWidth\s*:\s*[-+]?(?:\d+(?:\.\d*)?|\.\d+)"#, + in: splitRegion + )) + XCTAssertFalse(try containsRegex( + #"@State(?:\s*\([^)]*\))?\s+(?:private\s+)?var\s+\w*(?:divider|split(?:ter)?Position|pane(?:s)?Width|railWidth|tableWidth|detailWidth)\w*"#, + in: workspaceCode, + options: [.caseInsensitive] + )) + XCTAssertFalse(try containsRegex( + #"\.onChange\s*\(\s*of\s*:\s*(?:(?:\w+\.)*size\.width|proxy\.size(?!\.)|(?:\w+\.)*\w*(?:width|divider|split(?:ter)?position|paneposition)\w*)\s*\)"#, + in: workspaceCode, + options: [.caseInsensitive] + )) + XCTAssertFalse(try containsRegex( + #"\b(?:\w*(?:divider|split(?:ter)?position)\w*|(?:pane|rail|table|detail)\w*width\w*)\s*="#, + in: workspaceCode, + options: [.caseInsensitive] + )) + } + + func testProcessTerminationMenuKeepsForceActionsInOneNativeMenu() throws { + let source = try appSource(at: "Sources/DevScope/Views/ProcessCommandBar.swift") + + XCTAssertTrue(source.contains("Label(\"Force Kill Process…\", systemImage: \"bolt.fill\")")) + XCTAssertTrue(source.contains("Label(\"Force Kill Process Tree…\", systemImage: \"bolt.horizontal.fill\")")) + XCTAssertFalse(source.contains("Menu(\"Force Kill\")")) + } + + func testProcessTableCommandCopyUsesTheDurableRecoveryPath() throws { + let table = try appSource(at: "Sources/DevScope/Views/ProcessTableView.swift") + let content = try appSource(at: "Sources/DevScope/Views/ContentView.swift") + + XCTAssertTrue(table.contains("let copyCommand: (ClassifiedDevProcess) -> Void")) + XCTAssertTrue(table.contains("copyCommand(item)")) + XCTAssertFalse(table.contains("copy(item.process.command)")) + XCTAssertTrue(content.contains("copyCommand: copyCommand")) + } + + func testPartialMutationRecoveryLocationsOfferCopyAndFinderActions() throws { + let detail = try appSource(at: "Sources/DevScope/Views/AutomationDetailView.swift") + + XCTAssertTrue(detail.contains("Button(\"Reveal in Finder\")")) + XCTAssertTrue(detail.contains("Button(\"Copy Path\")")) + XCTAssertTrue(detail.contains("recoveryHandles")) + } + + func testSwiftSourceSanitizerPreservesStructureAroundLineCommentsAndEscapedStrings() { + let source = ###""" + GeometryReader { + let url = "https://example.com/{ignored}" + let escaped = "quote: \" }" + // HSplitView { decoy } + HSplitView { pane } + } + """### + + let sanitized = sanitizedSwiftSource(source) + + XCTAssertEqual(sanitized.count, source.count) + XCTAssertEqual( + sanitized.enumerated().compactMap { $0.element.isNewline ? $0.offset : nil }, + source.enumerated().compactMap { $0.element.isNewline ? $0.offset : nil } + ) + XCTAssertFalse(sanitized.contains("https://example.com/{ignored}")) + XCTAssertFalse(sanitized.contains("HSplitView { decoy }")) + XCTAssertTrue(sanitized.contains("HSplitView { pane }")) + XCTAssertEqual(sanitized.filter { $0 == "{" }.count, 2) + XCTAssertEqual(sanitized.filter { $0 == "}" }.count, 2) + } + + func testSwiftSourceSanitizerIgnoresNestedCommentsAndMultilineRawStrings() { + let source = ####""" + Root { + /* outer { HSplitView { decoy } /* nested { } */ still ignored } */ + let multiline = """ + HSplitView { multilineDecoy } + /* string content is not a comment */ + """ + let raw = ##"ScrollView(.horizontal) { rawDecoy } \##" escapedRawQuote { }"## + let rawMultiline = #""" + GeometryReader { rawMultilineDecoy } + """# + HSplitView { live } + } + """#### + + let sanitized = sanitizedSwiftSource(source) + + XCTAssertEqual(sanitized.count, source.count) + XCTAssertFalse(sanitized.contains("decoy")) + XCTAssertFalse(sanitized.contains("multilineDecoy")) + XCTAssertFalse(sanitized.contains("rawDecoy")) + XCTAssertFalse(sanitized.contains("rawMultilineDecoy")) + XCTAssertTrue(sanitized.contains("HSplitView { live }")) + XCTAssertEqual(sanitized.filter { $0 == "{" }.count, 2) + XCTAssertEqual(sanitized.filter { $0 == "}" }.count, 2) + } + + func testSwiftSourceSanitizerIgnoresNormalAndMultilineNestedInterpolationStrings() throws { + let source = ####""" + Root { + let value = "prefix \(flag + ? "HSplitView { nestedMarker /* unmatched-looking }" + : "ScrollView(.horizontal) { pane.tableMinimum }") suffix" + let multiline = """ + prefix \(flag + ? """ + ScrollView(.horizontal) { multilineNestedMarker /* unmatched-looking } */ + """ + : "pane.detailPreferred { multilineFallbackMarker }") suffix + """ + HSplitView { live } + } + """#### + + let sanitized = sanitizedSwiftSource(source) + let block = try sanitizedSourceBlock(in: source, startingWith: "Root") + + XCTAssertEqual(sanitized.count, source.count) + XCTAssertEqual( + sanitized.enumerated().compactMap { $0.element.isNewline ? $0.offset : nil }, + source.enumerated().compactMap { $0.element.isNewline ? $0.offset : nil } + ) + XCTAssertFalse(sanitized.contains("nestedMarker")) + XCTAssertFalse(sanitized.contains("multilineNestedMarker")) + XCTAssertFalse(sanitized.contains("multilineFallbackMarker")) + XCTAssertFalse(sanitized.contains("ScrollView(.horizontal)")) + XCTAssertFalse(sanitized.contains("pane.tableMinimum")) + XCTAssertFalse(sanitized.contains("pane.detailPreferred")) + XCTAssertTrue(sanitized.contains("HSplitView { live }")) + XCTAssertEqual(sanitized.filter { $0 == "{" }.count, 2) + XCTAssertEqual(sanitized.filter { $0 == "}" }.count, 2) + XCTAssertTrue(block.contains("HSplitView { live }")) + XCTAssertEqual(block.filter { $0 == "{" }.count, 2) + XCTAssertEqual(block.filter { $0 == "}" }.count, 2) + } + + func testSwiftSourceSanitizerIgnoresRawHashNestedInterpolationsAndComments() throws { + let source = ######""" + Root { + let value = ###""" + prefix \###( + flag + ? ###""" + HSplitView { rawNestedMarker /* unmatched-looking } */ + \###("ScrollView(.horizontal) { nestedInterpolationMarker }") + """### + : { + // ScrollView(.horizontal) { lineCommentMarker } + /* pane.detailPreferred { outer /* HSplitView { nestedCommentMarker } */ } */ + return "pane.tableMinimum { ordinaryNestedMarker }" + }() + ) suffix + """### + HSplitView { live } + } + """###### + + let sanitized = sanitizedSwiftSource(source) + let block = try sanitizedSourceBlock(in: source, startingWith: "Root") + + XCTAssertEqual(sanitized.count, source.count) + XCTAssertEqual( + sanitized.enumerated().compactMap { $0.element.isNewline ? $0.offset : nil }, + source.enumerated().compactMap { $0.element.isNewline ? $0.offset : nil } + ) + for leakedMarker in [ + "rawNestedMarker", + "nestedInterpolationMarker", + "lineCommentMarker", + "nestedCommentMarker", + "pane.detailPreferred", + "pane.tableMinimum", + "ordinaryNestedMarker", + "ScrollView(.horizontal)", + ] { + XCTAssertFalse(sanitized.contains(leakedMarker), "Leaked \(leakedMarker)") + } + XCTAssertTrue(sanitized.contains("HSplitView { live }")) + XCTAssertEqual(sanitized.filter { $0 == "{" }.count, 2) + XCTAssertEqual(sanitized.filter { $0 == "}" }.count, 2) + XCTAssertTrue(block.contains("HSplitView { live }")) + XCTAssertEqual(block.filter { $0 == "{" }.count, 2) + XCTAssertEqual(block.filter { $0 == "}" }.count, 2) + } + + func testSanitizedSourceBlockFindsLiveMarkerAfterCommentAndStringDecoys() throws { + let source = ####""" + // GeometryReader { commentedLineDecoy } + let text = "GeometryReader { stringDecoy }" + /* GeometryReader { commentedBlockDecoy /* nested { } */ } */ + GeometryReader { + let closingBraceLiteral = "} stringInsideDecoy" + HSplitView { live } + } + trailing { unrelated } + """#### + + let block = try sanitizedSourceBlock(in: source, startingWith: "GeometryReader") + + XCTAssertTrue(block.contains("HSplitView { live }")) + XCTAssertFalse(block.contains("commentedLineDecoy")) + XCTAssertFalse(block.contains("commentedBlockDecoy")) + XCTAssertFalse(block.contains("stringInsideDecoy")) + XCTAssertFalse(block.contains("trailing")) + XCTAssertEqual(block.filter { $0 == "{" }.count, 2) + XCTAssertEqual(block.filter { $0 == "}" }.count, 2) + } + + func testBalancedRegionIgnoresSanitizedDelimiterDecoysAndStopsBeforeTrailingOverride() throws { + let source = ####""" + static func make() { + let sources: [any AutomationSource] = [ + /* ] BackgroundTaskAutomationSource(diagnosticPolicy: .available) */ + "string ] ) BackgroundTaskAutomationSource(diagnosticPolicy: .available)", + BackgroundTaskAutomationSource( + runner: runner, + metadata: nestedCall(values: [one, two]) + ), + ] + BackgroundTaskAutomationSource(runner: runner, diagnosticPolicy: .available) + } + """#### + let sanitized = sanitizedSwiftSource(source) + let make = try sourceBlock(in: sanitized, startingWith: "static func make()") + let sources = try balancedRegion( + in: make, + startingWith: "let sources: [any AutomationSource] =", + opening: "[", + closing: "]" + ) + let initializer = try balancedRegion( + in: sources, + startingWith: "BackgroundTaskAutomationSource", + opening: "(", + closing: ")" + ) + + XCTAssertEqual(occurrenceCount(of: "BackgroundTaskAutomationSource", in: sources), 1) + XCTAssertTrue(initializer.contains("nestedCall(values: [one, two])")) + XCTAssertFalse(sources.contains("diagnosticPolicy: .available")) + } + + func testAutomationSourceCompositionAcceptsValidCurrentArray() throws { + let source = """ + [ + LaunchdAutomationSource( + fileSystem: fileSystem + ), + BackgroundTaskAutomationSource( + runner: runner, + diagnosticPolicy: .currentSystem + ), + LegacyLoginItemAutomationSource(adapter: adapter, currentUID: 501), + CronAutomationSource(commandRunner: runner), + ] + """ + + XCTAssertTrue(try isSafeAutomationSourceComposition(sanitizedSwiftSource(source))) + let omittedPolicy = source.replacingOccurrences( + of: ",\n diagnosticPolicy: .currentSystem", + with: "" + ) + XCTAssertTrue(try isSafeAutomationSourceComposition(sanitizedSwiftSource(omittedPolicy))) + } + + func testAutomationSourceCompositionRejectsTernaryNestedBackgroundConstructor() throws { + let source = """ + [ + LaunchdAutomationSource(fileSystem: fileSystem), + useDiagnostic + ? BackgroundTaskAutomationSource(runner: runner) + : backgroundSource, + LegacyLoginItemAutomationSource(adapter: adapter, currentUID: 501), + CronAutomationSource(commandRunner: runner), + ] + """ + + XCTAssertFalse(try isSafeAutomationSourceComposition(sanitizedSwiftSource(source))) + } + + func testAutomationSourceCompositionRejectsClosureAndWrapperNestedBackgroundConstructors() throws { + let source = """ + [ + LaunchdAutomationSource(fileSystem: fileSystem), + makeSource { BackgroundTaskAutomationSource(runner: runner) }, + LegacyLoginItemAutomationSource(adapter: adapter, currentUID: 501), + CronAutomationSource(commandRunner: runner), + ] + """ + + XCTAssertFalse(try isSafeAutomationSourceComposition(sanitizedSwiftSource(source))) + + let wrapped = source.replacingOccurrences( + of: "makeSource { BackgroundTaskAutomationSource(runner: runner) }", + with: "wrapper(BackgroundTaskAutomationSource(runner: runner))" + ) + XCTAssertFalse(try isSafeAutomationSourceComposition(sanitizedSwiftSource(wrapped))) + } + + func testAutomationSourceCompositionRejectsParenthesizedAlias() throws { + let source = """ + [ + LaunchdAutomationSource(fileSystem: fileSystem), + (backgroundSource), + LegacyLoginItemAutomationSource(adapter: adapter, currentUID: 501), + CronAutomationSource(commandRunner: runner), + ] + """ + + XCTAssertFalse(try isSafeAutomationSourceComposition(sanitizedSwiftSource(source))) + } + + func testAutomationSourceCompositionRejectsFunctionCallAlias() throws { + let source = """ + [ + LaunchdAutomationSource(fileSystem: fileSystem), + unsafeBackgroundSource(), + LegacyLoginItemAutomationSource(adapter: adapter, currentUID: 501), + CronAutomationSource(commandRunner: runner), + ] + """ + + XCTAssertFalse(try isSafeAutomationSourceComposition(sanitizedSwiftSource(source))) + } + + func testAutomationSourceCompositionRejectsExtraFifthElement() throws { + let source = """ + [ + LaunchdAutomationSource(fileSystem: fileSystem), + BackgroundTaskAutomationSource(runner: runner), + LegacyLoginItemAutomationSource(adapter: adapter, currentUID: 501), + CronAutomationSource(commandRunner: runner), + unsafeBackgroundSource(), + ] + """ + + XCTAssertFalse(try isSafeAutomationSourceComposition(sanitizedSwiftSource(source))) + } + + func testAutomationSourceCompositionRejectsAvailableAndIgnoresLexicalDecoys() throws { + let source = ####""" + [ + /* BackgroundTaskAutomationSource(runner: runner) */ + "BackgroundTaskAutomationSource(runner: runner)", + LaunchdAutomationSource(fileSystem: fileSystem), + BackgroundTaskAutomationSource( + runner: runner, + diagnosticPolicy: .available + ), + LegacyLoginItemAutomationSource(adapter: adapter, currentUID: 501), + CronAutomationSource(commandRunner: runner), + ] + """#### + + XCTAssertFalse(try isSafeAutomationSourceComposition(sanitizedSwiftSource(source))) + } + + func testTopLevelOccurrenceCountRejectsNestedClosureAndArgumentDecoys() throws { + let source = """ + ZStack { + // processModeWorkspace + processModeWorkspace + Group { processModeWorkspace } + wrapper(AutomationWorkspaceView()) + [AutomationWorkspaceView()] + AutomationWorkspaceView() + } + """ + let sanitized = sanitizedSwiftSource(source) + let block = try sourceBlock(in: sanitized, startingWith: "ZStack") + + XCTAssertEqual(topLevelOccurrenceCount(of: "processModeWorkspace", in: block), 1) + XCTAssertEqual(topLevelOccurrenceCount(of: "AutomationWorkspaceView", in: block), 1) + } + + func testAutomationCompositionCreatesBothTransactionAndRecoveryRoots() throws { + let source = try appSource(at: "Sources/DevScope/App/AutomationComposition.swift") + + XCTAssertTrue(source.contains( + "try? fileSystem.createDirectory(transactionRoot, permissions: 0o700)" + )) + XCTAssertTrue(source.contains( + "try? fileSystem.createDirectory(recoveryRoot, permissions: 0o700)" + )) + } + + func testAutomationCompositionUsesSafeCurrentSystemBackgroundDiagnosticPolicy() throws { + let source = try appSource(at: "Sources/DevScope/App/AutomationComposition.swift") + let sanitized = sanitizedSwiftSource(source) + let make = try sourceBlock(in: sanitized, startingWith: "static func make()") + let sources = try balancedRegion( + in: make, + startingWith: "let sources: [any AutomationSource] =", + opening: "[", + closing: "]" + ) + XCTAssertTrue(try isSafeAutomationSourceComposition(sources)) + } + + func testAccessSettingsExposeExactManualFullDiskAccessWorkflow() throws { + let repositoryRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let source = try String( + contentsOf: repositoryRoot.appendingPathComponent( + "Sources/DevScope/Views/SettingsView.swift"), + encoding: .utf8 + ) + + XCTAssertTrue(source.contains("FullDiskAccessGuidance(")) + XCTAssertTrue(source.contains("Bundle.main.bundleURL")) + XCTAssertTrue(source.contains("Reveal DevScope in Finder")) + XCTAssertTrue(source.contains("activateFileViewerSelecting([Bundle.main.bundleURL])")) + XCTAssertTrue(source.contains("Array(fullDiskAccessGuidance.steps.enumerated())")) + XCTAssertTrue(source.contains("Text(\"\\(index + 1).\")")) + XCTAssertTrue(source.contains("FullDiskAccessSettingsRoute.open(")) + XCTAssertTrue(source.contains("using: NSWorkspace.shared.open")) + XCTAssertTrue(source.contains("fallback: openPrivacyAndSecurity")) + XCTAssertTrue(source.contains("if !fullDiskAccessGuidance.isSandboxed")) + XCTAssertFalse(source.contains("return [.fullDiskAccess, .privacySecurity]")) + XCTAssertTrue(source.contains("if shouldShowFullDiskAccessGuidance")) + XCTAssertTrue(source.contains("ProcessAccessStatus.isSandboxed")) + XCTAssertTrue(source.contains("neededAccessActions.contains(.fullDiskAccess)")) + let accessPanel = try XCTUnwrap(source.range(of: "SettingsPanel(title: \"Process Access\")")) + let accessPrefix = source[..$BUNDLE_ID")) + XCTAssertFalse(source.contains("cat >\"$INFO_PLIST\" <\"$INFO_PLIST\" </dev/null"#)) + } + + func testTransientActionFeedbackRequestsAnAccessibilityAnnouncement() throws { + let repositoryRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let source = try String( + contentsOf: repositoryRoot.appendingPathComponent("Sources/DevScope/Views/ContentView.swift"), + encoding: .utf8 + ) + + XCTAssertTrue(source.contains("notification: .announcementRequested")) + XCTAssertTrue(source.contains(".announcement: announcement")) + } + + func testSettingsTabsProvideScrollableContent() throws { + let repositoryRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let source = try String( + contentsOf: repositoryRoot.appendingPathComponent( + "Sources/DevScope/Views/SettingsView.swift"), + encoding: .utf8 + ) + let scrollViewCount = source.components(separatedBy: "ScrollView {").count - 1 + + XCTAssertGreaterThanOrEqual(scrollViewCount, 3) + } + + func testAppUsesSingleWindowSceneToAvoidDuplicateProcessScanners() throws { + let repositoryRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let appSource = try String( + contentsOf: repositoryRoot.appendingPathComponent("Sources/DevScope/App/DevScopeApp.swift"), + encoding: .utf8 + ) + + XCTAssertFalse(appSource.contains("WindowGroup(")) + XCTAssertTrue(appSource.contains("Window(\"DevScope\", id: \"main\")")) + } + + func testGeneratedAppIconRepresentationsMatchFilenameDimensions() throws { + let repositoryRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let outputDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: outputDirectory) } + + let process = Process() + let output = Pipe() + process.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun") + process.arguments = [ + "swift", + repositoryRoot.appendingPathComponent("script/generate_app_icon.swift").path, + outputDirectory.path, + ] + process.standardOutput = output + process.standardError = output + try process.run() + let capturedOutput = output.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + XCTAssertEqual( + process.terminationStatus, + 0, + String(decoding: capturedOutput, as: UTF8.self) + ) + + let expectedDimensions = [ + "icon_16x16.png": 16, + "icon_16x16@2x.png": 32, + "icon_32x32.png": 32, + "icon_32x32@2x.png": 64, + "icon_128x128.png": 128, + "icon_128x128@2x.png": 256, + "icon_256x256.png": 256, + "icon_256x256@2x.png": 512, + "icon_512x512.png": 512, + "icon_512x512@2x.png": 1024, + ] + + for (filename, expectedPixels) in expectedDimensions { + let data = try Data(contentsOf: outputDirectory.appendingPathComponent(filename)) + let representation = try XCTUnwrap(NSBitmapImageRep(data: data), filename) + XCTAssertEqual(representation.pixelsWide, expectedPixels, filename) + XCTAssertEqual(representation.pixelsHigh, expectedPixels, filename) + } + } + + func testReleaseScriptsReferenceTrackedEntitlementsDirectoryWithExactCase() throws { + let repositoryRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let scriptPaths = [ + "script/build_release_bundle.sh", + "script/install_local_full_build.sh", + "script/package_developer_id.sh", + ] + + for scriptPath in scriptPaths { + let contents = try String( + contentsOf: repositoryRoot.appendingPathComponent(scriptPath), + encoding: .utf8 + ) + XCTAssertFalse( + contents.contains("$ROOT_DIR/Config/"), + "\(scriptPath) must use the tracked lowercase config directory" + ) + XCTAssertTrue( + contents.contains("$ROOT_DIR/config/"), + "\(scriptPath) must reference the tracked entitlement path" + ) + } + } + + func testNonSandboxValidationRejectsSandboxedBundle() throws { + let temporaryRoot = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: temporaryRoot) } + + let appBundle = try makeSignedTestBundle(at: temporaryRoot, sandboxed: true) + let result = try runValidation(for: appBundle, requiresSandbox: false) + + XCTAssertNotEqual(result.status, 0, result.output) + XCTAssertTrue(result.output.contains("App Sandbox entitlement must be absent"), result.output) + } + + func testSandboxValidationRejectsFalseSandboxValueBesideTrueEntitlement() throws { + let temporaryRoot = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: temporaryRoot) } + + let appBundle = try makeSignedTestBundle( + at: temporaryRoot, + entitlementBody: """ + com.apple.security.app-sandbox + com.apple.security.network.client + """ + ) + let result = try runValidation(for: appBundle, requiresSandbox: true) + + XCTAssertNotEqual(result.status, 0, result.output) + XCTAssertTrue(result.output.contains("App Sandbox entitlement is not true"), result.output) + + let source = try appSource(at: "script/validate_release_bundle.sh") + XCTAssertTrue(source.contains("plutil -extract 'com\\.apple\\.security\\.app-sandbox' raw")) + XCTAssertFalse(source.contains("grep -A3 \"com.apple.security.app-sandbox\"")) + } + + func testSandboxValidationRejectsMissingUserSelectedReadWriteEntitlement() throws { + let temporaryRoot = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: temporaryRoot) } + + let appBundle = try makeSignedTestBundle( + at: temporaryRoot, + entitlementBody: """ + com.apple.security.app-sandbox + """ + ) + let result = try runValidation(for: appBundle, requiresSandbox: true) + + XCTAssertNotEqual(result.status, 0, result.output) + XCTAssertTrue( + result.output.contains("user-selected read-write entitlement missing"), + result.output + ) + } + + func testAppStorePackagingPinsSandboxEntitlementsAndValidation() throws { + let source = try appSource(at: "script/package_app_store.sh") + + XCTAssertTrue(source.contains("security cms -D -i")) + XCTAssertTrue(source.contains("ApplicationIdentifierPrefix.0")) + XCTAssertTrue(source.contains("$PROFILE_PREFIX.$DEVSCOPE_BUNDLE_ID")) + XCTAssertFalse(source.contains("$PROFILE_TEAM.$DEVSCOPE_BUNDLE_ID")) + XCTAssertTrue(source.contains("com.apple.application-identifier")) + XCTAssertTrue(source.contains("com.apple.developer.team-identifier")) + XCTAssertTrue(source.contains("DEVSCOPE_ENTITLEMENTS=\"$APP_STORE_ENTITLEMENTS\"")) + XCTAssertTrue(source.contains("DEVSCOPE_REQUIRE_SANDBOX=1")) + } + + func testReleaseWorkflowPinsThirdPartyActionsByCommit() throws { + let source = try appSource(at: ".github/workflows/release-gates.yml") + + XCTAssertTrue( + source.contains("actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd") + ) + XCTAssertFalse(source.contains("actions/checkout@v6")) + } + + func testOpenSourceReadinessCheckerRejectsRepositoryWithoutLicense() throws { + let temporaryRoot = try makeOpenSourceReadinessFixture() + defer { try? FileManager.default.removeItem(at: temporaryRoot) } + try FileManager.default.removeItem(at: temporaryRoot.appendingPathComponent("LICENSE")) + + let result = try runOpenSourceReadinessChecker(root: temporaryRoot) + + XCTAssertNotEqual(result.status, 0, result.output) + XCTAssertTrue(result.output.contains("LICENSE"), result.output) + } + + func testOpenSourceReadinessCheckerRejectsMissingPublicIdentity() throws { + let temporaryRoot = try makeOpenSourceReadinessFixture() + defer { try? FileManager.default.removeItem(at: temporaryRoot) } + try Data("DevScope\nCopyright 2026 unknown\n".utf8).write( + to: temporaryRoot.appendingPathComponent("NOTICE") + ) + + let result = try runOpenSourceReadinessChecker(root: temporaryRoot) + + XCTAssertNotEqual(result.status, 0, result.output) + XCTAssertTrue(result.output.contains("public identity"), result.output) + } + + func testOpenSourceReadinessCheckerRejectsInternalPublicationMaterial() throws { + let temporaryRoot = try makeOpenSourceReadinessFixture() + defer { try? FileManager.default.removeItem(at: temporaryRoot) } + let internalDirectory = temporaryRoot.appendingPathComponent( + "docs/monetization", + isDirectory: true + ) + try FileManager.default.createDirectory( + at: internalDirectory, + withIntermediateDirectories: true + ) + try Data("private economics".utf8).write( + to: internalDirectory.appendingPathComponent("strategy.md") + ) + + let result = try runOpenSourceReadinessChecker(root: temporaryRoot) + + XCTAssertNotEqual(result.status, 0, result.output) + XCTAssertTrue(result.output.contains("internal publication material"), result.output) + XCTAssertTrue(result.output.contains("docs/monetization"), result.output) + } + + func testOpenSourceReadinessCheckerRejectsUnpinnedGitHubAction() throws { + let temporaryRoot = try makeOpenSourceReadinessFixture() + defer { try? FileManager.default.removeItem(at: temporaryRoot) } + let workflows = temporaryRoot.appendingPathComponent( + ".github/workflows", isDirectory: true + ) + try Data( + """ + name: Unsafe fixture + on: [push] + permissions: + contents: read + jobs: + fixture: + runs-on: macos-latest + steps: + - uses: actions/checkout@v6 + """.utf8 + ).write(to: workflows.appendingPathComponent("unpinned-fixture.yml")) + + let result = try runOpenSourceReadinessChecker(root: temporaryRoot) + + XCTAssertNotEqual(result.status, 0, result.output) + XCTAssertTrue(result.output.contains("not pinned to a full commit SHA"), result.output) + XCTAssertTrue(result.output.contains("actions/checkout@v6"), result.output) + } + + func testOpenSourceReadinessCheckerAcceptsCurrentPublicSurface() throws { + let temporaryRoot = try makeOpenSourceReadinessFixture() + defer { try? FileManager.default.removeItem(at: temporaryRoot) } + + let result = try runOpenSourceReadinessChecker(root: temporaryRoot) + + XCTAssertEqual(result.status, 0, result.output) + XCTAssertTrue(result.output.contains("Open-source readiness check passed."), result.output) + } + + func testOpenSourceReadinessCheckerRejectsWorkflowWriteAllPermission() throws { + let temporaryRoot = try makeOpenSourceReadinessFixture() + defer { try? FileManager.default.removeItem(at: temporaryRoot) } + let workflow = temporaryRoot.appendingPathComponent( + ".github/workflows/release-gates.yml" + ) + try Data( + """ + name: Unsafe permissions fixture + on: [push] + permissions: write-all + jobs: + fixture: + runs-on: macos-latest + steps: [] + """.utf8 + ).write(to: workflow) + + let result = try runOpenSourceReadinessChecker(root: temporaryRoot) + + XCTAssertNotEqual(result.status, 0, result.output) + XCTAssertTrue(result.output.contains("permission"), result.output) + } + + func testOpenSourceReadinessCheckerRejectsJobWritePermissionOverride() throws { + let temporaryRoot = try makeOpenSourceReadinessFixture() + defer { try? FileManager.default.removeItem(at: temporaryRoot) } + let workflow = temporaryRoot.appendingPathComponent( + ".github/workflows/release-gates.yml" + ) + try Data( + """ + name: Unsafe job permissions fixture + on: [push] + permissions: + contents: read + jobs: + fixture: + permissions: + contents: write + runs-on: macos-latest + steps: [] + """.utf8 + ).write(to: workflow) + + let result = try runOpenSourceReadinessChecker(root: temporaryRoot) + + XCTAssertNotEqual(result.status, 0, result.output) + XCTAssertTrue(result.output.contains("unexpected write permission"), result.output) + XCTAssertTrue(result.output.contains("job fixture"), result.output) + } + + func testProvisioningProfileValidationChecksReleaseSemantics() throws { + let source = try appSource(at: "script/validate_release_bundle.sh") + + XCTAssertTrue(source.contains("security cms -D -i")) + XCTAssertTrue(source.contains("ExpirationDate")) + XCTAssertTrue(source.contains("application-identifier")) + XCTAssertTrue(source.contains("ApplicationIdentifierPrefix.0")) + XCTAssertTrue(source.contains("$PROFILE_PREFIX.$BUNDLE_ID")) + XCTAssertFalse(source.contains("$PROFILE_TEAM.$BUNDLE_ID")) + XCTAssertTrue(source.contains("Entitlements.com\\.apple\\.application-identifier")) + XCTAssertTrue(source.contains("TeamIdentifier")) + XCTAssertTrue(source.contains("DeveloperCertificates")) + XCTAssertTrue(source.contains("--extract-certificates")) + XCTAssertTrue(source.contains("profile App Sandbox entitlement is not true")) + } + + func testDeveloperIDPackagingRequiresNotarizationUnlessExplicitlyOverridden() throws { + let source = try appSource(at: "script/package_developer_id.sh") + + XCTAssertTrue(source.contains("DEVSCOPE_ALLOW_UNNOTARIZED")) + XCTAssertTrue(source.contains("notary profile is required for a release artifact")) + XCTAssertEqual(source.components(separatedBy: "--norsrc --noextattr").count - 1, 2) + } + + func testCommunityPreviewPackagingIsExplicitlyUnnotarizedAndChecksummed() throws { + let source = try appSource(at: "script/package_community_preview.sh") + + XCTAssertTrue(source.contains("DEVSCOPE_ACKNOWLEDGE_UNNOTARIZED_PREVIEW")) + XCTAssertTrue(source.contains("UNNOTARIZED COMMUNITY PREVIEW")) + XCTAssertTrue(source.contains("Do not disable Gatekeeper")) + XCTAssertTrue(source.contains("DEVSCOPE_SIGN_IDENTITY=\"-\"")) + XCTAssertTrue(source.contains("DEVSCOPE_ENTITLEMENTS=\"$ROOT_DIR/config/DevScopeDeveloperID.entitlements\"")) + XCTAssertTrue(source.contains("DEVSCOPE_REQUIRE_SANDBOX=0")) + XCTAssertTrue(source.contains("DEVSCOPE_REQUIRE_APPLE_EVENTS=1")) + XCTAssertTrue(source.contains("--norsrc --noextattr")) + XCTAssertTrue(source.contains("shasum -a 256")) + XCTAssertTrue(source.contains("zipinfo -1")) + } + + func testValidationRejectsBundleMissingRequiredArchitecture() throws { + let temporaryRoot = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: temporaryRoot) } + + let appBundle = try makeSignedTestBundle(at: temporaryRoot, sandboxed: false) + let result = try runValidation( + for: appBundle, + requiresSandbox: false, + requiredArchitectures: "not-a-real-architecture" + ) + + XCTAssertNotEqual(result.status, 0, result.output) + XCTAssertTrue(result.output.contains("required architecture missing"), result.output) + } + + func testReleaseValidationRejectsMissingPublicLicenseNotice() throws { + let temporaryRoot = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: temporaryRoot) } + + let appBundle = try makeSignedTestBundle(at: temporaryRoot, sandboxed: false) + try FileManager.default.removeItem( + at: appBundle.appendingPathComponent("Contents/Resources/LICENSE") + ) + + let result = try runValidation(for: appBundle, requiresSandbox: false) + + XCTAssertNotEqual(result.status, 0, result.output) + XCTAssertTrue(result.output.contains("LICENSE missing"), result.output) + } + + func testDeveloperIDPackageRebuildsArchiveAfterStapling() throws { + let fileManager = FileManager.default + let repositoryRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let temporaryRoot = fileManager.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? fileManager.removeItem(at: temporaryRoot) } + + let scriptDirectory = temporaryRoot.appendingPathComponent("script", isDirectory: true) + let fakeBinDirectory = temporaryRoot.appendingPathComponent("bin", isDirectory: true) + let distDirectory = temporaryRoot.appendingPathComponent("dist", isDirectory: true) + try fileManager.createDirectory(at: scriptDirectory, withIntermediateDirectories: true) + try fileManager.createDirectory(at: fakeBinDirectory, withIntermediateDirectories: true) + + try fileManager.copyItem( + at: repositoryRoot.appendingPathComponent("script/package_developer_id.sh"), + to: scriptDirectory.appendingPathComponent("package_developer_id.sh") + ) + try writeExecutable( + """ + #!/usr/bin/env bash + set -euo pipefail + app="$DEVSCOPE_DIST_DIR/DevScope.app" + mkdir -p "$app" + echo "$app" + """, + to: scriptDirectory.appendingPathComponent("build_release_bundle.sh") + ) + try writeExecutable( + "#!/usr/bin/env bash\nexit 0\n", + to: scriptDirectory.appendingPathComponent("validate_release_bundle.sh")) + try writeExecutable( + """ + #!/usr/bin/env bash + echo "$DEVSCOPE_DEVELOPER_ID_SIGN_IDENTITY" + """, + to: fakeBinDirectory.appendingPathComponent("security") + ) + try writeExecutable( + """ + #!/usr/bin/env bash + set -euo pipefail + app="${@: -2:1}" + archive="${@: -1}" + if [[ -f "$app/stapled-ticket" ]]; then + echo stapled >> "$MOCK_DITTO_LOG" + else + echo unstapled >> "$MOCK_DITTO_LOG" + fi + touch "$archive" + """, + to: fakeBinDirectory.appendingPathComponent("ditto") + ) + try writeExecutable( + """ + #!/usr/bin/env bash + set -euo pipefail + if [[ "${1:-}" == "stapler" && "${2:-}" == "staple" ]]; then + touch "$3/stapled-ticket" + fi + """, + to: fakeBinDirectory.appendingPathComponent("xcrun") + ) + try writeExecutable( + "#!/usr/bin/env bash\nexit 0\n", to: fakeBinDirectory.appendingPathComponent("spctl")) + + let logURL = temporaryRoot.appendingPathComponent("ditto.log") + let process = Process() + let output = Pipe() + process.executableURL = URL(fileURLWithPath: "/bin/bash") + process.arguments = [scriptDirectory.appendingPathComponent("package_developer_id.sh").path] + process.standardOutput = output + process.standardError = output + process.environment = ProcessInfo.processInfo.environment.merging([ + "PATH": "\(fakeBinDirectory.path):/usr/bin:/bin", + "DEVSCOPE_DEVELOPER_ID_SIGN_IDENTITY": "Developer ID Application: Test", + "DEVSCOPE_NOTARY_KEYCHAIN_PROFILE": "test-profile", + "DEVSCOPE_DIST_DIR": distDirectory.path, + "MOCK_DITTO_LOG": logURL.path, + ]) { _, replacement in replacement } + + try process.run() + let capturedOutput = output.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + + XCTAssertEqual( + process.terminationStatus, + 0, + String(decoding: capturedOutput, as: UTF8.self) + ) + let archiveStates = try String(contentsOf: logURL, encoding: .utf8) + .split(whereSeparator: \.isNewline) + .map(String.init) + XCTAssertEqual(archiveStates, ["unstapled", "stapled"]) + } + + private func writeExecutable(_ contents: String, to url: URL) throws { + try Data(contents.utf8).write(to: url) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + } + + private func makeOpenSourceReadinessFixture() throws -> URL { + let fileManager = FileManager.default + let repositoryRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let temporaryRoot = fileManager.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try fileManager.createDirectory(at: temporaryRoot, withIntermediateDirectories: true) + + let paths = [ + ".github", ".gitleaks.toml", "CHANGELOG.md", "CODE_OF_CONDUCT.md", + "CONTRIBUTING.md", "GOVERNANCE.md", "LICENSE", "NOTICE", "PRIVACY.md", + "README.md", "SECURITY.md", "SUPPORT.md", "THIRD_PARTY_NOTICES.md", + "TRADEMARKS.md", "docs", "script", + ] + for path in paths { + try fileManager.copyItem( + at: repositoryRoot.appendingPathComponent(path), + to: temporaryRoot.appendingPathComponent(path) + ) + } + return temporaryRoot + } + + private func runOpenSourceReadinessChecker( + root: URL, + skipDependencyCheck: Bool = true + ) throws -> ( + status: Int32, output: String + ) { + let repositoryRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let checker = repositoryRoot.appendingPathComponent( + "script/check_open_source_readiness.sh" + ) + let process = Process() + let output = Pipe() + process.executableURL = URL(fileURLWithPath: "/bin/bash") + process.arguments = [checker.path, "--root", root.path] + if skipDependencyCheck { + process.arguments?.append("--skip-dependency-check") + } + process.standardOutput = output + process.standardError = output + try process.run() + let capturedOutput = output.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + return ( + process.terminationStatus, + String(decoding: capturedOutput, as: UTF8.self) + ) + } + + private func appSource(at path: String) throws -> String { + let repositoryRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + return try String(contentsOf: repositoryRoot.appendingPathComponent(path), encoding: .utf8) + } + + private func entitlementDictionary(at path: String) throws -> [String: Any] { + let data = try Data(appSource(at: path).utf8) + return try XCTUnwrap( + PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any] + ) + } + + private func sanitizedSwiftSource(_ source: String) -> String { + var sanitizer = SwiftSourceLexicalSanitizer(source: source) + return sanitizer.sanitizedSource() + } + + private func sourceBlock(in source: String, startingWith marker: String) throws -> String { + let markerRange = try XCTUnwrap(source.range(of: marker)) + let openingBrace = try XCTUnwrap(source[markerRange.upperBound...].firstIndex(of: "{")) + var depth = 0 + + for index in source.indices[openingBrace...] { + switch source[index] { + case "{": + depth += 1 + case "}": + depth -= 1 + if depth == 0 { + return String(source[openingBrace...index]) + } + default: + break + } + } + + throw NSError( + domain: "ReleaseScriptTests", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Unterminated source block after \(marker)"] + ) + } + + private func balancedRegion( + in source: String, + startingWith marker: String, + opening: Character, + closing: Character + ) throws -> String { + let markerRange = try XCTUnwrap(source.range(of: marker)) + let openingIndex = try XCTUnwrap(source[markerRange.upperBound...].firstIndex(of: opening)) + var depth = 0 + + for index in source.indices[openingIndex...] { + if source[index] == opening { + depth += 1 + } else if source[index] == closing { + depth -= 1 + if depth == 0 { + return String(source[openingIndex...index]) + } + } + } + + throw NSError( + domain: "ReleaseScriptTests", + code: 2, + userInfo: [NSLocalizedDescriptionKey: "Unterminated balanced region after \(marker)"] + ) + } + + private func isSafeAutomationSourceComposition( + _ sanitizedArray: String + ) throws -> Bool { + let elements = try topLevelArrayElements(in: sanitizedArray) + let normalizedElements = elements.map { $0.filter { !$0.isWhitespace } } + guard normalizedElements.count == 4 else { return false } + let safeBackgroundExpressions: Set = [ + "BackgroundTaskAutomationSource(runner:runner)", + "BackgroundTaskAutomationSource(runner:runner,diagnosticPolicy:.currentSystem)", + ] + + return normalizedElements.filter { + isDirectInitializer($0, named: "LaunchdAutomationSource") + }.count == 1 + && normalizedElements.filter(safeBackgroundExpressions.contains).count == 1 + && normalizedElements.filter { + isDirectInitializer($0, named: "LegacyLoginItemAutomationSource") + }.count == 1 + && normalizedElements.filter { + isDirectInitializer($0, named: "CronAutomationSource") + }.count == 1 + } + + private func isDirectInitializer(_ expression: String, named typeName: String) -> Bool { + guard expression.hasPrefix("\(typeName)(") else { return false } + let opening = expression.index(expression.startIndex, offsetBy: typeName.count) + var depth = 0 + + for index in expression.indices[opening...] { + if expression[index] == "(" { + depth += 1 + } else if expression[index] == ")" { + depth -= 1 + if depth == 0 { + return index == expression.index(before: expression.endIndex) + } + } + if depth < 0 { return false } + } + return false + } + + private func topLevelArrayElements(in sanitizedArray: String) throws -> [String] { + let trimmed = sanitizedArray.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.first == "[", trimmed.last == "]" else { + throw NSError( + domain: "ReleaseScriptTests", + code: 3, + userInfo: [NSLocalizedDescriptionKey: "Expected one balanced array expression"] + ) + } + + let contentStart = trimmed.index(after: trimmed.startIndex) + let contentEnd = trimmed.index(before: trimmed.endIndex) + let content = trimmed[contentStart..= 0, parenthesisDepth >= 0, bracketDepth >= 0 else { + throw NSError( + domain: "ReleaseScriptTests", + code: 4, + userInfo: [NSLocalizedDescriptionKey: "Unbalanced top-level array element"] + ) + } + } + + guard braceDepth == 0, parenthesisDepth == 0, bracketDepth == 0 else { + throw NSError( + domain: "ReleaseScriptTests", + code: 5, + userInfo: [NSLocalizedDescriptionKey: "Unterminated top-level array element"] + ) + } + appendElement(endingAt: content.endIndex) + return elements + } + + private func topLevelOccurrenceCount(of needle: String, in block: String) -> Int { + guard !needle.isEmpty else { return 0 } + var braceDepth = 0 + var parenthesisDepth = 0 + var bracketDepth = 0 + var count = 0 + var index = block.startIndex + + while index < block.endIndex { + switch block[index] { + case "{": + braceDepth += 1 + case "}": + braceDepth -= 1 + case "(": + parenthesisDepth += 1 + case ")": + parenthesisDepth -= 1 + case "[": + bracketDepth += 1 + case "]": + bracketDepth -= 1 + default: + if braceDepth == 1, + parenthesisDepth == 0, + bracketDepth == 0, + block[index...].hasPrefix(needle) + { + count += 1 + } + } + index = block.index(after: index) + } + + return count + } + + private func sanitizedSourceBlock(in source: String, startingWith marker: String) throws -> String { + try sourceBlock(in: sanitizedSwiftSource(source), startingWith: marker) + } + + private func occurrenceCount(of needle: String, in source: String) -> Int { + guard !needle.isEmpty else { return 0 } + var count = 0 + var searchStart = source.startIndex + while let range = source.range(of: needle, range: searchStart.. Bool { + let expression = try NSRegularExpression(pattern: pattern, options: options) + return expression.firstMatch( + in: source, + range: NSRange(source.startIndex..., in: source) + ) != nil + } + + private func makeSignedTestBundle(at root: URL, sandboxed: Bool) throws -> URL { + let entitlementBody = + sandboxed + ? """ + com.apple.security.app-sandbox + com.apple.security.files.user-selected.read-write + """ + : "" + return try makeSignedTestBundle(at: root, entitlementBody: entitlementBody) + } + + private func makeSignedTestBundle(at root: URL, entitlementBody: String) throws -> URL { + let fileManager = FileManager.default + let appBundle = root.appendingPathComponent("DevScope.app", isDirectory: true) + let contents = appBundle.appendingPathComponent("Contents", isDirectory: true) + let macOS = contents.appendingPathComponent("MacOS", isDirectory: true) + let resources = contents.appendingPathComponent("Resources", isDirectory: true) + try fileManager.createDirectory(at: macOS, withIntermediateDirectories: true) + try fileManager.createDirectory(at: resources, withIntermediateDirectories: true) + + try fileManager.copyItem( + at: URL(fileURLWithPath: "/usr/bin/true"), + to: macOS.appendingPathComponent("DevScope") + ) + try Data("icon".utf8).write(to: resources.appendingPathComponent("AppIcon.icns")) + for filename in ["LICENSE", "NOTICE", "THIRD_PARTY_NOTICES.md"] { + try Data("test notice".utf8).write(to: resources.appendingPathComponent(filename)) + } + try Data( + """ + + + + NSPrivacyAccessedAPITypes + NSPrivacyAccessedAPITypeNSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasonsCA92.1 + + + """.utf8 + ).write(to: resources.appendingPathComponent("PrivacyInfo.xcprivacy")) + try Data( + """ + + + + CFBundleIdentifiercom.example.DevScopeTests + CFBundleShortVersionString1.0 + CFBundleVersion1 + CFBundleExecutableDevScope + + """.utf8 + ).write(to: contents.appendingPathComponent("Info.plist")) + + let entitlements = root.appendingPathComponent("entitlements.plist") + try Data( + """ + + + \(entitlementBody) + """.utf8 + ).write(to: entitlements) + + let signing = Process() + signing.executableURL = URL(fileURLWithPath: "/usr/bin/codesign") + signing.arguments = [ + "--force", "--sign", "-", "--entitlements", entitlements.path, appBundle.path, + ] + try signing.run() + signing.waitUntilExit() + XCTAssertEqual(signing.terminationStatus, 0) + return appBundle + } + + private func runValidation( + for appBundle: URL, + requiresSandbox: Bool, + requiredArchitectures: String = "" + ) throws -> ( + status: Int32, output: String + ) { + let repositoryRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let process = Process() + let output = Pipe() + process.executableURL = URL(fileURLWithPath: "/bin/bash") + process.arguments = [ + repositoryRoot.appendingPathComponent("script/validate_release_bundle.sh").path, + appBundle.path, + ] + process.environment = ProcessInfo.processInfo.environment.merging([ + "DEVSCOPE_REQUIRE_SANDBOX": requiresSandbox ? "1" : "0", + "DEVSCOPE_REQUIRE_GATEKEEPER": "0", + "DEVSCOPE_REQUIRED_ARCHITECTURES": requiredArchitectures, + ]) { _, replacement in replacement } + process.standardOutput = output + process.standardError = output + try process.run() + let data = output.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + return (process.terminationStatus, String(decoding: data, as: UTF8.self)) + } +} + +private struct SwiftSourceLexicalSanitizer { + private struct StringDelimiter { + let hashCount: Int + let quoteCount: Int + + var length: Int { hashCount + quoteCount } + } + + private let characters: [Character] + private var sanitized: [Character] + private var index = 0 + + init(source: String) { + characters = Array(source) + sanitized = Array(source) + } + + mutating func sanitizedSource() -> String { + scanCode() + return String(sanitized) + } + + private mutating func scanCode() { + while index < characters.count { + if hasPair("/", "/") { + scanLineComment() + } else if hasPair("/", "*") { + scanBlockComment() + } else if let delimiter = openingStringDelimiter() { + scanString(delimiter) + } else { + index += 1 + } + } + } + + private mutating func scanString(_ delimiter: StringDelimiter) { + eraseCurrentCharacters(delimiter.length) + + while index < characters.count { + if isClosingStringDelimiter(delimiter) { + eraseCurrentCharacters(delimiter.length) + return + } + + if let interpolationLength = interpolationStartLength(for: delimiter) { + eraseCurrentCharacters(interpolationLength) + scanInterpolation() + } else if delimiter.hashCount == 0, + characters[index] == "\\", + index + 1 < characters.count { + eraseCurrentCharacters(2) + } else { + eraseCurrentCharacter() + } + } + } + + private mutating func scanInterpolation() { + var parenthesisDepth = 1 + + while index < characters.count, parenthesisDepth > 0 { + if hasPair("/", "/") { + scanLineComment() + } else if hasPair("/", "*") { + scanBlockComment() + } else if let delimiter = openingStringDelimiter() { + scanString(delimiter) + } else if characters[index] == "(" { + parenthesisDepth += 1 + eraseCurrentCharacter() + } else if characters[index] == ")" { + parenthesisDepth -= 1 + eraseCurrentCharacter() + } else { + eraseCurrentCharacter() + } + } + } + + private mutating func scanLineComment() { + while index < characters.count, !characters[index].isNewline { + eraseCurrentCharacter() + } + } + + private mutating func scanBlockComment() { + var depth = 1 + eraseCurrentCharacters(2) + + while index < characters.count, depth > 0 { + if hasPair("/", "*") { + depth += 1 + eraseCurrentCharacters(2) + } else if hasPair("*", "/") { + depth -= 1 + eraseCurrentCharacters(2) + } else { + eraseCurrentCharacter() + } + } + } + + private func openingStringDelimiter() -> StringDelimiter? { + var cursor = index + while cursor < characters.count, characters[cursor] == "#" { + cursor += 1 + } + guard cursor < characters.count, characters[cursor] == "\"" else { return nil } + let hasThreeQuotes = cursor + 2 < characters.count + && characters[cursor + 1] == "\"" + && characters[cursor + 2] == "\"" + return StringDelimiter( + hashCount: cursor - index, + quoteCount: hasThreeQuotes ? 3 : 1 + ) + } + + private func isClosingStringDelimiter(_ delimiter: StringDelimiter) -> Bool { + guard index + delimiter.length <= characters.count else { return false } + for offset in 0.. Int? { + guard characters[index] == "\\" else { return nil } + var cursor = index + 1 + for _ in 0.. Bool { + index + 1 < characters.count + && characters[index] == first + && characters[index + 1] == second + } + + private mutating func eraseCurrentCharacters(_ count: Int) { + for _ in 0.. [UInt8] { + var count = count + return withUnsafeBytes(of: &count) { Array($0) } + } + + XCTAssertNil(KernelProcessArgumentVectorProvider.parse( + buffer: header(1) + [0, 0] + Array("/bin/tool".utf8) + [0] + )) + XCTAssertNil(KernelProcessArgumentVectorProvider.parse( + buffer: header(1) + [0xFF, 0, 0] + Array("/bin/tool".utf8) + [0] + )) + XCTAssertNil(KernelProcessArgumentVectorProvider.parse( + buffer: header(1) + Array("relative-tool".utf8) + [0, 0] + Array("relative-tool".utf8) + [0] + )) + XCTAssertNil(KernelProcessArgumentVectorProvider.parse( + buffer: header(0) + Array("/bin/tool".utf8) + [0, 0] + )) + XCTAssertNil(KernelProcessArgumentVectorProvider.parse( + buffer: header(4_097) + Array("/bin/tool".utf8) + [0, 0] + )) + XCTAssertNil(KernelProcessArgumentVectorProvider.parse( + buffer: header(1) + Array("/bin/tool".utf8) + [0, 0, 0xFF, 0] + )) + XCTAssertNil(KernelProcessArgumentVectorProvider.parse( + buffer: header(1) + Array("/bin/tool".utf8) + [0, 0] + Array("/bin/tool".utf8) + )) + XCTAssertNil(KernelProcessArgumentVectorProvider.parse( + buffer: [UInt8](repeating: 0, count: 1_048_577) + )) + } + + func testNativeScannerFailsClosedWhenInjectedArgumentProviderFails() { + let scanner = NativeProcessScanner( + bundleIdentityProvider: FakeBundleIdentityProvider(identifiers: [:]), + argumentVectorProvider: FakeArgumentVectorProvider(vectors: [:]) + ) + + XCTAssertNil(scanner.argumentVector(for: getpid())) + } + + func testNativeMetadataDropsCollectedArgumentsAndBundleWhenFinalBirthChanges() throws { + let processID = getpid() + let calls = OrderedProcessEvidenceCallRecorder() + let initialBirth = try XCTUnwrap(NativeProcessScanner().birthToken(for: processID)) + let recycledBirth = ProcessBirthToken( + seconds: initialBirth.seconds + 1, + microseconds: initialBirth.microseconds + ) + let bundleProvider = ConstantBundleIdentityProvider( + identifier: "com.example.raced", recorder: calls + ) + let argumentProvider = RecordingArgumentVectorProvider( + vectors: [processID: ["/opt/jobs/exact", "--mode=value"]], recorder: calls + ) + let scanner = NativeProcessScanner( + bundleIdentityProvider: bundleProvider, + argumentVectorProvider: argumentProvider, + finalBirthTokenProvider: FakeFinalBirthTokenProvider(token: recycledBirth, recorder: calls) + ) + + XCTAssertNil(scanner.metadata(for: processID)) + XCTAssertEqual(argumentProvider.requestedProcessIDs, [processID]) + XCTAssertFalse(bundleProvider.requestedPaths.isEmpty, "Bundle metadata must precede final birth") + XCTAssertEqual(calls.evidenceKinds(for: processID), [ + .argumentVector, .bundleIdentity, .finalBirth, + ]) + XCTAssertEqual(calls.count(.finalBirth, for: processID), 1) + } + + func testNativeMetadataKeepsStableBirthAfterCollectedEvidence() throws { + let processID = getpid() + let calls = OrderedProcessEvidenceCallRecorder() + let birthToken = try XCTUnwrap(NativeProcessScanner().birthToken(for: processID)) + let argumentVector = ["/opt/jobs/exact", "--mode=value"] + let scanner = NativeProcessScanner( + bundleIdentityProvider: ConstantBundleIdentityProvider( + identifier: "com.example.stable", recorder: calls + ), + argumentVectorProvider: FakeArgumentVectorProvider( + vectors: [processID: argumentVector], recorder: calls + ), + finalBirthTokenProvider: FakeFinalBirthTokenProvider(token: birthToken, recorder: calls) + ) + + let metadata = try XCTUnwrap(scanner.metadata(for: processID)) + XCTAssertEqual(metadata.birthToken, birthToken) + XCTAssertEqual(metadata.argumentVector, argumentVector) + XCTAssertEqual(metadata.bundleIdentifier, "com.example.stable") + XCTAssertEqual(calls.evidenceKinds(for: processID), [ + .argumentVector, .bundleIdentity, .finalBirth, + ]) + } + + func testNativeSnapshotFinalBirthForCurrentProcessFollowsCollectedEvidence() throws { + let processID = getpid() + let baseline = try XCTUnwrap(NativeProcessScanner().metadata(for: processID)) + let birthToken = try XCTUnwrap(baseline.birthToken) + let calls = OrderedProcessEvidenceCallRecorder() + let argumentVector = ["/opt/jobs/exact", "--mode=value"] + let scanner = NativeProcessScanner( + bundleIdentityProvider: ConstantBundleIdentityProvider( + identifier: "com.example.snapshot", recorder: calls + ), + argumentVectorProvider: FakeArgumentVectorProvider( + vectors: [processID: argumentVector], recorder: calls + ), + finalBirthTokenProvider: FakeFinalBirthTokenProvider(token: birthToken, recorder: calls) + ) + + let snapshotProcess = try XCTUnwrap(scanner.snapshot().first { $0.pid == processID }) + + XCTAssertEqual(snapshotProcess.birthToken, birthToken) + XCTAssertEqual(snapshotProcess.argumentVector, argumentVector) + XCTAssertEqual(snapshotProcess.bundleIdentifier, "com.example.snapshot") + XCTAssertEqual(calls.evidenceKinds(for: processID), [ + .argumentVector, .bundleIdentity, .finalBirth, + ]) + } + + func testNativeMetadataDropsRowWhenFinalBirthReadIsUnavailable() { + let processID = getpid() + let scanner = NativeProcessScanner( + bundleIdentityProvider: ConstantBundleIdentityProvider(identifier: "com.example.unavailable"), + argumentVectorProvider: FakeArgumentVectorProvider( + vectors: [processID: ["/opt/jobs/exact", "--mode=value"]] + ), + finalBirthTokenProvider: FakeFinalBirthTokenProvider(token: nil) + ) + + XCTAssertNil(scanner.metadata(for: processID)) + } + + func testDoesNotDowngradeFullPSExecutableToNativeBasename() { + let psProcess = DevProcess( + pid: 103, + parentPID: 1, + executable: "/usr/libexec/configd", + command: "/usr/libexec/configd", + resourceUsage: DevProcessResourceUsage(cpuPercent: 0.2, residentMemoryBytes: 128, elapsedTime: "03:04") + ) + let nativeProcess = DevProcess( + pid: 103, + parentPID: 1, + executable: "configd", + command: "configd" + ) + + let merged = ProcessScanner.mergedProcess( + psProcess: psProcess, + nativeProcess: nativeProcess, + currentDirectory: nil, + birthToken: nil + ) + + XCTAssertEqual(merged.executable, "/usr/libexec/configd") + XCTAssertEqual(merged.command, "/usr/libexec/configd") + XCTAssertEqual(merged.resourceUsage, psProcess.resourceUsage) + } + + func testExcludesOnlyTheExplicitScannerHelperPID() { + let scannerHelper = DevProcess( + pid: 201, + parentPID: 200, + executable: "/bin/ps", + command: "/bin/ps -axo pid=,ppid=,%cpu=,rss=,etime=,comm=,args=" + ) + let unrelatedPSProcess = DevProcess( + pid: 202, + parentPID: 1, + executable: "/bin/ps", + command: "/bin/ps aux" + ) + let unrelatedProcess = DevProcess( + pid: 203, + parentPID: 1, + executable: "/usr/bin/swift", + command: "swift test" + ) + + let filtered = ProcessScanner.excludingScannerHelper( + from: [scannerHelper, unrelatedPSProcess, unrelatedProcess], + helperPID: scannerHelper.pid + ) + + XCTAssertEqual(filtered.map(\.pid), [unrelatedPSProcess.pid, unrelatedProcess.pid]) + } + + func testSnapshotCompletesAndIncludesTheCurrentTestProcess() throws { + let scanner = SystemProcessScanner() + + let processes = try scanner.snapshot() + + XCTAssertFalse(processes.isEmpty) + XCTAssertTrue( + processes.contains { $0.pid == getpid() }, + "Expected snapshot to include the current XCTest process" + ) + } + + func testNativeSnapshotCompletesAndIncludesTheCurrentProcess() throws { + let scanner = NativeProcessScanner() + + let processes = try scanner.snapshot() + let currentProcess = try XCTUnwrap(processes.first { $0.pid == getpid() }) + + XCTAssertFalse(processes.isEmpty) + XCTAssertNotNil(currentProcess.birthToken) + XCTAssertNotNil(currentProcess.argumentVector) + XCTAssertNil(currentProcess.launchLabel, "Native scanning must not infer a launch label") + } + + private func psProcess( + pid: Int32, + executable: String = "/usr/bin/node", + command: String = "/usr/bin/node worker.js" + ) -> DevProcess { + DevProcess(pid: pid, parentPID: 1, executable: executable, command: command) + } + + private func nativeProcess(pid: Int32, birthToken: ProcessBirthToken) -> DevProcess { + DevProcess( + pid: pid, + parentPID: 1, + executable: "node", + command: "/usr/bin/node", + birthToken: birthToken + ) + } + + private func makeBundle(at url: URL, executable: String, identifier: String) throws -> URL { + let executableURL = url.appendingPathComponent("Contents/MacOS/\(executable)") + try FileManager.default.createDirectory( + at: executableURL.deletingLastPathComponent(), withIntermediateDirectories: true + ) + XCTAssertTrue(FileManager.default.createFile(atPath: executableURL.path, contents: Data())) + let plist: [String: Any] = [ + "CFBundleExecutable": executable, + "CFBundleIdentifier": identifier, + "CFBundlePackageType": "APPL", + ] + let data = try PropertyListSerialization.data( + fromPropertyList: plist, format: .xml, options: 0 + ) + try data.write(to: url.appendingPathComponent("Contents/Info.plist")) + return executableURL + } +} + +private enum ProcessEvidenceKind: Equatable, Sendable { + case argumentVector + case bundleIdentity + case finalBirth +} + +private struct ProcessEvidenceCall: Equatable, Sendable { + let kind: ProcessEvidenceKind + let processID: Int32? +} + +private final class OrderedProcessEvidenceCallRecorder: @unchecked Sendable { + private let lock = NSLock() + private var recordedCalls: [ProcessEvidenceCall] = [] + + func record(_ kind: ProcessEvidenceKind, processID: Int32?) { + lock.withLock { + recordedCalls.append(ProcessEvidenceCall(kind: kind, processID: processID)) + } + } + + func evidenceKinds(for processID: Int32) -> [ProcessEvidenceKind] { + lock.withLock { + recordedCalls.filter { $0.processID == processID }.map(\.kind) + } + } + + func count(_ kind: ProcessEvidenceKind, for processID: Int32) -> Int { + lock.withLock { + recordedCalls.count { $0.processID == processID && $0.kind == kind } + } + } + + func processIDs(for kind: ProcessEvidenceKind) -> [Int32?] { + lock.withLock { + recordedCalls.filter { $0.kind == kind }.map(\.processID) + } + } +} + +private final class FakeNativeMetadataProvider: @unchecked Sendable, NativeProcessMetadataProviding { + private let lock = NSLock() + private var processes: [Int32: DevProcess] + private var requestedIDs: [Int32] = [] + + init(processes: [Int32: DevProcess]) { + self.processes = processes + } + + var requestedProcessIDs: [Int32] { + lock.withLock { requestedIDs } + } + + func setProcess(_ process: DevProcess, for processID: Int32) { + lock.withLock { + processes[processID] = process + } + } + + func metadata(for processID: Int32) -> DevProcess? { + lock.withLock { + requestedIDs.append(processID) + return processes[processID] + } + } +} + +private final class FakeBundleIdentityProvider: @unchecked Sendable, ProcessBundleIdentityProviding { + private let lock = NSLock() + private let identifiers: [String: String] + private var paths: [String] = [] + + init(identifiers: [String: String]) { + self.identifiers = identifiers + } + + var requestedPaths: [String] { + lock.withLock { paths } + } + + func bundleIdentifier(forExecutableURL executableURL: URL, processID _: Int32) -> String? { + lock.withLock { + paths.append(executableURL.path) + return identifiers[executableURL.path] + } + } +} + +private struct FakeArgumentVectorProvider: ProcessArgumentVectorProviding { + let vectors: [Int32: [String]] + let recorder: OrderedProcessEvidenceCallRecorder? + + init( + vectors: [Int32: [String]], + recorder: OrderedProcessEvidenceCallRecorder? = nil + ) { + self.vectors = vectors + self.recorder = recorder + } + + func argumentVector(for processID: Int32) -> [String]? { + recorder?.record(.argumentVector, processID: processID) + return vectors[processID] + } +} + +private final class RecordingArgumentVectorProvider: @unchecked Sendable, ProcessArgumentVectorProviding { + private let lock = NSLock() + private let vectors: [Int32: [String]] + private let recorder: OrderedProcessEvidenceCallRecorder? + private var requestedIDs: [Int32] = [] + + init( + vectors: [Int32: [String]], + recorder: OrderedProcessEvidenceCallRecorder? = nil + ) { + self.vectors = vectors + self.recorder = recorder + } + + var requestedProcessIDs: [Int32] { + lock.withLock { requestedIDs } + } + + func argumentVector(for processID: Int32) -> [String]? { + recorder?.record(.argumentVector, processID: processID) + return lock.withLock { + requestedIDs.append(processID) + return vectors[processID] + } + } +} + +private struct FakeFinalBirthTokenProvider: ProcessBirthTokenProviding { + let token: ProcessBirthToken? + let recorder: OrderedProcessEvidenceCallRecorder? + + init( + token: ProcessBirthToken?, + recorder: OrderedProcessEvidenceCallRecorder? = nil + ) { + self.token = token + self.recorder = recorder + } + + func birthToken(for processID: Int32) -> ProcessBirthToken? { + recorder?.record(.finalBirth, processID: processID) + return token + } +} + +private final class ConstantBundleIdentityProvider: @unchecked Sendable, ProcessBundleIdentityProviding { + private let lock = NSLock() + private let identifier: String + private let recorder: OrderedProcessEvidenceCallRecorder? + private var paths: [String] = [] + + init( + identifier: String, + recorder: OrderedProcessEvidenceCallRecorder? = nil + ) { + self.identifier = identifier + self.recorder = recorder + } + + var requestedPaths: [String] { + lock.withLock { paths } + } + + func bundleIdentifier(forExecutableURL executableURL: URL, processID: Int32) -> String? { + recorder?.record(.bundleIdentity, processID: processID) + return lock.withLock { + paths.append(executableURL.path) + return identifier + } + } +} diff --git a/Tests/DevScopeCoreTests/WorkspaceFactsCacheTests.swift b/Tests/DevScopeCoreTests/WorkspaceFactsCacheTests.swift new file mode 100644 index 0000000..d11b406 --- /dev/null +++ b/Tests/DevScopeCoreTests/WorkspaceFactsCacheTests.swift @@ -0,0 +1,159 @@ +import XCTest +@testable import DevScopeCore + +final class WorkspaceFactsCacheTests: XCTestCase { + func testBudgetedCycleRevalidatesOnlyItsStaleEntryBudget() { + var probedDirectories: [String] = [] + let cache = WorkspaceFactsCache { path in + if path.hasSuffix("/pubspec.yaml") { + probedDirectories.append(String(path.dropLast("/pubspec.yaml".count))) + } + return false + } + let directories = (1...3).map { "/workspace/\($0)" } + directories.forEach { _ = cache.facts(for: $0) } + probedDirectories.removeAll() + cache.invalidateAll() + + cache.withRevalidationCycle(maximumStaleEntries: 2) { + directories.forEach { _ = cache.facts(for: $0) } + } + + XCTAssertEqual(probedDirectories, Array(directories.prefix(2))) + } + + func testLaterBudgetedCycleContinuesRevalidatingRemainingStaleEntries() { + var probedDirectories: [String] = [] + let cache = WorkspaceFactsCache { path in + if path.hasSuffix("/pubspec.yaml") { + probedDirectories.append(String(path.dropLast("/pubspec.yaml".count))) + } + return false + } + let directories = (1...3).map { "/workspace/\($0)" } + directories.forEach { _ = cache.facts(for: $0) } + cache.invalidateAll() + + cache.withRevalidationCycle(maximumStaleEntries: 2) { + directories.forEach { _ = cache.facts(for: $0) } + } + probedDirectories.removeAll() + cache.withRevalidationCycle(maximumStaleEntries: 2) { + directories.forEach { _ = cache.facts(for: $0) } + } + + XCTAssertEqual(probedDirectories, [directories[2]]) + } + + func testDuplicateDirectoryUsesOneStaleEntrySlot() { + var probedDirectories: [String] = [] + let cache = WorkspaceFactsCache { path in + if path.hasSuffix("/pubspec.yaml") { + probedDirectories.append(String(path.dropLast("/pubspec.yaml".count))) + } + return false + } + let directories = ["/workspace/shared", "/workspace/second", "/workspace/third"] + directories.forEach { _ = cache.facts(for: $0) } + cache.invalidateAll() + probedDirectories.removeAll() + + cache.withRevalidationCycle(maximumStaleEntries: 2) { + _ = cache.facts(for: directories[0]) + _ = cache.facts(for: directories[0]) + _ = cache.facts(for: directories[1]) + _ = cache.facts(for: directories[2]) + } + + XCTAssertEqual(probedDirectories, Array(directories.prefix(2))) + } + + func testDirectLookupAfterInvalidationRevalidatesImmediately() { + var manifestExists = false + let cache = WorkspaceFactsCache { path in + manifestExists && path.hasSuffix("/pubspec.yaml") + } + let directory = "/workspace/direct" + + XCTAssertFalse(cache.facts(for: directory).isFlutterWorkspace) + manifestExists = true + cache.invalidateAll() + + XCTAssertTrue(cache.facts(for: directory).isFlutterWorkspace) + } + + func testUnseenDirectoryProbesAfterStaleBudgetIsExhausted() { + var manifests: Set = [] + let cache = WorkspaceFactsCache { path in + manifests.contains(path) + } + let staleDirectory = "/workspace/stale" + let newDirectory = "/workspace/new" + _ = cache.facts(for: staleDirectory) + cache.invalidateAll() + manifests.insert(newDirectory + "/pubspec.yaml") + + let newFacts = cache.withRevalidationCycle(maximumStaleEntries: 0) { + cache.facts(for: newDirectory) + } + + XCTAssertTrue(newFacts.isFlutterWorkspace) + } + + func testLaterInvalidationPrunesDirectoryNotAccessedSincePreviousInvalidation() { + var manifests: Set = [] + let cache = WorkspaceFactsCache { path in + manifests.contains(path) + } + let activeDirectory = "/workspace/active" + let inactiveDirectory = "/workspace/inactive" + _ = cache.facts(for: activeDirectory) + _ = cache.facts(for: inactiveDirectory) + cache.invalidateAll() + cache.withRevalidationCycle(maximumStaleEntries: 0) { + _ = cache.facts(for: activeDirectory) + } + cache.invalidateAll() + manifests.insert(inactiveDirectory + "/pubspec.yaml") + + let inactiveFacts = cache.withRevalidationCycle(maximumStaleEntries: 0) { + cache.facts(for: inactiveDirectory) + } + + XCTAssertTrue(inactiveFacts.isFlutterWorkspace) + } + + func testProbeStartedBeforeConcurrentInvalidationCannotInstallItsResult() { + let firstProbeStarted = DispatchSemaphore(value: 0) + let allowFirstProbeToFinish = DispatchSemaphore(value: 0) + let probeLock = NSLock() + var pubspecProbeCount = 0 + let cache = WorkspaceFactsCache { path in + guard path.hasSuffix("/pubspec.yaml") else { + return false + } + let probeNumber = probeLock.withLock { + pubspecProbeCount += 1 + return pubspecProbeCount + } + if probeNumber == 1 { + firstProbeStarted.signal() + _ = allowFirstProbeToFinish.wait(timeout: .now() + 2) + return false + } + return true + } + let completed = expectation(description: "fresh generation returned") + + DispatchQueue.global().async { + XCTAssertTrue(cache.facts(for: "/workspace/concurrent").isFlutterWorkspace) + completed.fulfill() + } + XCTAssertEqual(firstProbeStarted.wait(timeout: .now() + 2), .success) + cache.invalidateAll() + allowFirstProbeToFinish.signal() + + wait(for: [completed], timeout: 3) + XCTAssertEqual(probeLock.withLock { pubspecProbeCount }, 2) + } +} diff --git a/Tests/DevScopeTests/AutomationApplicationIconStoreTests.swift b/Tests/DevScopeTests/AutomationApplicationIconStoreTests.swift new file mode 100644 index 0000000..ca28d21 --- /dev/null +++ b/Tests/DevScopeTests/AutomationApplicationIconStoreTests.swift @@ -0,0 +1,423 @@ +import AppKit +import Combine +import DevScopeCore +import Foundation +import XCTest +@testable import DevScope + +final class AutomationApplicationIconStoreTests: XCTestCase { + @MainActor + func testBundleIdentifierLookupPrecedesSourceAndExecutableCandidates() async { + let locator = FakeAutomationApplicationLocator( + bundleURLs: ["com.example.App": URL(fileURLWithPath: "/Applications/Example.app")], + iconsByPath: [ + "/Applications/Example.app": NSImage(size: .init(width: 16, height: 16)) + ] + ) + let store = AutomationApplicationIconStore(locator: locator, capacity: 8) + let record = makeRecord( + providerBundleIdentifier: "com.example.App", + sourceURL: URL(fileURLWithPath: "/tmp/fallback.app"), + executable: "/tmp/fallback.app/Contents/MacOS/fallback" + ) + + await store.resolve(record) + + XCTAssertNotNil(store.icon(for: record)) + XCTAssertEqual(locator.requestedBundleIdentifiers, ["com.example.App"]) + XCTAssertEqual(locator.requestedIconPaths, ["/Applications/Example.app"]) + } + + @MainActor + func testMissingBundleLookupFallsThroughToSourceBeforeExecutable() async { + let sourceURL = URL(fileURLWithPath: "/Applications/SourceFallback.app") + let executableURL = URL(fileURLWithPath: "/Applications/ExecutableFallback.app") + let locator = FakeAutomationApplicationLocator( + iconsByPath: [ + sourceURL.path: NSImage(size: .init(width: 16, height: 16)), + executableURL.path: NSImage(size: .init(width: 16, height: 16)) + ] + ) + let store = AutomationApplicationIconStore(locator: locator, capacity: 8) + let record = makeRecord( + providerBundleIdentifier: "com.example.Missing", + sourceURL: sourceURL, + executable: executableURL.appendingPathComponent("Contents/MacOS/Executable").path + ) + + await store.resolve(record) + + XCTAssertNotNil(store.icon(for: record)) + XCTAssertEqual(locator.requestedBundleIdentifiers, ["com.example.Missing"]) + XCTAssertEqual(locator.requestedIconPaths, [sourceURL.path]) + XCTAssertEqual(locator.totalLookupCount, 2) + } + + @MainActor + func testMissingBundleIconFallsThroughToSourceBeforeExecutable() async { + let bundleURL = URL(fileURLWithPath: "/Applications/BundleWithoutIcon.app") + let sourceURL = URL(fileURLWithPath: "/Applications/SourceAfterBundleIcon.app") + let executableURL = URL(fileURLWithPath: "/Applications/ExecutableMustNotLoad.app") + let locator = FakeAutomationApplicationLocator( + bundleURLs: ["com.example.NoIcon": bundleURL], + iconsByPath: [ + sourceURL.path: NSImage(size: .init(width: 16, height: 16)), + executableURL.path: NSImage(size: .init(width: 16, height: 16)) + ] + ) + let store = AutomationApplicationIconStore(locator: locator, capacity: 8) + let record = makeRecord( + providerBundleIdentifier: "com.example.NoIcon", + sourceURL: sourceURL, + executable: executableURL.appendingPathComponent("Contents/MacOS/Executable").path + ) + + await store.resolve(record) + + XCTAssertNotNil(store.icon(for: record)) + XCTAssertEqual(locator.requestedBundleIdentifiers, ["com.example.NoIcon"]) + XCTAssertEqual(locator.requestedIconPaths, [bundleURL.path, sourceURL.path]) + XCTAssertEqual(locator.totalLookupCount, 3) + } + + @MainActor + func testExactApplicationSourceURLProvidesFallbackIcon() async { + let sourceURL = URL(fileURLWithPath: "/Applications/Source.app") + let locator = FakeAutomationApplicationLocator( + iconsByPath: [sourceURL.path: NSImage(size: .init(width: 16, height: 16))] + ) + let store = AutomationApplicationIconStore(locator: locator, capacity: 8) + let record = makeRecord(sourceURL: sourceURL) + + await store.resolve(record) + + XCTAssertNotNil(store.icon(for: record)) + XCTAssertEqual(locator.requestedIconPaths, [sourceURL.path]) + } + + @MainActor + func testExecutableInsideApplicationUsesExactContainingBundle() async { + let applicationURL = URL(fileURLWithPath: "/Applications/Foo.app") + let locator = FakeAutomationApplicationLocator( + iconsByPath: [applicationURL.path: NSImage(size: .init(width: 16, height: 16))] + ) + let store = AutomationApplicationIconStore(locator: locator, capacity: 8) + let record = makeRecord(executable: "/Applications/Foo.app/Contents/MacOS/Foo") + + await store.resolve(record) + + XCTAssertNotNil(store.icon(for: record)) + XCTAssertEqual(locator.requestedIconPaths, [applicationURL.path]) + } + + @MainActor + func testNonApplicationSourcesAndExecutableParentsAreRejected() async { + let locator = FakeAutomationApplicationLocator( + iconsByPath: [ + "/Applications/Unrelated": NSImage(size: .init(width: 16, height: 16)), + "/usr/local": NSImage(size: .init(width: 16, height: 16)) + ] + ) + let store = AutomationApplicationIconStore(locator: locator, capacity: 8) + let record = makeRecord( + sourceURL: URL(fileURLWithPath: "/Applications/Unrelated/Contents/Info.plist"), + executable: "/usr/local/bin/Tool" + ) + + await store.resolve(record) + + XCTAssertNil(store.icon(for: record)) + XCTAssertTrue(locator.requestedIconPaths.isEmpty) + } + + @MainActor + func testPositiveResolutionIsCached() async { + let sourceURL = URL(fileURLWithPath: "/Applications/Cached.app") + let locator = FakeAutomationApplicationLocator( + iconsByPath: [sourceURL.path: NSImage(size: .init(width: 16, height: 16))] + ) + let store = AutomationApplicationIconStore(locator: locator, capacity: 8) + let record = makeRecord(sourceURL: sourceURL) + + await store.resolve(record) + await store.resolve(record) + + XCTAssertNotNil(store.icon(for: record)) + XCTAssertEqual(locator.requestedIconPaths, [sourceURL.path]) + XCTAssertEqual(store.revision, 1) + } + + @MainActor + func testStandardizedSourceAndExecutablePathsReuseOneCachedResolution() async { + let canonicalSourceURL = URL(fileURLWithPath: "/Applications/Normalized.app") + let locator = FakeAutomationApplicationLocator( + iconsByPath: [ + canonicalSourceURL.path: NSImage(size: .init(width: 16, height: 16)) + ] + ) + let store = AutomationApplicationIconStore(locator: locator, capacity: 8) + let canonicalRecord = makeRecord( + id: "canonical", + sourceURL: canonicalSourceURL, + executable: "/Applications/Normalized.app/Contents/MacOS/Normalized" + ) + let equivalentRecord = makeRecord( + id: "equivalent", + sourceURL: URL( + fileURLWithPath: "/Applications/Elsewhere/../Normalized.app" + ), + executable: "/Applications/Normalized.app/Contents/Helpers/../MacOS/Normalized" + ) + + XCTAssertNotEqual(canonicalRecord.sourceURL?.path, equivalentRecord.sourceURL?.path) + XCTAssertNotEqual(canonicalRecord.executable, equivalentRecord.executable) + XCTAssertEqual(store.key(for: canonicalRecord), store.key(for: equivalentRecord)) + + await store.resolve(canonicalRecord) + await store.resolve(equivalentRecord) + + XCTAssertNotNil(store.icon(for: equivalentRecord)) + XCTAssertEqual(locator.requestedIconPaths, [canonicalSourceURL.path]) + XCTAssertEqual(locator.totalLookupCount, 1) + XCTAssertEqual(store.revision, 1) + } + + @MainActor + func testNegativeResolutionIsCached() async { + let locator = FakeAutomationApplicationLocator() + let store = AutomationApplicationIconStore(locator: locator, capacity: 8) + let record = makeRecord( + id: "missing", + providerBundleIdentifier: "com.example.Missing" + ) + + await store.resolve(record) + await store.resolve(record) + + XCTAssertNil(store.icon(for: record)) + XCTAssertEqual(locator.requestedBundleIdentifiers, ["com.example.Missing"]) + XCTAssertEqual(store.revision, 1) + } + + @MainActor + func testConcurrentRequestsForOneKeyCoalesce() async { + let sourceURL = URL(fileURLWithPath: "/Applications/Coalesced.app") + let locator = FakeAutomationApplicationLocator( + iconsByPath: [sourceURL.path: NSImage(size: .init(width: 16, height: 16))] + ) + let store = AutomationApplicationIconStore(locator: locator, capacity: 8) + let record = makeRecord(sourceURL: sourceURL) + + let receivedIconCount = await withTaskGroup(of: Bool.self) { group in + for _ in 0..<10 { + group.addTask { + await store.resolve(record) + return await store.icon(for: record) != nil + } + } + var count = 0 + for await receivedIcon in group { + if receivedIcon { + count += 1 + } + } + return count + } + + XCTAssertEqual(receivedIconCount, 10) + XCTAssertNotNil(store.icon(for: record)) + XCTAssertEqual(locator.requestedIconPaths, [sourceURL.path]) + XCTAssertEqual(store.revision, 1) + } + + @MainActor + func testCancellingOneWaiterDoesNotCancelSharedLookupOrSurvivingWaiter() async { + let sourceURL = URL(fileURLWithPath: "/Applications/Cancellation.app") + let gate = SynchronousIconLookupGate() + let locator = FakeAutomationApplicationLocator( + iconsByPath: [sourceURL.path: NSImage(size: .init(width: 16, height: 16))] + ) + locator.iconLookupGate = gate + let store = AutomationApplicationIconStore(locator: locator, capacity: 8) + let record = makeRecord(sourceURL: sourceURL) + + let cancelledWaiter = Task { @MainActor in + _ = await store.resolve(record) + } + let survivingWaiter = Task { @MainActor in + await store.resolve(record) != nil + } + let cancellation = Task.detached { + let lookupDidStart = gate.waitUntilLookupStarts() + cancelledWaiter.cancel() + gate.releaseLookup() + return lookupDidStart + } + + let survivingWaiterReceivedIcon = await survivingWaiter.value + let lookupDidStart = await cancellation.value + await cancelledWaiter.value + + XCTAssertTrue(lookupDidStart) + XCTAssertTrue(cancelledWaiter.isCancelled) + XCTAssertTrue(survivingWaiterReceivedIcon) + XCTAssertNotNil(store.icon(for: record)) + XCTAssertEqual(locator.requestedIconPaths, [sourceURL.path]) + XCTAssertEqual(locator.totalLookupCount, 1) + XCTAssertEqual(store.revision, 1) + } + + @MainActor + func testPositiveAndNegativeResolutionsDoNotPublishStoreWideInvalidations() async { + let sourceURL = URL(fileURLWithPath: "/Applications/Localized.app") + let locator = FakeAutomationApplicationLocator( + iconsByPath: [sourceURL.path: NSImage(size: .init(width: 16, height: 16))] + ) + let store = AutomationApplicationIconStore(locator: locator, capacity: 8) + let resolved = makeRecord(id: "resolved", sourceURL: sourceURL) + let missing = makeRecord( + id: "missing", + providerBundleIdentifier: "com.example.Missing" + ) + var invalidationCount = 0 + let observation = store.objectWillChange.sink { + invalidationCount += 1 + } + + await store.resolve(resolved) + await store.resolve(missing) + + withExtendedLifetime(observation) {} + XCTAssertEqual(invalidationCount, 0) + XCTAssertNotNil(store.icon(for: resolved)) + XCTAssertNil(store.icon(for: missing)) + } + + @MainActor + func testPositiveAndNegativeEntriesShareBoundedEviction() async { + let firstURL = URL(fileURLWithPath: "/Applications/First.app") + let thirdURL = URL(fileURLWithPath: "/Applications/Third.app") + let locator = FakeAutomationApplicationLocator( + iconsByPath: [ + firstURL.path: NSImage(size: .init(width: 16, height: 16)), + thirdURL.path: NSImage(size: .init(width: 16, height: 16)) + ] + ) + let store = AutomationApplicationIconStore(locator: locator, capacity: 2) + let first = makeRecord(id: "first", sourceURL: firstURL) + let missing = makeRecord( + id: "missing", + providerBundleIdentifier: "com.example.Missing" + ) + let third = makeRecord(id: "third", sourceURL: thirdURL) + + await store.resolve(first) + await store.resolve(missing) + await store.resolve(third) + await store.resolve(missing) + await store.resolve(first) + + XCTAssertEqual( + locator.requestedIconPaths, + [firstURL.path, thirdURL.path, firstURL.path] + ) + XCTAssertEqual(locator.requestedBundleIdentifiers, ["com.example.Missing"]) + XCTAssertEqual(store.revision, 4) + } +} + +@MainActor +private final class FakeAutomationApplicationLocator: AutomationApplicationLocating { + var bundleURLs: [String: URL] + var iconsByPath: [String: NSImage] + var iconLookupGate: SynchronousIconLookupGate? + private(set) var requestedBundleIdentifiers: [String] = [] + private(set) var requestedIconPaths: [String] = [] + + init(bundleURLs: [String: URL] = [:], iconsByPath: [String: NSImage] = [:]) { + self.bundleURLs = bundleURLs + self.iconsByPath = iconsByPath + } + + func applicationURL(forBundleIdentifier identifier: String) -> URL? { + requestedBundleIdentifiers.append(identifier) + return bundleURLs[identifier] + } + + func icon(forApplicationAt url: URL) -> NSImage? { + requestedIconPaths.append(url.path) + iconLookupGate?.pauseLookupUntilReleased() + return iconsByPath[url.path] + } + + var totalLookupCount: Int { + requestedBundleIdentifiers.count + requestedIconPaths.count + } +} + +private final class SynchronousIconLookupGate: @unchecked Sendable { + private let condition = NSCondition() + private var lookupStarted = false + private var lookupReleased = false + + func pauseLookupUntilReleased() { + condition.lock() + lookupStarted = true + condition.broadcast() + while !lookupReleased { + condition.wait() + } + condition.unlock() + } + + func waitUntilLookupStarts(timeout: TimeInterval = 5) -> Bool { + condition.lock() + defer { condition.unlock() } + let deadline = Date().addingTimeInterval(timeout) + while !lookupStarted { + guard condition.wait(until: deadline) else { + return false + } + } + return true + } + + func releaseLookup() { + condition.lock() + lookupReleased = true + condition.broadcast() + condition.unlock() + } +} + +private func makeRecord( + id: String = "icon-record", + providerBundleIdentifier: String? = nil, + sourceURL: URL? = nil, + executable: String? = nil +) -> AutomationRecord { + AutomationRecord( + id: .init(rawValue: id), + kind: .backgroundItem, + sourceKind: .serviceManagement, + label: id, + displayName: "Icon Record", + providerBundleIdentifier: providerBundleIdentifier, + ownerUID: 501, + ownership: .thirdPartySystem, + executable: executable, + arguments: [], + environment: [:], + workingDirectory: nil, + schedule: .init(triggers: [.demand], summary: "On demand"), + sourceURL: sourceURL, + sourceChecksum: nil, + enabledState: .enabled, + loadState: .unknown, + approvalState: .unknown, + state: .idle, + evidence: [], + capabilities: [], + validationFindings: [] + ) +} diff --git a/Tests/DevScopeTests/AutomationDocumentPanelTests.swift b/Tests/DevScopeTests/AutomationDocumentPanelTests.swift new file mode 100644 index 0000000..c7e9e35 --- /dev/null +++ b/Tests/DevScopeTests/AutomationDocumentPanelTests.swift @@ -0,0 +1,85 @@ +import Foundation +import XCTest +import DevScopeCore +@testable import DevScope + +@MainActor +final class AutomationDocumentPanelTests: XCTestCase { + func testValidatedImportDataRejectsContentBeyondLimitAfterRead() throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("devscope-import-\(UUID().uuidString)") + let oversized = Data(repeating: 0x41, count: AutomationDocumentPanel.maximumImportBytes + 1) + try oversized.write(to: url) + defer { try? FileManager.default.removeItem(at: url) } + + XCTAssertThrowsError(try AutomationDocumentPanel.validatedImportData(at: url)) { error in + XCTAssertEqual((error as? CocoaError)?.code, .fileReadTooLarge) + } + } + + func testValidatedImportDataReturnsContentAtLimit() throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("devscope-import-\(UUID().uuidString)") + let content = Data(repeating: 0x42, count: AutomationDocumentPanel.maximumImportBytes) + try content.write(to: url) + defer { try? FileManager.default.removeItem(at: url) } + + XCTAssertEqual(try AutomationDocumentPanel.validatedImportData(at: url), content) + } + + func testValidatedImportDataRefusesSymbolicLinksAndNonRegularFiles() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("devscope-import-types-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let source = directory.appendingPathComponent("source") + let link = directory.appendingPathComponent("link") + let fifo = directory.appendingPathComponent("fifo") + try Data("safe".utf8).write(to: source) + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: source) + XCTAssertEqual(mkfifo(fifo.path, 0o600), 0) + + XCTAssertThrowsError(try AutomationDocumentPanel.validatedImportData(at: link)) + XCTAssertThrowsError(try AutomationDocumentPanel.validatedImportData(at: fifo)) + } + + func testExportWriterRestrictsSecretBearingFileToCurrentUser() throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("devscope-export-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: url) } + let artifact = AutomationExportArtifact( + suggestedFilename: "automation.plist", + mediaType: "application/x-plist", + format: "source.plist", + data: Data("TOKEN=private".utf8), + isRedacted: false + ) + + try AutomationDocumentPanel.writeExport(artifact, to: url) + + XCTAssertEqual(try Data(contentsOf: url), artifact.data) + let attributes = try FileManager.default.attributesOfItem(atPath: url.path) + XCTAssertEqual((attributes[.posixPermissions] as? NSNumber)?.intValue, 0o600) + } + + func testExportWriterAtomicallyReplacesExistingFileWithoutKeepingBroadPermissions() throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("devscope-export-existing-\(UUID().uuidString)") + try Data("old".utf8).write(to: url) + try FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: url.path) + defer { try? FileManager.default.removeItem(at: url) } + let artifact = AutomationExportArtifact( + suggestedFilename: "automation.plist", + mediaType: "application/x-plist", + format: "source.plist", + data: Data("new-secret".utf8), + isRedacted: false + ) + + try AutomationDocumentPanel.writeExport(artifact, to: url) + + XCTAssertEqual(try Data(contentsOf: url), artifact.data) + let attributes = try FileManager.default.attributesOfItem(atPath: url.path) + XCTAssertEqual((attributes[.posixPermissions] as? NSNumber)?.intValue, 0o600) + } +} diff --git a/Tests/DevScopeTests/AutomationNotifierTests.swift b/Tests/DevScopeTests/AutomationNotifierTests.swift new file mode 100644 index 0000000..9838bc4 --- /dev/null +++ b/Tests/DevScopeTests/AutomationNotifierTests.swift @@ -0,0 +1,238 @@ +import DevScopeCore +import XCTest +@testable import DevScope + +@MainActor +final class AutomationNotifierTests: XCTestCase { + func testDirectOptInRequestsOnceAndDuplicateVerifiedEventDeliversOnce() async { + let center = RecordingNotificationCenter(granted: true) + let notifier = AutomationNotifier(notificationCenter: center) + let event = AutomationEvent.crossedLongRunningThreshold( + process: ProcessIdentity( + pid: 7_001, + birthToken: ProcessBirthToken(seconds: 123, microseconds: 4) + ), + recordID: AutomationRecord.ID(rawValue: "notification-record") + ) + + await notifier.consume([event]) + let deliveryCountBeforeOptIn = await center.deliveryCount() + XCTAssertEqual(deliveryCountBeforeOptIn, 0) + + await notifier.setPreference(.crossedLongRunningThreshold, isEnabled: true) + await notifier.setPreference(.unexpectedExit, isEnabled: true) + await notifier.consume([event, event]) + + let authorizationRequests = await center.authorizationRequestCount() + let deliveryCount = await center.deliveryCount() + let lastDelivery = await center.lastDelivery() + XCTAssertEqual(authorizationRequests, 1) + XCTAssertEqual(deliveryCount, 1) + let delivered = try! XCTUnwrap(lastDelivery) + XCTAssertEqual(delivered.title, "Long-running process detected") + XCTAssertFalse(delivered.body.contains("7_001")) + XCTAssertFalse(delivered.body.contains("notification-record")) + } + + func testDisablingPreferenceNeverRequestsAuthorizationOrDelivers() async { + let center = RecordingNotificationCenter(granted: true) + let notifier = AutomationNotifier(notificationCenter: center) + await notifier.setPreference(.repeatedFailure, isEnabled: false) + await notifier.consume([.repeatedFailure( + recordID: AutomationRecord.ID(rawValue: "private-record"), + observedExitCount: 3 + )]) + + let authorizationRequests = await center.authorizationRequestCount() + let deliveryCount = await center.deliveryCount() + XCTAssertEqual(authorizationRequests, 0) + XCTAssertEqual(deliveryCount, 0) + } + + func testDeniedAuthorizationPublishesBlockedDeliveryWithoutClearingOptIn() async { + let center = RecordingNotificationCenter(granted: false) + let notifier = AutomationNotifier(notificationCenter: center) + + await notifier.synchronize(AutomationNotificationPreferences( + crossedLongRunningThreshold: true, + unexpectedExit: true, + repeatedFailure: true + )) + + XCTAssertEqual(notifier.deliveryState, .denied) + XCTAssertEqual(notifier.preferences, AutomationNotificationPreferences( + crossedLongRunningThreshold: true, + unexpectedExit: true, + repeatedFailure: true + )) + let requests = await center.authorizationRequestCount() + XCTAssertEqual(requests, 1) + } + + func testAuthorizationRequestErrorIsNotReportedAsUserDenial() async { + let notifier = AutomationNotifier(notificationCenter: FailingNotificationCenter()) + + await notifier.setPreference(.unexpectedExit, isEnabled: true) + + XCTAssertEqual(notifier.deliveryState, .authorizationFailed) + XCTAssertTrue(notifier.preferences.unexpectedExit) + } + + func testDeliveryRetriesOnceInsideTheProductionConsumePathAndDeduplicatesSuccess() async { + let center = FlakyNotificationCenter(failingAttempts: [1]) + let notifier = AutomationNotifier( + notificationCenter: center, + retryDelay: .zero, + sleep: { _ in } + ) + let event = AutomationEvent.repeatedFailure( + recordID: AutomationRecord.ID(rawValue: "retryable-record"), + observedExitCount: 3 + ) + await notifier.setPreference(.repeatedFailure, isEnabled: true) + + await notifier.consume([event]) + XCTAssertEqual(notifier.deliveryState, .enabled) + + await notifier.consume([event]) + XCTAssertEqual(notifier.deliveryState, .enabled) + let attempts = await center.deliveryAttempts() + XCTAssertEqual(attempts, 2) + } + + func testOneFailureKeepsTheWholeDeliveryBatchVisibleEvenIfALaterEventSucceeds() async { + let center = FlakyNotificationCenter(failingAttempts: [1, 2]) + let notifier = AutomationNotifier( + notificationCenter: center, + retryDelay: .zero, + sleep: { _ in } + ) + await notifier.setPreference(.repeatedFailure, isEnabled: true) + + await notifier.consume([ + .repeatedFailure( + recordID: AutomationRecord.ID(rawValue: "failed-record"), + observedExitCount: 3 + ), + .repeatedFailure( + recordID: AutomationRecord.ID(rawValue: "successful-record"), + observedExitCount: 3 + ), + ]) + + XCTAssertEqual(notifier.deliveryState, .deliveryFailed) + let attempts = await center.deliveryAttempts() + XCTAssertEqual(attempts, 3) + } + + func testConcurrentConsumeCallsSerializeAndCannotHideAnExhaustedRetry() async { + let center = FlakyNotificationCenter(failingAttempts: [1, 2]) + let sleeper = GatedRetrySleeper() + let notifier = AutomationNotifier( + notificationCenter: center, + retryDelay: .seconds(2), + sleep: { _ in try await sleeper.sleep() } + ) + await notifier.setPreference(.repeatedFailure, isEnabled: true) + let firstEvent = AutomationEvent.repeatedFailure( + recordID: AutomationRecord.ID(rawValue: "first-concurrent-record"), + observedExitCount: 3 + ) + let secondEvent = AutomationEvent.repeatedFailure( + recordID: AutomationRecord.ID(rawValue: "second-concurrent-record"), + observedExitCount: 3 + ) + + let first = Task { await notifier.consume([firstEvent]) } + await sleeper.waitUntilCalled() + let second = Task { await notifier.consume([secondEvent]) } + + let deadline = ContinuousClock.now + .milliseconds(100) + while await center.deliveryAttempts() == 1, ContinuousClock.now < deadline { + try? await Task.sleep(for: .milliseconds(1)) + } + let attemptsWhileFirstRetryWasSleeping = await center.deliveryAttempts() + XCTAssertEqual(attemptsWhileFirstRetryWasSleeping, 1) + + await sleeper.releaseAll() + await first.value + await second.value + + XCTAssertEqual(notifier.deliveryState, .deliveryFailed) + let finalAttempts = await center.deliveryAttempts() + XCTAssertEqual(finalAttempts, 3) + } +} + +private struct FailingNotificationCenter: AutomationNotificationCenterClient { + struct Failure: Error {} + func requestAuthorization() async throws -> Bool { throw Failure() } + func deliver(_ content: AutomationNotificationContent) async throws {} +} + +private actor RecordingNotificationCenter: AutomationNotificationCenterClient { + private let granted: Bool + private var requestCount = 0 + private var deliveries: [AutomationNotificationContent] = [] + + init(granted: Bool) { + self.granted = granted + } + + func requestAuthorization() async throws -> Bool { + requestCount += 1 + return granted + } + + func deliver(_ content: AutomationNotificationContent) async throws { + deliveries.append(content) + } + + func authorizationRequestCount() -> Int { requestCount } + func deliveryCount() -> Int { deliveries.count } + func lastDelivery() -> AutomationNotificationContent? { deliveries.last } +} + +private actor FlakyNotificationCenter: AutomationNotificationCenterClient { + struct Failure: Error {} + private let failingAttempts: Set + private var attempts = 0 + + init(failingAttempts: Set) { + self.failingAttempts = failingAttempts + } + + func requestAuthorization() async throws -> Bool { true } + + func deliver(_ content: AutomationNotificationContent) async throws { + attempts += 1 + if failingAttempts.contains(attempts) { throw Failure() } + } + + func deliveryAttempts() -> Int { attempts } +} + +private actor GatedRetrySleeper { + private var wasCalled = false + private var callWaiters: [CheckedContinuation] = [] + private var sleepWaiters: [CheckedContinuation] = [] + + func sleep() async throws { + wasCalled = true + let waiters = callWaiters + callWaiters.removeAll() + for waiter in waiters { waiter.resume() } + await withCheckedContinuation { sleepWaiters.append($0) } + } + + func waitUntilCalled() async { + guard !wasCalled else { return } + await withCheckedContinuation { callWaiters.append($0) } + } + + func releaseAll() { + let waiters = sleepWaiters + sleepWaiters.removeAll() + for waiter in waiters { waiter.resume() } + } +} diff --git a/Tests/DevScopeTests/AutomationStoreTests.swift b/Tests/DevScopeTests/AutomationStoreTests.swift new file mode 100644 index 0000000..ee802ea --- /dev/null +++ b/Tests/DevScopeTests/AutomationStoreTests.swift @@ -0,0 +1,416 @@ +import DevScopeCore +import XCTest +@testable import DevScope + +@MainActor +final class AutomationStoreTests: XCTestCase { + func testRefreshPublishesCapabilityDecisionsWithTheSameInventoryGeneration() async { + let record = automationRecord(capabilities: [.exportRecord]) + let expected = AutomationCapabilityDecision( + capabilities: [.startNow, .disable, .exportRecord], + reason: "Some lifecycle operations are unavailable." + ) + let inventory = ImmediateInventoryService(snapshot: AutomationInventorySnapshot( + generation: 17, + records: [record], + health: [:], + refreshedAt: Date(timeIntervalSince1970: 17) + )) + let store = AutomationStore( + inventoryService: inventory, + manager: ImmediateAutomationManager(), + capabilityDecisionProvider: FixedCapabilityDecisionProvider( + decisions: [record.id: expected] + ) + ) + + store.refresh() + await eventually { !store.isRefreshing && store.snapshot.generation == 17 } + + XCTAssertEqual(store.capabilitySnapshot.inventoryGeneration, 17) + XCTAssertEqual(store.capabilitySnapshot.decisionsByRecordID[record.id], expected) + } + + func testMissingCapabilityDecisionFailsClosedToPublishedInspectionOnlyExport() async { + let record = automationRecord(capabilities: [.disable, .exportRecord]) + let store = AutomationStore( + inventoryService: ImmediateInventoryService(snapshot: AutomationInventorySnapshot( + generation: 21, + records: [record], + health: [:], + refreshedAt: Date(timeIntervalSince1970: 21) + )), + manager: ImmediateAutomationManager(), + capabilityDecisionProvider: FixedCapabilityDecisionProvider(decisions: [:]) + ) + + store.refresh() + await eventually { !store.isRefreshing && store.snapshot.generation == 21 } + + let decision = store.capabilitySnapshot.decisionsByRecordID[record.id] + XCTAssertEqual(decision?.capabilities, [.exportRecord]) + XCTAssertEqual( + decision?.reason, + "DevScope could not verify source ownership and path safety for management." + ) + } + + func testImportDestinationIsTheSelectedChecksumBoundSourceWhileDuplicateUsesSibling() { + let record = automationRecord( + capabilities: [.importRecord, .duplicate] + ) + let provider = AutomationManagementDestinationProvider( + transactionRoot: URL(fileURLWithPath: "/tmp/transactions") + ) + + XCTAssertEqual( + provider.importDestination(for: record, suggestedFilename: "other.plist"), + record.sourceURL + ) + XCTAssertEqual( + provider.duplicateDestination(for: record, label: "Store.Copy"), + record.sourceURL?.deletingLastPathComponent().appendingPathComponent("Store.Copy.plist") + ) + XCTAssertNil(provider.duplicateDestination(for: record, label: "../escape")) + } + + func testCompletedSnapshotPublishesBeforeQueuedOperationRefreshCanFinish() async { + let inventory = GatedInventoryService() + let manager = ImmediateAutomationManager() + let store = AutomationStore(inventoryService: inventory, manager: manager) + let record = automationRecord() + + store.refresh() + await inventory.waitUntilCall(1) + store.perform(.disable, record: record, expectedChecksum: nil, linkedProcesses: []) + await inventory.release(call: 1, generation: 1) + await inventory.waitUntilCall(2) + + XCTAssertEqual(store.snapshot.generation, 1) + XCTAssertNotNil(store.pendingOperation) + + await inventory.release(call: 2, generation: 2) + await eventually { store.pendingOperation == nil } + XCTAssertEqual(store.snapshot.generation, 2) + } + + func testOperationCompletionReadsTheManagerRefreshedCacheWithoutForcingAnotherGeneration() async { + let inventory = GatedInventoryService() + let store = AutomationStore( + inventoryService: inventory, + manager: ImmediateAutomationManager() + ) + + store.perform( + .disable, + record: automationRecord(), + expectedChecksum: nil, + linkedProcesses: [] + ) + await inventory.waitUntilCall(1) + + let callKinds = await inventory.callKinds() + XCTAssertEqual(callKinds, ["automatic"]) + await inventory.release(call: 1, generation: 1) + await eventually { store.pendingOperation == nil } + } + + func testRefreshQueueIsBoundedToOneTrailingRequest() async { + let inventory = GatedInventoryService() + let store = AutomationStore( + inventoryService: inventory, + manager: ImmediateAutomationManager() + ) + + store.refresh() + await inventory.waitUntilCall(1) + for _ in 0..<20 { store.refresh() } + await inventory.release(call: 1, generation: 1) + await inventory.waitUntilCall(2) + let callsWhileTrailing = await inventory.callCount() + XCTAssertEqual(callsWhileTrailing, 2) + await inventory.release(call: 2, generation: 2) + await eventually { !store.isRefreshing } + let finalCalls = await inventory.callCount() + XCTAssertEqual(finalCalls, 2) + } + + func testStopInvalidatesOldCompletionAndRestartCanPublishNewGeneration() async { + let inventory = GatedInventoryService() + let store = AutomationStore( + inventoryService: inventory, + manager: ImmediateAutomationManager() + ) + + store.refresh() + await inventory.waitUntilCall(1) + store.stop() + store.refresh() + await inventory.waitUntilCall(2) + await inventory.release(call: 1, generation: 41) + await Task.yield() + XCTAssertNotEqual(store.snapshot.generation, 41) + await inventory.release(call: 2, generation: 42) + await eventually { store.snapshot.generation == 42 } + } + + func testStopPreservesAuthorizedOperationUntilResultAndEvidenceArePublished() async { + let manager = GatedAutomationManager() + let store = AutomationStore( + inventoryService: ImmediateInventoryService( + snapshot: ProcessStoreLiveSnapshot.emptyAutomationInventory + ), + manager: manager + ) + store.perform(.disable, record: automationRecord(), expectedChecksum: nil, linkedProcesses: []) + await manager.waitUntilCalled() + XCTAssertNotNil(store.pendingOperation) + + store.stop() + XCTAssertNotNil(store.pendingOperation) + await manager.release() + await eventually { store.pendingOperation == nil } + XCTAssertEqual(store.operationResult?.status, .succeeded) + XCTAssertEqual(store.operationResultRecordID, automationRecord().id) + } + + func testLifecycleUsesOne350MillisecondInitialDelayAndAtLeast60SecondCadence() async { + let inventory = GatedInventoryService() + let sleeper = ManualSleeper() + let store = AutomationStore( + inventoryService: inventory, + manager: ImmediateAutomationManager(), + sleep: { try await sleeper.sleep($0) } + ) + + store.start() + store.start() + await sleeper.waitUntilCall(1) + let firstDuration = await sleeper.duration(call: 1) + let initialCallCount = await sleeper.callCount() + XCTAssertEqual(firstDuration, .milliseconds(350)) + XCTAssertEqual(initialCallCount, 1) + await sleeper.release(call: 1) + await inventory.waitUntilCall(1) + await sleeper.waitUntilCall(2) + let observedCadence = await sleeper.duration(call: 2) + let cadence = try! XCTUnwrap(observedCadence) + XCTAssertGreaterThanOrEqual(cadence, .seconds(60)) + + store.stop() + store.start() + await sleeper.waitUntilCall(3) + let restartedDuration = await sleeper.duration(call: 3) + XCTAssertEqual(restartedDuration, .milliseconds(350)) + store.stop() + await sleeper.releaseAll() + await inventory.releaseAll() + } +} + +private actor GatedInventoryService: AutomationInventoryRefreshing { + private struct Pending { + let call: Int + let continuation: CheckedContinuation + } + + private var calls = 0 + private var kinds: [String] = [] + private var pending: [Int: Pending] = [:] + private var waiters: [(Int, CheckedContinuation)] = [] + + func refresh(force: Bool) async -> AutomationInventorySnapshot { + kinds.append(force ? "force" : "automatic") + return await next() + } + + func refreshAfterCurrent() async -> AutomationInventorySnapshot { + kinds.append("afterCurrent") + return await next() + } + + private func next() async -> AutomationInventorySnapshot { + calls += 1 + let call = calls + let ready = waiters.filter { $0.0 <= calls } + waiters.removeAll { $0.0 <= calls } + for (_, waiter) in ready { waiter.resume() } + return await withCheckedContinuation { continuation in + pending[call] = Pending(call: call, continuation: continuation) + } + } + + func waitUntilCall(_ target: Int) async { + guard calls < target else { return } + await withCheckedContinuation { waiters.append((target, $0)) } + } + + func release(call: Int, generation: UInt64) { + pending.removeValue(forKey: call)?.continuation.resume(returning: AutomationInventorySnapshot( + generation: generation, + records: [], + health: [:], + refreshedAt: Date(timeIntervalSince1970: TimeInterval(generation)) + )) + } + + func callCount() -> Int { calls } + func callKinds() -> [String] { kinds } + + func releaseAll() { + for (call, value) in pending.sorted(by: { $0.key < $1.key }) { + value.continuation.resume(returning: AutomationInventorySnapshot( + generation: UInt64(call), records: [], health: [:], refreshedAt: .distantPast + )) + } + pending.removeAll() + } +} + +private actor ImmediateAutomationManager: AutomationManaging { + func perform( + _ operation: AutomationOperation, + record: AutomationRecord, + expectedChecksum: String?, + linkedProcesses: [ClassifiedDevProcess] + ) async -> AutomationOperationResult { + AutomationOperationResult( + operation: operation, + status: .succeeded, + appliedSteps: [], + verificationEvidence: [], + rollback: .notNeeded, + manualRecovery: nil + ) + } +} + +private actor GatedAutomationManager: AutomationManaging { + private var continuation: CheckedContinuation? + private var callWaiter: CheckedContinuation? + private var called = false + + func perform( + _ operation: AutomationOperation, + record: AutomationRecord, + expectedChecksum: String?, + linkedProcesses: [ClassifiedDevProcess] + ) async -> AutomationOperationResult { + called = true + callWaiter?.resume() + callWaiter = nil + await withCheckedContinuation { continuation = $0 } + return AutomationOperationResult( + operation: operation, status: .succeeded, appliedSteps: [], + verificationEvidence: [], rollback: .notNeeded, manualRecovery: nil + ) + } + + func waitUntilCalled() async { + guard !called else { return } + await withCheckedContinuation { callWaiter = $0 } + } + + func release() { + continuation?.resume() + continuation = nil + } +} + +private actor ManualSleeper { + private var calls: [Int: (Duration, CheckedContinuation)] = [:] + private var nextCall = 0 + private var waiters: [(Int, CheckedContinuation)] = [] + + func sleep(_ duration: Duration) async throws { + nextCall += 1 + let call = nextCall + let ready = waiters.filter { $0.0 <= nextCall } + waiters.removeAll { $0.0 <= nextCall } + for (_, waiter) in ready { waiter.resume() } + await withCheckedContinuation { calls[call] = (duration, $0) } + try Task.checkCancellation() + } + + func waitUntilCall(_ target: Int) async { + guard nextCall < target else { return } + await withCheckedContinuation { waiters.append((target, $0)) } + } + + func duration(call: Int) -> Duration? { calls[call]?.0 } + func callCount() -> Int { nextCall } + + func release(call: Int) { + calls.removeValue(forKey: call)?.1.resume() + } + + func releaseAll() { + let continuations = calls.values.map(\.1) + calls.removeAll() + for continuation in continuations { continuation.resume() } + } +} + +@MainActor +private func eventually( + _ predicate: @MainActor () -> Bool, + file: StaticString = #filePath, + line: UInt = #line +) async { + for _ in 0..<100 where !predicate() { + await Task.yield() + } + if !predicate() { + XCTFail("Timed out waiting for asynchronous store state.", file: file, line: line) + } +} + +private func automationRecord( + capabilities: Set = [] +) -> AutomationRecord { + AutomationRecord( + id: AutomationRecord.ID(rawValue: "store-test"), + kind: .launchAgent, + sourceKind: .launchAgent, + label: "Store Test", + displayName: "Store Test", + providerBundleIdentifier: nil, + ownerUID: 501, + ownership: .user, + executable: "/bin/true", + arguments: [], + environment: [:], + workingDirectory: nil, + schedule: AutomationSchedule(triggers: [.demand], summary: "On demand"), + sourceURL: URL(fileURLWithPath: "/tmp/store-test.plist"), + sourceChecksum: nil, + enabledState: .enabled, + loadState: .unknown, + approvalState: .notApplicable, + state: .idle, + evidence: [], + capabilities: capabilities, + validationFindings: [] + ) +} + +private actor ImmediateInventoryService: AutomationInventoryRefreshing { + let snapshot: AutomationInventorySnapshot + + init(snapshot: AutomationInventorySnapshot) { + self.snapshot = snapshot + } + + func refresh(force: Bool) async -> AutomationInventorySnapshot { snapshot } + func refreshAfterCurrent() async -> AutomationInventorySnapshot { snapshot } +} + +private struct FixedCapabilityDecisionProvider: AutomationCapabilityDecisionProviding { + let decisions: [AutomationRecord.ID: AutomationCapabilityDecision] + + func decisions( + for records: [AutomationRecord] + ) async -> [AutomationRecord.ID: AutomationCapabilityDecision] { + decisions + } +} diff --git a/Tests/DevScopeTests/AutomationVisualIdentityTests.swift b/Tests/DevScopeTests/AutomationVisualIdentityTests.swift new file mode 100644 index 0000000..92b90bc --- /dev/null +++ b/Tests/DevScopeTests/AutomationVisualIdentityTests.swift @@ -0,0 +1,80 @@ +import DevScopeCore +import XCTest + +@testable import DevScope + +final class AutomationVisualIdentityTests: XCTestCase { + func testEveryAutomationKindHasAnExactStableFallbackIdentity() { + let expected: [AutomationKind: AutomationVisualIdentity] = [ + .launchAgent: .init( + symbolName: "bolt.horizontal", + colorToken: .blue, + accessibilityTitle: "LaunchAgent" + ), + .launchDaemon: .init( + symbolName: "bolt.horizontal.circle", + colorToken: .indigo, + accessibilityTitle: "LaunchDaemon" + ), + .loginItem: .init( + symbolName: "person.crop.circle.badge.clock", + colorToken: .cyan, + accessibilityTitle: "Login item" + ), + .backgroundItem: .init( + symbolName: "person.crop.circle.badge.clock", + colorToken: .cyan, + accessibilityTitle: "Background item" + ), + .cron: .init( + symbolName: "calendar.badge.clock", + colorToken: .purple, + accessibilityTitle: "Scheduled automation" + ), + ] + + XCTAssertEqual(Set(expected.keys), Set(AutomationKind.allCases)) + for kind in AutomationKind.allCases { + XCTAssertEqual(AutomationVisualIdentity.fallback(for: kind), expected[kind]) + } + } + + func testAutomationStatesKeepSymbolTextAndSemanticColorTogether() { + let expected: [(AutomationState, String, AutomationColorToken, String)] = [ + (.running, "play.circle.fill", .green, "Running"), + (.idle, "pause.circle", .secondary, "Idle"), + (.disabled, "nosign", .secondary, "Disabled"), + (.needsApproval, "person.badge.clock", .orange, "Needs Approval"), + (.invalid, "exclamationmark.triangle", .red, "Invalid"), + (.unresolved, "questionmark.circle", .orange, "Unresolved"), + ] + + for (state, symbolName, colorToken, accessibilityTitle) in expected { + let identity = AutomationVisualIdentity.state(for: state) + XCTAssertEqual(identity.symbolName, symbolName) + XCTAssertEqual(identity.colorToken, colorToken) + XCTAssertEqual(identity.accessibilityTitle, accessibilityTitle) + } + } + + func testEveryAutomationStateLabelCarriesVisibleTextSymbolAndColor() { + let expected: [(AutomationState, String, String, AutomationColorToken)] = [ + (.running, "Running", "play.circle.fill", .green), + (.idle, "Idle", "pause.circle", .secondary), + (.disabled, "Disabled", "nosign", .secondary), + (.needsApproval, "Needs Approval", "person.badge.clock", .orange), + (.invalid, "Invalid", "exclamationmark.triangle", .red), + (.unresolved, "Unresolved", "questionmark.circle", .orange), + ] + + XCTAssertEqual(expected.count, AutomationState.allCases.count) + for (state, title, symbolName, colorToken) in expected { + let presentation = AutomationStateLabelPresentation(state: state) + XCTAssertEqual(presentation.title, title) + XCTAssertEqual(presentation.symbolName, symbolName) + XCTAssertEqual(presentation.colorToken, colorToken) + XCTAssertFalse(presentation.title.isEmpty) + XCTAssertFalse(presentation.symbolName.isEmpty) + } + } +} diff --git a/Tests/DevScopeTests/AutomationWorkspaceLayoutPolicyTests.swift b/Tests/DevScopeTests/AutomationWorkspaceLayoutPolicyTests.swift new file mode 100644 index 0000000..a5d5503 --- /dev/null +++ b/Tests/DevScopeTests/AutomationWorkspaceLayoutPolicyTests.swift @@ -0,0 +1,87 @@ +import XCTest + +@testable import DevScope + +final class AutomationWorkspaceLayoutPolicyTests: XCTestCase { + func testConstraintsFitEverySupportedWindowWidth() { + for width in [1080.0, 1280.0, 1728.0] { + let value = AutomationWorkspaceLayoutPolicy.constraints(availableWidth: width) + + XCTAssertLessThanOrEqual(value.minimumTotal, width) + XCTAssertGreaterThan(value.tableMinimum, value.railMinimum) + XCTAssertEqual(value.detailPriority, value.tablePriority) + } + } + + func testWiderWindowsIncreasePreferenceWithoutMakingPanelsFixed() { + let compact = AutomationWorkspaceLayoutPolicy.constraints(availableWidth: 1080) + let wide = AutomationWorkspaceLayoutPolicy.constraints(availableWidth: 1728) + + XCTAssertGreaterThan(wide.tablePreferred, compact.tablePreferred) + XCTAssertNotEqual(compact.railPreferred, wide.railPreferred) + XCTAssertNotEqual(compact.detailPreferred, wide.detailPreferred) + } + + func testMinimumsProtectSplitterAllowanceAcrossBoundaryWidths() { + let widths: [CGFloat] = [0, 17, 18, 19, 797, 798, 799] + + for width in widths { + let value = AutomationWorkspaceLayoutPolicy.constraints(availableWidth: width) + let usableWidth = max(0, width - 18) + let expectedMinimumTotal = min(usableWidth, 780) + + XCTAssertEqual(value.minimumTotal, expectedMinimumTotal, accuracy: 0.000_001) + XCTAssertLessThanOrEqual(value.minimumTotal, usableWidth + 0.000_001) + for minimum in [value.railMinimum, value.tableMinimum, value.detailMinimum] { + XCTAssertTrue(minimum.isFinite, "minimum must be finite at width \(width)") + XCTAssertGreaterThanOrEqual(minimum, 0, "minimum must be nonnegative at width \(width)") + } + } + } + + func testPreferencesRemainValidDuringTransientBoundaryWidths() { + let widths: [CGFloat] = [0, 1, 17, 18, 19, 400, 797, 798, 808, 828, 1080] + + for width in widths { + let value = AutomationWorkspaceLayoutPolicy.constraints(availableWidth: width) + let usableWidth = max(0, width - 18) + let preferences = [value.railPreferred, value.tablePreferred, value.detailPreferred] + let minimums = [value.railMinimum, value.tableMinimum, value.detailMinimum] + + XCTAssertLessThanOrEqual( + preferences.reduce(0, +), + usableWidth + 0.000_001, + "preferred widths must fit at width \(width)" + ) + for (preferred, minimum) in zip(preferences, minimums) { + XCTAssertTrue(preferred.isFinite, "preference must be finite at width \(width)") + XCTAssertGreaterThanOrEqual(preferred, 0, "preference must be nonnegative at width \(width)") + XCTAssertGreaterThanOrEqual( + preferred + 0.000_001, + minimum, + "preference must not undercut its minimum at width \(width)" + ) + } + } + } + + func testMinimumScalingUsesUsableWidthUntilItCaps() { + for width: CGFloat in [19, 797] { + let value = AutomationWorkspaceLayoutPolicy.constraints(availableWidth: width) + let expectedScale = (width - 18) / 780 + + XCTAssertEqual(value.railMinimum, 150 * expectedScale, accuracy: 0.000_001) + XCTAssertEqual(value.tableMinimum, 360 * expectedScale, accuracy: 0.000_001) + XCTAssertEqual(value.detailMinimum, 270 * expectedScale, accuracy: 0.000_001) + } + + for width: CGFloat in [798, 799, 1728] { + let value = AutomationWorkspaceLayoutPolicy.constraints(availableWidth: width) + + XCTAssertEqual(value.railMinimum, 150, accuracy: 0.000_001) + XCTAssertEqual(value.tableMinimum, 360, accuracy: 0.000_001) + XCTAssertEqual(value.detailMinimum, 270, accuracy: 0.000_001) + XCTAssertEqual(value.minimumTotal, 780, accuracy: 0.000_001) + } + } +} diff --git a/Tests/DevScopeTests/LaunchctlAutomationRuntimeStateProviderTests.swift b/Tests/DevScopeTests/LaunchctlAutomationRuntimeStateProviderTests.swift new file mode 100644 index 0000000..e329afe --- /dev/null +++ b/Tests/DevScopeTests/LaunchctlAutomationRuntimeStateProviderTests.swift @@ -0,0 +1,173 @@ +import DevScopeCore +import Foundation +import XCTest + +@testable import DevScope + +final class LaunchctlAutomationRuntimeStateProviderTests: XCTestCase { + func testParsesNamedAndBooleanPrintDisabledValuesAndRejectsConflicts() { + let parsed = LaunchctlAutomationRuntimeStateProvider.parseEnabledStates( + Data( + """ + disabled services = { + "com.example.disabled" => disabled + "com.example.enabled" => enabled + "com.example.true" => true + "com.example.false" => false + } + """.utf8)) + + XCTAssertEqual(parsed?["com.example.disabled"], .disabled) + XCTAssertEqual(parsed?["com.example.enabled"], .enabled) + XCTAssertEqual(parsed?["com.example.true"], .disabled) + XCTAssertEqual(parsed?["com.example.false"], .enabled) + XCTAssertNil( + LaunchctlAutomationRuntimeStateProvider.parseEnabledStates( + Data( + """ + "com.example.conflict" => enabled + "com.example.conflict" => disabled + """.utf8))) + } + + func testQueriesOneDomainMapAndDistinguishesMissingTargetsFromOperationalFailures() async { + let runner = RuntimeStateCommandRunner() + let provider = LaunchctlAutomationRuntimeStateProvider(runner: runner) + + let states = await provider.states( + for: [ + "com.example.loaded", + "com.example.disabled", + "com.example.default", + "com.example.operational-failure", + ], + guiUID: 501 + ) + + XCTAssertEqual( + states["com.example.loaded"], + LaunchdRuntimeState( + enabledState: .enabled, + loadState: .loaded + )) + XCTAssertEqual( + states["com.example.disabled"], + LaunchdRuntimeState( + enabledState: .disabled, + loadState: .unloaded + )) + XCTAssertEqual( + states["com.example.default"], + LaunchdRuntimeState( + enabledState: .enabled, + loadState: .unloaded + )) + XCTAssertEqual( + states["com.example.operational-failure"], + LaunchdRuntimeState( + enabledState: .enabled, + loadState: .unknown + )) + let invocations = await runner.invocations() + XCTAssertEqual(invocations.filter { $0.arguments == ["print-disabled", "gui/501"] }.count, 1) + XCTAssertEqual( + Set(invocations.filter { $0.arguments.first == "print" }.map { $0.arguments.last! }), + [ + "gui/501/com.example.loaded", + "gui/501/com.example.disabled", + "gui/501/com.example.default", + "gui/501/com.example.operational-failure", + ] + ) + XCTAssertTrue(invocations.allSatisfy { $0.environment["LC_ALL"] == "C" }) + } + + func testDeduplicatesLabelsAndBoundsConcurrentPrintQueries() async { + let runner = ConcurrencyTrackingRuntimeStateCommandRunner() + let provider = LaunchctlAutomationRuntimeStateProvider(runner: runner) + let uniqueLabels = (0..<12).map { "com.example.job-\($0)" } + + let states = await provider.states( + for: uniqueLabels + [uniqueLabels[3], uniqueLabels[7], uniqueLabels[3]], + guiUID: 501 + ) + let metrics = await runner.metrics() + + XCTAssertEqual(states.count, uniqueLabels.count) + XCTAssertEqual(metrics.printInvocations, uniqueLabels.count) + XCTAssertLessThanOrEqual(metrics.maximumConcurrentPrints, 4) + for (index, label) in uniqueLabels.enumerated() { + XCTAssertEqual(states[label]?.loadState, index.isMultiple(of: 2) ? .loaded : .unloaded) + } + } +} + +private actor RuntimeStateCommandRunner: AutomationCommandRunning { + private var commands: [AutomationCommand] = [] + + func run(_ command: AutomationCommand) async throws -> AutomationCommandResult { + commands.append(command) + if command.arguments == ["print-disabled", "gui/501"] { + return AutomationCommandResult( + status: 0, + standardOutput: Data("\"com.example.disabled\" => disabled\n".utf8), + standardError: Data() + ) + } + if command.arguments.last == "gui/501/com.example.loaded" { + return AutomationCommandResult(status: 0, standardOutput: Data(), standardError: Data()) + } + if command.arguments.last == "gui/501/com.example.operational-failure" { + return AutomationCommandResult( + status: 113, + standardOutput: Data(), + standardError: Data("Operation not permitted\n".utf8) + ) + } + let label = command.arguments.last!.split(separator: "/").last! + return AutomationCommandResult( + status: 113, + standardOutput: Data(), + standardError: Data( + "Bad request.\nCould not find service \"\(label)\" in domain for user gui: 501\n".utf8 + ) + ) + } + + func invocations() -> [AutomationCommand] { commands } +} + +private actor ConcurrencyTrackingRuntimeStateCommandRunner: AutomationCommandRunning { + private var printInvocations = 0 + private var activePrints = 0 + private var maximumConcurrentPrints = 0 + + func run(_ command: AutomationCommand) async throws -> AutomationCommandResult { + guard command.arguments.first == "print" else { + return AutomationCommandResult(status: 0, standardOutput: Data(), standardError: Data()) + } + printInvocations += 1 + activePrints += 1 + maximumConcurrentPrints = max(maximumConcurrentPrints, activePrints) + defer { activePrints -= 1 } + try? await Task.sleep(for: .milliseconds(10)) + + let target = command.arguments.last! + let label = String(target.split(separator: "/").last!) + let index = Int(label.split(separator: "-").last!)! + if index.isMultiple(of: 2) { + return AutomationCommandResult(status: 0, standardOutput: Data(), standardError: Data()) + } + return AutomationCommandResult( + status: 113, + standardOutput: Data(), + standardError: Data( + "Bad request.\nCould not find service \"\(label)\" in domain for user gui: 501\n".utf8 + ) + ) + } + + func metrics() -> (printInvocations: Int, maximumConcurrentPrints: Int) { + (printInvocations, maximumConcurrentPrints) + } +} diff --git a/Tests/DevScopeTests/LocalAutomationFileSystemTests.swift b/Tests/DevScopeTests/LocalAutomationFileSystemTests.swift new file mode 100644 index 0000000..9bb5174 --- /dev/null +++ b/Tests/DevScopeTests/LocalAutomationFileSystemTests.swift @@ -0,0 +1,564 @@ +import Foundation +import DevScopeCore +import XCTest +@testable import DevScope + +final class LocalAutomationFileSystemTests: XCTestCase { + func testReadRejectsFilesLargerThanTheConfiguredBoundary() throws { + let fixture = try FileSystemFixture() + defer { fixture.cleanup() } + let source = fixture.approved.appending(path: "oversized.plist") + try Data(repeating: 0x41, count: 65).write(to: source) + let fileSystem = LocalAutomationFileSystem(maxReadableBytes: 64) + + XCTAssertThrowsError(try fileSystem.read(source)) { error in + XCTAssertEqual((error as? POSIXError)?.code, .EFBIG) + } + } + + func testFileAuthorizationAcceptsEquivalentParentWithDifferentDirectoryHint() throws { + let parentWithHint = URL(fileURLWithPath: "/tmp/devscope-authorization", isDirectory: true) + let parentWithoutHint = URL(fileURLWithPath: "/tmp/devscope-authorization") + let directory = try XCTUnwrap(AutomationDirectoryAuthorization( + directoryURL: parentWithHint, + resourceIdentifier: "device:inode" + )) + + XCTAssertNotNil(AutomationFileAuthorization( + fileURL: parentWithoutHint.appendingPathComponent("source.plist"), + directory: directory, + expectation: .absent + )) + } + + func testTemporaryWriteRejectsAuthorizedParentReplacedBySymlink() throws { + let fixture = try FileSystemFixture() + defer { fixture.cleanup() } + let fileSystem = LocalAutomationFileSystem() + let authorization = try fixture.authorization(using: fileSystem) + + try fixture.swapApprovedParentForSymlinkToOutside() + _ = try fileSystem.metadata(for: fixture.approved) + XCTAssertThrowsError(try fileSystem.writeStagedFile( + nextTo: fixture.approved.appending(path: "target"), + data: Data("safe".utf8), + permissions: 0o600, + authorization: authorization + )) + XCTAssertEqual(try FileManager.default.contentsOfDirectory(atPath: fixture.outside.path), []) + } + + func testTemporaryWriteRejectsAuthorizedParentReplacedByDifferentDirectory() throws { + let fixture = try FileSystemFixture() + defer { fixture.cleanup() } + let fileSystem = LocalAutomationFileSystem() + let authorization = try fixture.authorization(using: fileSystem) + + try fixture.swapApprovedParentForFreshDirectory() + _ = try fileSystem.metadata(for: fixture.approved) + XCTAssertThrowsError(try fileSystem.writeStagedFile( + nextTo: fixture.approved.appending(path: "target"), + data: Data("safe".utf8), + permissions: 0o600, + authorization: authorization + )) + XCTAssertEqual(try FileManager.default.contentsOfDirectory(atPath: fixture.approved.path), []) + } + + func testRemoveRejectsParentSwapAndDoesNotUnlinkOutsideFile() throws { + let fixture = try FileSystemFixture() + defer { fixture.cleanup() } + let source = fixture.approved.appending(path: "same-name") + let outside = fixture.outside.appending(path: "same-name") + try Data("approved".utf8).write(to: source) + try Data("outside".utf8).write(to: outside) + let fileSystem = LocalAutomationFileSystem() + let authorization = try fixture.authorization(using: fileSystem) + let sourceAuthorization = try fixture.fileAuthorization( + source, directory: authorization, fileSystem: fileSystem + ) + + try fixture.swapApprovedParentForSymlinkToOutside() + _ = try fileSystem.metadata(for: fixture.approved) + XCTAssertThrowsError(try fileSystem.removeItem(sourceAuthorization)) + XCTAssertEqual(try Data(contentsOf: outside), Data("outside".utf8)) + } + + func testRemoveRejectsLeafReplacementEvenAfterMetadataReadsReplacement() throws { + let fixture = try FileSystemFixture() + defer { fixture.cleanup() } + let fileSystem = LocalAutomationFileSystem() + let directory = try fixture.authorization(using: fileSystem) + let source = fixture.approved.appending(path: "source") + try Data("original".utf8).write(to: source) + let sourceAuthorization = try fixture.fileAuthorization( + source, directory: directory, fileSystem: fileSystem + ) + + try FileManager.default.removeItem(at: source) + try Data("replacement".utf8).write(to: source) + _ = try fileSystem.metadata(for: source) + XCTAssertThrowsError(try fileSystem.removeItem(sourceAuthorization)) + XCTAssertEqual(try Data(contentsOf: source), Data("replacement".utf8)) + } + + func testTrashRejectsLeafReplacementAndRestoresReplacementInPlace() throws { + let fixture = try FileSystemFixture() + defer { fixture.cleanup() } + let fileSystem = LocalAutomationFileSystem() + let directory = try fixture.authorization(using: fileSystem) + let source = fixture.approved.appending(path: "trash-source") + try Data("original".utf8).write(to: source) + let sourceAuthorization = try fixture.fileAuthorization( + source, directory: directory, fileSystem: fileSystem + ) + + try FileManager.default.removeItem(at: source) + try Data("replacement".utf8).write(to: source) + XCTAssertThrowsError(try fileSystem.moveToTrash(sourceAuthorization)) + XCTAssertEqual(try Data(contentsOf: source), Data("replacement".utf8)) + } + + func testReplaceRejectsStagedFileReplacement() throws { + let fixture = try FileSystemFixture() + defer { fixture.cleanup() } + let fileSystem = LocalAutomationFileSystem() + let directory = try fixture.authorization(using: fileSystem) + let destinationURL = fixture.approved.appending(path: "destination") + try Data("destination".utf8).write(to: destinationURL) + let destination = try fixture.fileAuthorization( + destinationURL, directory: directory, fileSystem: fileSystem + ) + let staged = try fileSystem.writeStagedFile( + nextTo: destinationURL, + data: Data("intended".utf8), + permissions: 0o600, + authorization: directory + ) + + try FileManager.default.removeItem(at: staged.url) + try Data("attacker".utf8).write(to: staged.url) + _ = try fileSystem.metadata(for: staged.url) + XCTAssertThrowsError(try fileSystem.replaceItem(at: destination, with: staged)) + XCTAssertEqual(try Data(contentsOf: destinationURL), Data("destination".utf8)) + } + + func testReplaceRejectsSameInodeStagedByteMutationAtCommitBoundary() throws { + let fixture = try FileSystemFixture() + defer { fixture.cleanup() } + let fileSystem = LocalAutomationFileSystem() + let directory = try fixture.authorization(using: fileSystem) + let destinationURL = fixture.approved.appending(path: "destination") + try Data("destination".utf8).write(to: destinationURL) + let destination = try fixture.fileAuthorization( + destinationURL, directory: directory, fileSystem: fileSystem + ) + let staged = try fileSystem.writeStagedFile( + nextTo: destinationURL, + data: Data("reviewed-bytes".utf8), + permissions: 0o600, + authorization: directory + ) + let stagedIdentity = try fileSystem.metadata(for: staged.url).resourceIdentifier + + let handle = try FileHandle(forWritingTo: staged.url) + try handle.truncate(atOffset: 0) + try handle.write(contentsOf: Data("attacker-bytes".utf8)) + try handle.close() + XCTAssertEqual(try fileSystem.metadata(for: staged.url).resourceIdentifier, stagedIdentity) + + XCTAssertThrowsError(try fileSystem.replaceItem(at: destination, with: staged)) + XCTAssertEqual(try Data(contentsOf: destinationURL), Data("destination".utf8)) + } + + func testReplaceRejectsSameInodeStagedModeMutationAtCommitBoundary() throws { + let fixture = try FileSystemFixture() + defer { fixture.cleanup() } + let fileSystem = LocalAutomationFileSystem() + let directory = try fixture.authorization(using: fileSystem) + let destinationURL = fixture.approved.appending(path: "destination") + try Data("destination".utf8).write(to: destinationURL) + let destination = try fixture.fileAuthorization( + destinationURL, directory: directory, fileSystem: fileSystem + ) + let staged = try fileSystem.writeStagedFile( + nextTo: destinationURL, + data: Data("reviewed-bytes".utf8), + permissions: 0o600, + authorization: directory + ) + let stagedIdentity = try fileSystem.metadata(for: staged.url).resourceIdentifier + + try FileManager.default.setAttributes( + [.posixPermissions: 0o644], + ofItemAtPath: staged.url.path + ) + XCTAssertEqual(try fileSystem.metadata(for: staged.url).resourceIdentifier, stagedIdentity) + + XCTAssertThrowsError(try fileSystem.replaceItem(at: destination, with: staged)) + XCTAssertEqual(try Data(contentsOf: destinationURL), Data("destination".utf8)) + } + + func testReplaceRestoresDestinationWhenStagedInodeMutatesAfterPrecheck() throws { + let fixture = try FileSystemFixture() + defer { fixture.cleanup() } + let fileSystem = LocalAutomationFileSystem(hooks: .init( + beforeReplaceCommit: { stagedURL in + let handle = try! FileHandle(forWritingTo: stagedURL) + try! handle.truncate(atOffset: 0) + try! handle.write(contentsOf: Data("attacker-bytes".utf8)) + try! handle.close() + } + )) + let directory = try fixture.authorization(using: fileSystem) + let destinationURL = fixture.approved.appending(path: "destination") + try Data("destination".utf8).write(to: destinationURL) + let destination = try fixture.fileAuthorization( + destinationURL, directory: directory, fileSystem: fileSystem + ) + let staged = try fileSystem.writeStagedFile( + nextTo: destinationURL, + data: Data("reviewed-bytes".utf8), + permissions: 0o600, + authorization: directory + ) + + let outcome = try fileSystem.replaceItem(at: destination, with: staged) + + XCTAssertEqual(outcome, .unchanged) + XCTAssertEqual(try Data(contentsOf: destinationURL), Data("destination".utf8)) + XCTAssertFalse(FileManager.default.fileExists(atPath: staged.url.path)) + } + + func testReplaceRestoresAbsentDestinationAndRemovesExactStagedInode() throws { + let fixture = try FileSystemFixture() + defer { fixture.cleanup() } + let fileSystem = LocalAutomationFileSystem(hooks: .init( + faults: [.replacePostRenameVerification] + )) + let directory = try fixture.authorization(using: fileSystem) + let destinationURL = fixture.approved.appending(path: "destination") + let destination = try XCTUnwrap(AutomationFileAuthorization( + fileURL: destinationURL, + directory: directory, + expectation: .absent + )) + let staged = try fileSystem.writeStagedFile( + nextTo: destinationURL, + data: Data("reviewed".utf8), + permissions: 0o600, + authorization: directory + ) + + XCTAssertEqual(try fileSystem.replaceItem(at: destination, with: staged), .unchanged) + XCTAssertFalse(FileManager.default.fileExists(atPath: destinationURL.path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: staged.url.path)) + } + + func testReplaceRestoreCleanupFailureReportsExactStagedRecovery() throws { + let fixture = try FileSystemFixture() + defer { fixture.cleanup() } + let fileSystem = LocalAutomationFileSystem(hooks: .init( + faults: [.replacePostRenameVerification, .replaceRestoredStagedUnlink] + )) + let directory = try fixture.authorization(using: fileSystem) + let destinationURL = fixture.approved.appending(path: "destination") + let destination = try XCTUnwrap(AutomationFileAuthorization( + fileURL: destinationURL, + directory: directory, + expectation: .absent + )) + let staged = try fileSystem.writeStagedFile( + nextTo: destinationURL, + data: Data("reviewed".utf8), + permissions: 0o600, + authorization: directory + ) + + XCTAssertThrowsError(try fileSystem.replaceItem(at: destination, with: staged)) { error in + let partial = try! XCTUnwrap(error as? AutomationFilePartialMutation) + XCTAssertEqual(partial.kind, .remove) + XCTAssertEqual(partial.commitState, .unknown) + XCTAssertEqual(partial.recoveryHandle, staged.authorization) + XCTAssertEqual(partial.observedFiles, [staged.authorization]) + } + XCTAssertFalse(FileManager.default.fileExists(atPath: destinationURL.path)) + XCTAssertEqual(try Data(contentsOf: staged.url), Data("reviewed".utf8)) + } + + func testDisplacedUnlinkFailureReportsCommittedInstallAndExactRecoveryHandle() throws { + let fixture = try FileSystemFixture() + defer { fixture.cleanup() } + let fileSystem = LocalAutomationFileSystem(hooks: .init( + faults: [.replaceDisplacedUnlink] + )) + let directory = try fixture.authorization(using: fileSystem) + let destinationURL = fixture.approved.appending(path: "destination") + try Data("original".utf8).write(to: destinationURL) + let destination = try fixture.fileAuthorization( + destinationURL, directory: directory, fileSystem: fileSystem + ) + let staged = try fileSystem.writeStagedFile( + nextTo: destinationURL, + data: Data("reviewed".utf8), + permissions: 0o600, + authorization: directory + ) + + XCTAssertThrowsError(try fileSystem.replaceItem(at: destination, with: staged)) { error in + let partial = try! XCTUnwrap(error as? AutomationFilePartialMutation) + XCTAssertEqual(partial.commitState, .committed) + XCTAssertEqual(partial.observedFiles.first?.fileURL, destinationURL) + let recovery = try! XCTUnwrap(partial.recoveryHandle) + XCTAssertEqual(try! fileSystem.read(recovery.fileURL), Data("original".utf8)) + } + XCTAssertEqual(try Data(contentsOf: destinationURL), Data("reviewed".utf8)) + } + + func testFailedRestoreCollisionReportsEveryObservedEntryAndInstalledRecovery() throws { + let fixture = try FileSystemFixture() + defer { fixture.cleanup() } + let fileSystem = LocalAutomationFileSystem(hooks: .init( + faults: [.replacePostRenameVerification, .replaceRestoreCollision] + )) + let directory = try fixture.authorization(using: fileSystem) + let destinationURL = fixture.approved.appending(path: "destination") + let destination = try XCTUnwrap(AutomationFileAuthorization( + fileURL: destinationURL, + directory: directory, + expectation: .absent + )) + let staged = try fileSystem.writeStagedFile( + nextTo: destinationURL, + data: Data("reviewed".utf8), + permissions: 0o600, + authorization: directory + ) + + XCTAssertThrowsError(try fileSystem.replaceItem(at: destination, with: staged)) { error in + let partial = try! XCTUnwrap(error as? AutomationFilePartialMutation) + XCTAssertEqual(partial.commitState, .committed) + XCTAssertEqual(Set(partial.observedFiles.map(\.fileURL)).count, 2) + XCTAssertEqual(partial.recoveryHandle?.fileURL, destinationURL) + } + XCTAssertEqual(try Data(contentsOf: destinationURL), Data("reviewed".utf8)) + } + + func testFailedSwapBackReportsDisplacedOriginalAsExactRecovery() throws { + let fixture = try FileSystemFixture() + defer { fixture.cleanup() } + let fileSystem = LocalAutomationFileSystem(hooks: .init( + beforeReplaceCommit: { stagedURL in + let handle = try! FileHandle(forWritingTo: stagedURL) + try! handle.truncate(atOffset: 0) + try! handle.write(contentsOf: Data("attacker".utf8)) + try! handle.close() + }, + faults: [.replaceSwapBackFailure] + )) + let directory = try fixture.authorization(using: fileSystem) + let destinationURL = fixture.approved.appending(path: "destination") + try Data("original".utf8).write(to: destinationURL) + let destination = try fixture.fileAuthorization( + destinationURL, directory: directory, fileSystem: fileSystem + ) + let staged = try fileSystem.writeStagedFile( + nextTo: destinationURL, + data: Data("reviewed".utf8), + permissions: 0o600, + authorization: directory + ) + + XCTAssertThrowsError(try fileSystem.replaceItem(at: destination, with: staged)) { error in + let partial = try! XCTUnwrap(error as? AutomationFilePartialMutation) + XCTAssertEqual(partial.commitState, .unknown) + let recovery = try! XCTUnwrap(partial.recoveryHandle) + XCTAssertEqual(try! fileSystem.read(recovery.fileURL), Data("original".utf8)) + } + XCTAssertEqual(try Data(contentsOf: destinationURL), Data("attacker".utf8)) + } + + func testTrashFinalMoveFailureReturnsUnchangedOnlyAfterVerifiedRestore() throws { + let fixture = try FileSystemFixture() + defer { fixture.cleanup() } + let fileSystem = LocalAutomationFileSystem(hooks: .init( + faults: [.trashFinalMove], + trashDirectory: fixture.root.appending(path: "trash", directoryHint: .isDirectory) + )) + let directory = try fixture.authorization(using: fileSystem) + let sourceURL = fixture.approved.appending(path: "source") + try Data("original".utf8).write(to: sourceURL) + let source = try fixture.fileAuthorization( + sourceURL, directory: directory, fileSystem: fileSystem + ) + + XCTAssertEqual(try fileSystem.moveToTrash(source), .unchanged) + XCTAssertEqual(try Data(contentsOf: sourceURL), Data("original".utf8)) + } + + func testTrashStagedIdentityMismatchWithFailedRestoreReportsRecovery() throws { + let fixture = try FileSystemFixture() + defer { fixture.cleanup() } + let fileSystem = LocalAutomationFileSystem(hooks: .init( + faults: [.trashStagedIdentityMismatch, .trashRestoreCollision], + trashDirectory: fixture.root.appending(path: "trash", directoryHint: .isDirectory) + )) + let directory = try fixture.authorization(using: fileSystem) + let sourceURL = fixture.approved.appending(path: "source") + try Data("original".utf8).write(to: sourceURL) + let source = try fixture.fileAuthorization( + sourceURL, directory: directory, fileSystem: fileSystem + ) + + XCTAssertThrowsError(try fileSystem.moveToTrash(source)) { error in + let partial = try! XCTUnwrap(error as? AutomationFilePartialMutation) + XCTAssertEqual(partial.kind, .trash) + XCTAssertEqual(partial.commitState, .unknown) + XCTAssertEqual(partial.observedFiles.count, 2) + XCTAssertEqual(try! fileSystem.read(partial.recoveryHandle!.fileURL), Data("original".utf8)) + } + } + + func testRemoveUnlinkFailureReturnsUnchangedOnlyAfterVerifiedRestore() throws { + let fixture = try FileSystemFixture() + defer { fixture.cleanup() } + let fileSystem = LocalAutomationFileSystem(hooks: .init(faults: [.removeUnlink])) + let directory = try fixture.authorization(using: fileSystem) + let sourceURL = fixture.approved.appending(path: "source") + try Data("original".utf8).write(to: sourceURL) + let source = try fixture.fileAuthorization( + sourceURL, directory: directory, fileSystem: fileSystem + ) + + XCTAssertEqual(try fileSystem.removeItem(source), .unchanged) + XCTAssertEqual(try Data(contentsOf: sourceURL), Data("original".utf8)) + } + + func testRemoveStagedIdentityMismatchWithFailedRestoreReportsRecovery() throws { + let fixture = try FileSystemFixture() + defer { fixture.cleanup() } + let fileSystem = LocalAutomationFileSystem(hooks: .init( + faults: [.removeStagedIdentityMismatch, .removeRestoreCollision] + )) + let directory = try fixture.authorization(using: fileSystem) + let sourceURL = fixture.approved.appending(path: "source") + try Data("original".utf8).write(to: sourceURL) + let source = try fixture.fileAuthorization( + sourceURL, directory: directory, fileSystem: fileSystem + ) + + XCTAssertThrowsError(try fileSystem.removeItem(source)) { error in + let partial = try! XCTUnwrap(error as? AutomationFilePartialMutation) + XCTAssertEqual(partial.kind, .remove) + XCTAssertEqual(partial.commitState, .unknown) + XCTAssertEqual(partial.observedFiles.count, 2) + XCTAssertEqual(try! fileSystem.read(partial.recoveryHandle!.fileURL), Data("original".utf8)) + } + } + + func testAbsentDestinationThatAppearsIsNeverOverwritten() throws { + let fixture = try FileSystemFixture() + defer { fixture.cleanup() } + let fileSystem = LocalAutomationFileSystem() + let directory = try fixture.authorization(using: fileSystem) + let destinationURL = fixture.approved.appending(path: "destination") + let destination = try XCTUnwrap(AutomationFileAuthorization( + fileURL: destinationURL, + directory: directory, + expectation: .absent + )) + let staged = try fileSystem.writeStagedFile( + nextTo: destinationURL, + data: Data("intended".utf8), + permissions: 0o600, + authorization: directory + ) + + try Data("attacker".utf8).write(to: destinationURL) + XCTAssertThrowsError(try fileSystem.replaceItem(at: destination, with: staged)) + XCTAssertEqual(try Data(contentsOf: destinationURL), Data("attacker".utf8)) + } + + func testExistingDestinationReplacementIsNeverOverwritten() throws { + let fixture = try FileSystemFixture() + defer { fixture.cleanup() } + let fileSystem = LocalAutomationFileSystem() + let directory = try fixture.authorization(using: fileSystem) + let destinationURL = fixture.approved.appending(path: "destination") + try Data("original".utf8).write(to: destinationURL) + let destination = try fixture.fileAuthorization( + destinationURL, directory: directory, fileSystem: fileSystem + ) + let staged = try fileSystem.writeStagedFile( + nextTo: destinationURL, + data: Data("intended".utf8), + permissions: 0o600, + authorization: directory + ) + + try FileManager.default.removeItem(at: destinationURL) + try Data("attacker".utf8).write(to: destinationURL) + XCTAssertThrowsError(try fileSystem.replaceItem(at: destination, with: staged)) + XCTAssertEqual(try Data(contentsOf: destinationURL), Data("attacker".utf8)) + } +} + +private final class FileSystemFixture { + let root: URL + let approved: URL + let parked: URL + let outside: URL + + init() throws { + root = try FileManager.default.url( + for: .cachesDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ).appending(path: "DevScopeTests", directoryHint: .isDirectory).appending( + path: "devscope-fs-\(UUID().uuidString)", directoryHint: .isDirectory + ) + approved = root.appending(path: "approved", directoryHint: .isDirectory) + parked = root.appending(path: "parked", directoryHint: .isDirectory) + outside = root.appending(path: "outside", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: approved, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: outside, withIntermediateDirectories: true) + } + + func authorization( + using fileSystem: LocalAutomationFileSystem + ) throws -> AutomationDirectoryAuthorization { + let metadata = try fileSystem.metadata(for: approved) + return try XCTUnwrap(AutomationDirectoryAuthorization( + directoryURL: approved, + resourceIdentifier: metadata.resourceIdentifier + )) + } + + func fileAuthorization( + _ file: URL, + directory: AutomationDirectoryAuthorization, + fileSystem: LocalAutomationFileSystem + ) throws -> AutomationFileAuthorization { + let metadata = try fileSystem.metadata(for: file) + return try XCTUnwrap(AutomationFileAuthorization( + fileURL: file, + directory: directory, + expectation: .existing(resourceIdentifier: metadata.resourceIdentifier ?? "") + )) + } + + func swapApprovedParentForSymlinkToOutside() throws { + try FileManager.default.moveItem(at: approved, to: parked) + try FileManager.default.createSymbolicLink(at: approved, withDestinationURL: outside) + } + + func swapApprovedParentForFreshDirectory() throws { + try FileManager.default.moveItem(at: approved, to: parked) + try FileManager.default.createDirectory(at: approved, withIntermediateDirectories: false) + } + + func cleanup() { + try? FileManager.default.removeItem(at: root) + } +} diff --git a/Tests/DevScopeTests/ProcessSelectionPolicyTests.swift b/Tests/DevScopeTests/ProcessSelectionPolicyTests.swift new file mode 100644 index 0000000..344db45 --- /dev/null +++ b/Tests/DevScopeTests/ProcessSelectionPolicyTests.swift @@ -0,0 +1,112 @@ +import DevScopeCore +import XCTest +@testable import DevScope + +final class ProcessSelectionPolicyTests: XCTestCase { + func testPIDReuseClearsSelectionInsteadOfSelectingTheReplacementProcess() { + let original = item(pid: 42, birth: 100, cpu: 1) + let replacement = item(pid: 42, birth: 200, cpu: 2) + + let result = ProcessSelectionPolicy.reconcile( + selectedProcessID: 42, + retainedProcess: original, + currentProcesses: [replacement] + ) + + XCTAssertNil(result.selectedProcessID) + XCTAssertNil(result.retainedProcess) + } + + func testSameBirthRefreshesTheRetainedSelectionWithCurrentMetrics() { + let original = item(pid: 42, birth: 100, cpu: 1) + let refreshed = item(pid: 42, birth: 100, cpu: 9) + + let result = ProcessSelectionPolicy.reconcile( + selectedProcessID: 42, + retainedProcess: original, + currentProcesses: [refreshed] + ) + + XCTAssertEqual(result.selectedProcessID, 42) + XCTAssertEqual(result.retainedProcess, refreshed) + } + + func testSelectingDifferentPIDAdoptsTheNewProcessInsteadOfComparingOldIdentity() { + let previouslySelected = item(pid: 41, birth: 100, cpu: 1) + let newlySelected = item(pid: 42, birth: 200, cpu: 2) + + let result = ProcessSelectionPolicy.reconcile( + selectedProcessID: 42, + retainedProcess: previouslySelected, + currentProcesses: [previouslySelected, newlySelected] + ) + + XCTAssertEqual(result.selectedProcessID, 42) + XCTAssertEqual(result.retainedProcess, newlySelected) + } + + func testMissingCurrentProcessPreservesTheEndedSelectionForInspection() { + let original = item(pid: 42, birth: 100, cpu: 1) + + let result = ProcessSelectionPolicy.reconcile( + selectedProcessID: 42, + retainedProcess: original, + currentProcesses: [] + ) + + XCTAssertEqual(result.selectedProcessID, 42) + XCTAssertEqual(result.retainedProcess, original) + } + + func testSelectionClearsWhenOnlyOneSnapshotHasVerifiableBirthIdentity() { + let known = item(pid: 42, birth: 100, cpu: 1) + let unknown = item(pid: 42, birth: nil, cpu: 2) + + for (retained, current) in [(known, unknown), (unknown, known)] { + let result = ProcessSelectionPolicy.reconcile( + selectedProcessID: 42, + retainedProcess: retained, + currentProcesses: [current] + ) + XCTAssertNil(result.selectedProcessID) + XCTAssertNil(result.retainedProcess) + } + } + + func testSelectionClearsWhenNeitherSnapshotHasVerifiableBirthIdentity() { + let retained = item(pid: 42, birth: nil, cpu: 1) + let current = item(pid: 42, birth: nil, cpu: 2) + + let result = ProcessSelectionPolicy.reconcile( + selectedProcessID: 42, + retainedProcess: retained, + currentProcesses: [current] + ) + + XCTAssertNil(result.selectedProcessID) + XCTAssertNil(result.retainedProcess) + } + + private func item(pid: Int32, birth: UInt64?, cpu: Double) -> ClassifiedDevProcess { + ClassifiedDevProcess( + process: DevProcess( + pid: pid, + parentPID: 1, + executable: "/bin/sleep", + command: "/bin/sleep 10", + resourceUsage: DevProcessResourceUsage( + cpuPercent: cpu, + residentMemoryBytes: 1, + elapsedTime: "00:10" + ), + birthToken: birth.map { ProcessBirthToken(seconds: $0, microseconds: 1) } + ), + classification: DevProcessClassification( + kind: .other, + displayName: "sleep", + projectHint: nil, + tags: [] + ) + ) + } +} diff --git a/Tests/DevScopeTests/ProcessStoreAutomationTests.swift b/Tests/DevScopeTests/ProcessStoreAutomationTests.swift new file mode 100644 index 0000000..e49afa6 --- /dev/null +++ b/Tests/DevScopeTests/ProcessStoreAutomationTests.swift @@ -0,0 +1,199 @@ +import DevScopeCore +import Foundation +import XCTest +@testable import DevScope + +@MainActor +final class ProcessStoreAutomationTests: XCTestCase { + func testManualRefreshRequestedDuringAutomaticScanRunsAsTrailingFullRefresh() async { + let scanner = SequencedBlockingProcessScanner(result: []) + let store = ProcessStore(scanner: scanner, gpuMetricProvider: NilGPUMetricProvider()) + store.usesAppleNaming = false + + store.refresh() + let firstScanStarted = await scanner.waitUntilCallCount(1) + XCTAssertTrue(firstScanStarted) + scanner.releaseCall(0) + await eventuallyProcessStore { !store.isRefreshing } + + store.refresh(isAutomatic: true) + let automaticScanStarted = await scanner.waitUntilCallCount(2) + XCTAssertTrue(automaticScanStarted) + store.refresh() + scanner.releaseCall(1) + + let trailingManualScanStarted = await scanner.waitUntilCallCount(3) + XCTAssertTrue(trailingManualScanStarted) + scanner.releaseCall(2) + await eventuallyProcessStore { !store.isRefreshing } + + XCTAssertEqual(scanner.includeCurrentDirectoryRequests, [true, false, true]) + } + + func testContextChangedDuringInFlightScanWinsAtSinglePublication() async { + let process = DevProcess( + pid: 8_001, + parentPID: 1, + executable: "/bin/sleep", + command: "/bin/sleep 20000", + argumentVector: ["/bin/sleep", "20000"], + resourceUsage: DevProcessResourceUsage( + cpuPercent: 0, + residentMemoryBytes: 1, + elapsedTime: "05:00:00" + ), + birthToken: ProcessBirthToken(seconds: 500, microseconds: 1), + launchLabel: "com.example.context-race" + ) + let scanner = BlockingProcessScanner(result: [process]) + let store = ProcessStore(scanner: scanner, gpuMetricProvider: NilGPUMetricProvider()) + store.usesAppleNaming = false + + store.refresh() + await scanner.waitUntilStarted() + let inventory = AutomationInventorySnapshot( + generation: 12, + records: [automationRecordForContextRace()], + health: [.launchAgent: AutomationSourceHealth( + kind: .launchAgent, + state: .healthy, + message: nil, + refreshedAt: Date(timeIntervalSince1970: 12) + )], + refreshedAt: Date(timeIntervalSince1970: 12) + ) + store.updateAutomationContext(inventory: inventory, longRunningThreshold: 1) + scanner.release() + await eventuallyProcessStore { !store.isRefreshing } + + XCTAssertEqual(store.liveSnapshot.automationInventory.generation, 12) + XCTAssertEqual( + store.liveSnapshot.automationLinksByProcessID[process.pid]?.recordID, + inventory.records[0].id + ) + XCTAssertEqual(store.liveSnapshot.longRunningProcessIDs, [process.pid]) + XCTAssertEqual(store.liveSnapshot.processes.map(\.pid), [process.pid]) + } +} + +private final class SequencedBlockingProcessScanner: ProcessProviding, @unchecked Sendable { + private let condition = NSCondition() + private let result: [DevProcess] + private var requests: [Bool] = [] + private var releasedCalls: Set = [] + + init(result: [DevProcess]) { + self.result = result + } + + var includeCurrentDirectoryRequests: [Bool] { + condition.withLock { requests } + } + + func snapshot(includeCurrentDirectories: Bool) throws -> [DevProcess] { + condition.lock() + let callIndex = requests.count + requests.append(includeCurrentDirectories) + condition.broadcast() + while !releasedCalls.contains(callIndex) { + condition.wait() + } + condition.unlock() + return result + } + + func waitUntilCallCount( + _ expectedCount: Int, + timeout: Duration = .seconds(1) + ) async -> Bool { + let deadline = ContinuousClock.now + timeout + while ContinuousClock.now < deadline { + if condition.withLock({ requests.count >= expectedCount }) { + return true + } + try? await Task.sleep(for: .milliseconds(1)) + } + return condition.withLock { requests.count >= expectedCount } + } + + func releaseCall(_ callIndex: Int) { + condition.withLock { + releasedCalls.insert(callIndex) + condition.broadcast() + } + } +} + +private final class BlockingProcessScanner: ProcessProviding, @unchecked Sendable { + private let started = DispatchSemaphore(value: 0) + private let gate = DispatchSemaphore(value: 0) + private let result: [DevProcess] + + init(result: [DevProcess]) { + self.result = result + } + + func snapshot(includeCurrentDirectories: Bool) throws -> [DevProcess] { + started.signal() + gate.wait() + return result + } + + func waitUntilStarted() async { + await withCheckedContinuation { continuation in + DispatchQueue.global().async { + self.started.wait() + continuation.resume() + } + } + } + + func release() { + gate.signal() + } +} + +private struct NilGPUMetricProvider: GPUMetricProviding { + func snapshot() throws -> DevGPUMetric? { nil } +} + +@MainActor +private func eventuallyProcessStore( + _ predicate: @MainActor () -> Bool, + file: StaticString = #filePath, + line: UInt = #line +) async { + for _ in 0..<1_000 where !predicate() { + try? await Task.sleep(for: .milliseconds(1)) + } + if !predicate() { + XCTFail("Timed out waiting for asynchronous process-store state.", file: file, line: line) + } +} + +private func automationRecordForContextRace() -> AutomationRecord { + AutomationRecord( + id: AutomationRecord.ID(rawValue: "context-race-record"), + kind: .launchAgent, + sourceKind: .launchAgent, + label: "com.example.context-race", + displayName: "Context Race", + providerBundleIdentifier: nil, + ownerUID: 501, + ownership: .user, + executable: "/bin/sleep", + arguments: ["20000"], + environment: [:], + workingDirectory: nil, + schedule: AutomationSchedule(triggers: [.runAtLoad], summary: "At load"), + sourceURL: URL(fileURLWithPath: "/tmp/com.example.context-race.plist"), + sourceChecksum: "context-race", + enabledState: .enabled, + loadState: .loaded, + approvalState: .notApplicable, + state: .running, + evidence: [], + capabilities: [.exportRecord], + validationFindings: [] + ) +} diff --git a/Tests/DevScopeTests/SystemAutomationCommandRunnerTests.swift b/Tests/DevScopeTests/SystemAutomationCommandRunnerTests.swift new file mode 100644 index 0000000..3d0351c --- /dev/null +++ b/Tests/DevScopeTests/SystemAutomationCommandRunnerTests.swift @@ -0,0 +1,331 @@ +import Darwin +import Foundation +import XCTest +@testable import DevScope +import DevScopeCore + +final class SystemAutomationCommandRunnerTests: XCTestCase { + func testOrdinaryCommandCapturesBothStreamsEnvironmentAndExitStatus() async throws { + let result = try await SystemAutomationCommandRunner().run(AutomationCommand( + executable: "/bin/sh", + arguments: [ + "-c", + "printf '%s' \"$DEVSCOPE_RUNNER_TEST\"; printf 'stderr-value' >&2; exit 7", + ], + environment: ["DEVSCOPE_RUNNER_TEST": "stdout-value"] + )) + + XCTAssertEqual(result.status, 7) + XCTAssertEqual(String(data: result.standardOutput, encoding: .utf8), "stdout-value") + XCTAssertEqual(String(data: result.standardError, encoding: .utf8), "stderr-value") + } + + func testCaptureLimitDrainsAllOutputThenRejectsPartialResult() async throws { + let processGroup = LockedValue(nil) + let runner = SystemAutomationCommandRunner( + hooks: .init(afterSpawnBeforeInstall: { processGroup.set($0) }), + maximumCapturedBytes: 64 + ) + let fallback = cleanupFallback(processGroup) + defer { + fallback.cancel() + killGroupIfPresent(processGroup.value) + } + let started = ContinuousClock.now + + do { + _ = try await runner.run(AutomationCommand( + executable: "/bin/sh", + arguments: [ + "-c", + "/usr/bin/yes 1234567890 | /usr/bin/head -c 200000", + ] + )) + XCTFail("Expected outputLimitExceeded") + } catch SystemAutomationCommandError.outputLimitExceeded { + // Expected only after the output pipe reaches EOF. + } catch { + XCTFail("Expected outputLimitExceeded, received \(error)") + } + + XCTAssertLessThan(ContinuousClock.now - started, .seconds(4)) + await assertProcessGone(processGroup.value) + } + + func testExecutionTimeoutTerminatesTheSpawnedProcessGroupAndFailsClosed() async throws { + let processGroup = LockedValue(nil) + let runner = SystemAutomationCommandRunner( + hooks: .init(afterSpawnBeforeInstall: { processGroup.set($0) }), + executionTimeout: .milliseconds(50) + ) + let fallback = cleanupFallback(processGroup) + defer { + fallback.cancel() + killGroupIfPresent(processGroup.value) + } + let started = ContinuousClock.now + + do { + _ = try await runner.run(AutomationCommand( + executable: "/bin/sleep", + arguments: ["30"] + )) + XCTFail("Expected executionTimedOut") + } catch SystemAutomationCommandError.executionTimedOut { + // Expected after the runner terminates and reaps the process group. + } catch { + XCTFail("Expected executionTimedOut, received \(error)") + } + + XCTAssertLessThan(ContinuousClock.now - started, .seconds(4)) + await assertProcessGone(processGroup.value) + } + + func testAlreadyCancelledTaskDoesNotSpawnAChild() async throws { + let gate = DispatchSemaphore(value: 0) + let spawnCount = LockedValue(0) + let runner = SystemAutomationCommandRunner(hooks: .init( + afterSpawnBeforeInstall: { _ in spawnCount.mutate { $0 += 1 } } + )) + let task = Task { + _ = waitSynchronously(gate, timeout: .now() + 5) + return try await runner.run(AutomationCommand(executable: "/bin/true", arguments: [])) + } + + task.cancel() + gate.signal() + await assertCancellation(from: task) + XCTAssertEqual(spawnCount.value, 0) + } + + func testCancellationRequestedBeforePIDInstallationStillTerminatesSpawnedGroup() async throws { + let spawned = DispatchSemaphore(value: 0) + let allowInstallation = DispatchSemaphore(value: 0) + let processGroup = LockedValue(nil) + let runner = SystemAutomationCommandRunner(hooks: .init( + afterSpawnBeforeInstall: { pid in + processGroup.set(pid) + spawned.signal() + _ = allowInstallation.wait(timeout: .now() + 5) + } + )) + let task = Task { + try await runner.run(AutomationCommand(executable: "/bin/sleep", arguments: ["30"])) + } + + XCTAssertEqual(spawned.wait(timeout: .now() + 5), .success) + let fallback = cleanupFallback(processGroup) + defer { + fallback.cancel() + killGroupIfPresent(processGroup.value) + } + task.cancel() + allowInstallation.signal() + + let started = ContinuousClock.now + await assertCancellation(from: task) + XCTAssertLessThan(ContinuousClock.now - started, .seconds(4)) + await assertProcessGone(processGroup.value) + } + + func testCancellationEscalatesFromTERMToKILLForTermIgnoringProcessGroup() async throws { + let installed = DispatchSemaphore(value: 0) + let processGroup = LockedValue(nil) + let runner = SystemAutomationCommandRunner(hooks: .init( + afterInstall: { pid in + processGroup.set(pid) + installed.signal() + } + )) + let task = Task { + try await runner.run(AutomationCommand( + executable: "/bin/sh", + arguments: ["-c", "trap '' TERM; while :; do sleep 1; done"] + )) + } + + XCTAssertEqual(installed.wait(timeout: .now() + 5), .success) + let fallback = cleanupFallback(processGroup) + defer { + fallback.cancel() + killGroupIfPresent(processGroup.value) + } + let started = ContinuousClock.now + task.cancel() + await assertCancellation(from: task) + let elapsed = ContinuousClock.now - started + XCTAssertGreaterThanOrEqual(elapsed, .seconds(1.8)) + XCTAssertLessThan(elapsed, .seconds(4)) + await assertProcessGone(processGroup.value) + } + + func testCancellationKillsPipeHoldingDescendantAfterLeaderExits() async throws { + let leaderExited = DispatchSemaphore(value: 0) + let processGroup = LockedValue(nil) + let pidFile = FileManager.default.temporaryDirectory + .appendingPathComponent("devscope-runner-child-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: pidFile) } + let runner = SystemAutomationCommandRunner(hooks: .init( + afterSpawnBeforeInstall: { processGroup.set($0) }, + afterLeaderExitObserved: { _ in leaderExited.signal() } + )) + let task = Task { + try await runner.run(AutomationCommand( + executable: "/bin/sh", + arguments: [ + "-c", + "(trap '' TERM; exec /bin/sleep 30) & child=$!; echo $child > \(pidFile.path); exit 0", + ] + )) + } + + let didObserveLeaderExit = await waitForSignal(leaderExited, timeout: .seconds(5)) + XCTAssertTrue(didObserveLeaderExit) + let descendantPID = try XCTUnwrap(readPID(from: pidFile)) + let fallback = cleanupFallback(processGroup) + defer { + fallback.cancel() + killGroupIfPresent(processGroup.value) + _ = Darwin.kill(descendantPID, SIGKILL) + } + let started = ContinuousClock.now + task.cancel() + await assertCancellation(from: task) + XCTAssertLessThan(ContinuousClock.now - started, .seconds(4)) + await assertProcessGone(descendantPID) + } + + func testExecutionTimeoutKillsPipeHoldingDescendantAfterLeaderExits() async throws { + let processGroup = LockedValue(nil) + let descendantPID = LockedValue(nil) + let pidFile = FileManager.default.temporaryDirectory + .appendingPathComponent("devscope-runner-timeout-child-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: pidFile) } + let runner = SystemAutomationCommandRunner( + hooks: .init(afterSpawnBeforeInstall: { pid in + processGroup.set(pid) + descendantPID.set(waitForPID(from: pidFile, deadline: .now() + .seconds(2))) + }), + executionTimeout: .milliseconds(50) + ) + let fallback = cleanupFallback(processGroup) + defer { + fallback.cancel() + killGroupIfPresent(processGroup.value) + if let descendantPID = descendantPID.value { + _ = Darwin.kill(descendantPID, SIGKILL) + } + } + let started = ContinuousClock.now + + do { + _ = try await runner.run(AutomationCommand( + executable: "/bin/sh", + arguments: [ + "-c", + "(trap '' TERM; exec /bin/sleep 30) & child=$!; echo $child > \(pidFile.path); exit 0", + ] + )) + XCTFail("Expected executionTimedOut") + } catch SystemAutomationCommandError.executionTimedOut { + // Expected after the deadline terminates the pipe-holding descendant. + } catch { + XCTFail("Expected executionTimedOut, received \(error)") + } + + XCTAssertLessThan(ContinuousClock.now - started, .seconds(4)) + await assertProcessGone(try XCTUnwrap(descendantPID.value)) + } + + private func assertCancellation(from task: Task) async { + do { + _ = try await task.value + XCTFail("Expected CancellationError") + } catch is CancellationError { + // Expected. + } catch { + XCTFail("Expected CancellationError, received \(error)") + } + } + + private func cleanupFallback(_ processGroup: LockedValue) -> Task { + Task.detached { + try? await Task.sleep(for: .seconds(5)) + guard !Task.isCancelled else { return } + killGroupIfPresent(processGroup.value) + } + } + + private func assertProcessGone(_ pid: pid_t?) async { + guard let pid else { + XCTFail("Missing spawned PID") + return + } + for _ in 0..<100 { + errno = 0 + if Darwin.kill(pid, 0) == -1, errno == ESRCH { return } + try? await Task.sleep(for: .milliseconds(20)) + } + XCTFail("Process \(pid) was not reaped") + } + +} + +private final class LockedValue: @unchecked Sendable { + private let lock = NSLock() + private var storage: Value + + init(_ value: Value) { + storage = value + } + + var value: Value { lock.withLock { storage } } + + func set(_ value: Value) { + lock.withLock { storage = value } + } + + func mutate(_ body: (inout Value) -> Void) { + lock.withLock { body(&storage) } + } +} + +private func killGroupIfPresent(_ processGroup: pid_t?) { + guard let processGroup, processGroup > 0 else { return } + _ = Darwin.kill(-processGroup, SIGKILL) +} + +private func waitSynchronously( + _ semaphore: DispatchSemaphore, + timeout: DispatchTime +) -> DispatchTimeoutResult { + semaphore.wait(timeout: timeout) +} + +private func waitForSignal( + _ semaphore: DispatchSemaphore, + timeout: Duration +) async -> Bool { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while clock.now < deadline { + if waitSynchronously(semaphore, timeout: .now()) == .success { return true } + try? await Task.sleep(for: .milliseconds(10)) + } + return waitSynchronously(semaphore, timeout: .now()) == .success +} + +private func waitForPID(from url: URL, deadline: DispatchTime) -> pid_t? { + repeat { + if let pid = readPID(from: url) { return pid } + Darwin.usleep(1_000) + } while DispatchTime.now() < deadline + return readPID(from: url) +} + +private func readPID(from url: URL) -> pid_t? { + guard let text = try? String(contentsOf: url, encoding: .utf8), + let pid = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines)) + else { return nil } + return pid +} diff --git a/Tests/DevScopeTests/TableSortStateTests.swift b/Tests/DevScopeTests/TableSortStateTests.swift new file mode 100644 index 0000000..81ccf43 --- /dev/null +++ b/Tests/DevScopeTests/TableSortStateTests.swift @@ -0,0 +1,301 @@ +import DevScopeCore +import XCTest +@testable import DevScope + +final class TableSortStateTests: XCTestCase { + func testAutomationRunsStartsDescendingAndNameStartsAscending() { + var state = AutomationTableSortState() + state.activate(.runs) + XCTAssertEqual(state.direction, .descending) + state.activate(.automation) + XCTAssertEqual(state.direction, .ascending) + } + + func testAutomationStateSortUsesSemanticRank() { + let rows = [ + makeAutomationRow(id: "unresolved", state: .unresolved), + makeAutomationRow(id: "disabled", state: .disabled), + makeAutomationRow(id: "idle", state: .idle), + makeAutomationRow(id: "invalid", state: .invalid), + makeAutomationRow(id: "running", state: .running), + makeAutomationRow(id: "needs-approval", state: .needsApproval) + ] + + XCTAssertEqual( + AutomationTableSortState(column: .state, direction: .ascending) + .sorted(rows).map(\.record.state), + [.running, .idle, .needsApproval, .disabled, .invalid, .unresolved] + ) + } + + func testAutomationRunsSortsDescendingByLinkedProcessCount() { + let rows = [ + makeAutomationRow(id: "none", linkedProcessCount: 0), + makeAutomationRow(id: "many", linkedProcessCount: 5), + makeAutomationRow(id: "some", linkedProcessCount: 2) + ] + + XCTAssertEqual( + AutomationTableSortState(column: .runs, direction: .descending) + .sorted(rows).map(\.linkedProcessCount), + [5, 2, 0] + ) + } + + func testAutomationTextColumnsSortByPresentedText() { + let rows = [ + makeAutomationRow(id: "two", displayName: "Zulu", trigger: "At login"), + makeAutomationRow(id: "one", displayName: "Alpha", trigger: "Weekly") + ] + + XCTAssertEqual( + AutomationTableSortState(column: .automation, direction: .ascending) + .sorted(rows).map(\.id.rawValue), + ["one", "two"] + ) + XCTAssertEqual( + AutomationTableSortState(column: .trigger, direction: .ascending) + .sorted(rows).map(\.id.rawValue), + ["two", "one"] + ) + } + + func testAutomationKindAndOwnerSortUseSemanticRanks() { + let kindRows = [ + makeAutomationRow(id: "a", kind: .cron), + makeAutomationRow(id: "b", kind: .backgroundItem), + makeAutomationRow(id: "c", kind: .loginItem), + makeAutomationRow(id: "d", kind: .launchDaemon), + makeAutomationRow(id: "e", kind: .launchAgent) + ] + let ownerRows = [ + makeAutomationRow(id: "a", ownership: .appleSystem), + makeAutomationRow(id: "b", ownership: .managed), + makeAutomationRow(id: "c", ownership: .thirdPartySystem), + makeAutomationRow(id: "d", ownership: .user) + ] + + XCTAssertEqual( + AutomationTableSortState(column: .kind, direction: .ascending) + .sorted(kindRows).map(\.record.kind), + [.launchAgent, .launchDaemon, .loginItem, .backgroundItem, .cron] + ) + XCTAssertEqual( + AutomationTableSortState(column: .owner, direction: .ascending) + .sorted(ownerRows).map(\.record.ownership), + [.user, .thirdPartySystem, .managed, .appleSystem] + ) + } + + func testAutomationSortPreservesIdentityAndUsesRecordIDForTies() { + let rows = [ + makeAutomationRow(id: "c", linkedProcessCount: 1), + makeAutomationRow(id: "a", linkedProcessCount: 1), + makeAutomationRow(id: "b", linkedProcessCount: 1) + ] + let sorted = AutomationTableSortState(column: .runs, direction: .descending).sorted(rows) + let selectedID = AutomationRecord.ID(rawValue: "b") + + XCTAssertEqual(AutomationTableSortState().sorted(rows).map(\.id.rawValue), ["c", "a", "b"]) + XCTAssertEqual(sorted.map(\.id.rawValue), ["a", "b", "c"]) + XCTAssertEqual(Set(sorted.map(\.id)), Set(rows.map(\.id))) + XCTAssertEqual( + AutomationPresentation.resolvedSelection(current: selectedID, visibleIDs: sorted.map(\.id)), + selectedID + ) + } + + func testProcessSortStartsInSnapshotOrderWithoutAnActiveHeader() { + let state = ProcessTableSortState() + XCTAssertNil(state.column) + XCTAssertEqual(state.direction, .ascending) + } + + func testDefaultComparatorsPreserveSnapshotOrder() { + let rows = [ + processRow(pid: 30, snapshotOrder: 2), + processRow(pid: 10, snapshotOrder: 0), + processRow(pid: 20, snapshotOrder: 1) + ] + + XCTAssertEqual(sortedRows(rows, using: ProcessTableSortState()).map(\.pid), [10, 20, 30]) + } + + func testFirstActivationSortsEveryProcessColumnInItsSemanticDefaultDirection() { + let rows = processSortingRows() + let expectations: [(ProcessTableSortColumn, [Int])] = [ + (.process, [0, 1, 2]), + (.pid, [0, 2, 1]), + (.cpu, [1, 0, 2]), + (.memory, [1, 2, 0]), + (.time, [2, 0, 1]), + (.command, [2, 1, 0]) + ] + + for (column, expectedSnapshotOrder) in expectations { + var state = ProcessTableSortState() + state.activate(column) + + XCTAssertEqual( + sortedRows(rows, using: state).map(\.snapshotOrder), + expectedSnapshotOrder, + "Unexpected first-activation order for \(column)" + ) + } + } + + func testRepeatActivationReversesActualRowOrder() { + let rows = processSortingRows() + var state = ProcessTableSortState() + state.activate(.memory) + let firstOrder = sortedRows(rows, using: state).map(\.snapshotOrder) + + state.activate(.memory) + + XCTAssertEqual( + sortedRows(rows, using: state).map(\.snapshotOrder), + Array(firstOrder.reversed()) + ) + } + + func testEqualPrimaryValuesUseDeterministicStableTieOrder() { + let rows = [ + processRow(pid: 30, memory: 100, snapshotOrder: 2), + processRow(pid: 10, memory: 100, snapshotOrder: 0), + processRow(pid: 20, memory: 100, snapshotOrder: 1) + ] + var state = ProcessTableSortState() + state.activate(.memory) + + XCTAssertEqual(sortedRows(rows, using: state).map(\.pid), [10, 20, 30]) + } + + func testProcessMetricSortStartsDescendingAndReverses() { + var state = ProcessTableSortState() + state.activate(.cpu) + XCTAssertEqual(state.column, .cpu) + XCTAssertEqual(state.direction, .descending) + state.activate(.cpu) + XCTAssertEqual(state.direction, .ascending) + } + + func testProcessTextSortStartsAscendingWhenChangingColumns() { + var state = ProcessTableSortState(column: .memory, direction: .ascending) + state.activate(.process) + XCTAssertEqual(state.column, .process) + XCTAssertEqual(state.direction, .ascending) + } +} + +private func makeAutomationRow( + id: String, + displayName: String? = nil, + kind: AutomationKind = .launchAgent, + state: AutomationState = .idle, + ownership: AutomationOwnership = .user, + trigger: String = "On demand", + linkedProcessCount: Int = 0 +) -> AutomationTableRow { + let record = AutomationRecord( + id: .init(rawValue: id), + kind: kind, + sourceKind: kind == .cron ? .crontab : .launchAgent, + label: id, + displayName: displayName ?? id, + providerBundleIdentifier: nil, + ownerUID: 501, + ownership: ownership, + executable: "/bin/true", + arguments: [], + environment: [:], + workingDirectory: nil, + schedule: .init(triggers: [.demand], summary: trigger), + sourceURL: URL(fileURLWithPath: "/tmp/\(id).plist"), + sourceChecksum: nil, + enabledState: .enabled, + loadState: .unknown, + approvalState: .notApplicable, + state: state, + evidence: [], + capabilities: [], + validationFindings: [] + ) + return AutomationTableRow(record: record, linkedProcessCount: linkedProcessCount) +} + +private func processSortingRows() -> [ProcessTableRow] { + [ + processRow( + pid: 10, + title: "Alpha", + cpu: 20, + memory: 100, + elapsedTime: "00:00:20", + command: "charlie", + snapshotOrder: 0 + ), + processRow( + pid: 30, + title: "Beta", + cpu: 30, + memory: 300, + elapsedTime: "00:00:10", + command: "bravo", + snapshotOrder: 1 + ), + processRow( + pid: 20, + title: "Charlie", + cpu: 10, + memory: 200, + elapsedTime: "00:00:30", + command: "alpha", + snapshotOrder: 2 + ) + ] +} + +private func processRow( + pid: Int32, + title: String = "Process", + cpu: Double = 0, + memory: Int64 = 0, + elapsedTime: String = "00:00:00", + command: String = "command", + snapshotOrder: Int +) -> ProcessTableRow { + ProcessTableRow( + item: ClassifiedDevProcess( + process: DevProcess( + pid: pid, + parentPID: 1, + executable: title, + command: command, + resourceUsage: DevProcessResourceUsage( + cpuPercent: cpu, + residentMemoryBytes: memory, + elapsedTime: elapsedTime + ) + ), + classification: DevProcessClassification( + kind: .javascript, + displayName: title, + projectHint: nil + ) + ), + title: title, + isFavorite: false, + isWatched: false, + automationBadges: [], + snapshotOrder: snapshotOrder + ) +} + +private func sortedRows( + _ rows: [ProcessTableRow], + using state: ProcessTableSortState +) -> [ProcessTableRow] { + rows.sorted(using: state.comparators + [ + KeyPathComparator(\ProcessTableRow.stableOrderKey, order: .forward) + ]) +} diff --git a/Tests/DevScopeTests/WorkflowIntelligenceEnhancerTests.swift b/Tests/DevScopeTests/WorkflowIntelligenceEnhancerTests.swift new file mode 100644 index 0000000..87d25cd --- /dev/null +++ b/Tests/DevScopeTests/WorkflowIntelligenceEnhancerTests.swift @@ -0,0 +1,42 @@ +import DevScopeCore +import XCTest +@testable import DevScope + +final class WorkflowIntelligenceEnhancerTests: XCTestCase { + func testCacheKeyChangesWhenWorkflowFactsChangeUnderTheSameStableID() { + let baseline = workflow(cpu: 10, memory: 128 * 1_024 * 1_024, processIDs: [41]) + let changedUsage = workflow(cpu: 85, memory: 5 * 1_024 * 1_024 * 1_024, processIDs: [41]) + let changedComposition = workflow(cpu: 10, memory: 128 * 1_024 * 1_024, processIDs: [41, 42]) + + XCTAssertNotEqual( + WorkflowNoteCacheKey(workflow: baseline, items: []), + WorkflowNoteCacheKey(workflow: changedUsage, items: []) + ) + XCTAssertNotEqual( + WorkflowNoteCacheKey(workflow: baseline, items: []), + WorkflowNoteCacheKey(workflow: changedComposition, items: []) + ) + XCTAssertEqual( + WorkflowNoteCacheKey(workflow: baseline, items: []), + WorkflowNoteCacheKey(workflow: baseline, items: []) + ) + } + + private func workflow(cpu: Double, memory: Int64, processIDs: [Int32]) -> DevWorkflow { + DevWorkflow( + id: "workflow:stable", + title: "Stable Workflow", + subtitle: "facts", + kind: .buildWorkspace, + processIDs: processIDs, + primaryProject: "DevScope", + tags: [], + totalCPU: cpu, + totalMemoryBytes: memory, + risk: cpu >= 80 ? .heavy : .normal, + confidence: 0.9, + summary: "Current workflow facts.", + suggestedAction: "Inspect it." + ) + } +} diff --git a/config/DevScope.entitlements b/config/DevScope.entitlements new file mode 100644 index 0000000..48da0c8 --- /dev/null +++ b/config/DevScope.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.files.user-selected.read-write + + + diff --git a/config/DevScopeDeveloperID.entitlements b/config/DevScopeDeveloperID.entitlements new file mode 100644 index 0000000..8aa5d5d --- /dev/null +++ b/config/DevScopeDeveloperID.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.automation.apple-events + + + diff --git a/docs/APP_STORE_READINESS.md b/docs/APP_STORE_READINESS.md new file mode 100644 index 0000000..96f24f5 --- /dev/null +++ b/docs/APP_STORE_READINESS.md @@ -0,0 +1,144 @@ +# DevScope Mac App Store Readiness + +DevScope now has a reproducible Mac App Store preparation path, but the current product scope is **not ready for Mac App Store submission** because App Sandbox blocks the core process scanner in local validation. + +Local macOS 27 validation compared the full-access and sandbox policies. The +full-access candidate populated process and automation inventory, while the +sandbox candidate showed no running processes and an explicit blocked state. +This confirms the distribution conflict on the target OS and SDK rather than +resolving it. + +Current evidence: + +- The sandbox-signed release bundle builds, signs, validates, and launches. +- The first sandboxed scanner path using `/bin/ps` is blocked with an operation-not-permitted error. +- A native `libproc` fallback was added and validated by unit tests outside the sandbox. +- Inside the sandboxed `.app`, the native fallback still returns no visible process records. +- The sandboxed app therefore reports `0 tracked processes`. + +The final upload also depends on Apple account assets that must exist on the release Mac: + +- a Mac App Store application signing identity (installed on the audited Mac) +- a Mac App Store installer signing identity (installed on the audited Mac) +- a provisioning profile for the final bundle identifier +- an App Store Connect app record using the same bundle identifier + +The audited Mac has no provisioning profile matching `com.s1korrrr.DevScope`; the App Store Connect record has not been verified. + +The current recommended bundle identifier is: + +```bash +com.s1korrrr.DevScope +``` + +## What Is Prepared + +- App Sandbox entitlement: `config/DevScope.entitlements` +- Privacy manifest: `Resources/PrivacyInfo.xcprivacy` +- App icon generation: `script/generate_app_icon.swift` +- Release bundle builder: `script/build_release_bundle.sh` +- Release validator: `script/validate_release_bundle.sh` +- Sandboxed local launch smoke: `script/sandbox_smoke.sh` +- Mac App Store package builder: `script/package_app_store.sh` + +The release bundle contains: + +- `Contents/Info.plist` +- `Contents/MacOS/DevScope` +- `Contents/Resources/AppIcon.icns` +- `Contents/Resources/PrivacyInfo.xcprivacy` +- App Sandbox entitlements in the code signature + +## Local Sandbox Validation + +Run this before attempting App Store signing: + +```bash +./script/sandbox_smoke.sh +``` + +Expected result: + +```text +Validated .../dist/release/DevScope.app +Sandbox: enabled +Sandbox smoke passed: .../dist/release/DevScope.app +``` + +This proves the app can be built, signed with sandbox entitlements, validated, and launched locally. It does **not** prove App Store readiness. The current sandbox smoke shows that both shell-based process scanning and native fallback process scanning are blocked or empty, which is a release blocker for the full DevScope feature set. + +## App Store Package Build + +After creating/downloading the App Store signing assets, this command builds the package. Do not upload it until the sandbox compatibility blocker is resolved. + +```bash +DEVSCOPE_BUNDLE_ID="com.s1korrrr.DevScope" \ +DEVSCOPE_MARKETING_VERSION="0.1.0" \ +DEVSCOPE_BUILD_VERSION="1" \ +DEVSCOPE_APP_STORE_SIGN_IDENTITY="Apple Distribution: YOUR NAME (TEAMID)" \ +DEVSCOPE_INSTALLER_IDENTITY="3rd Party Mac Developer Installer: YOUR NAME (TEAMID)" \ +DEVSCOPE_PROVISIONING_PROFILE="/path/to/DevScope.provisionprofile" \ +./script/package_app_store.sh +``` + +If your Apple account uses a differently named modern signing identity, pass that exact identity string. You can inspect installed signing identities with: + +```bash +security find-identity -p codesigning -v +``` + +The package script creates: + +```text +dist/app-store/DevScope.pkg +``` + +Upload the package with Apple Transporter or App Store Connect tooling, then complete App Review metadata. + +## App Review Notes + +Use plain language in App Review notes: + +- DevScope is a local developer utility for inspecting user-owned development processes. +- It scans local process metadata with system tools and does not require a server. +- It shows CPU, memory, elapsed runtime, command context, process tree context, and local workflow grouping. +- Termination actions require explicit user interaction and use normal user-owned process signaling. +- The app does not collect analytics, does not track users, and does not upload process data. +- Apple naming support is optional and deterministic scanner facts remain authoritative. + +## Required Manual Smoke Before Upload + +Run these against the sandbox-signed `dist/release/DevScope.app`: + +- Live process table populates within a few seconds. +- Search filters process names, commands, PIDs, folders, and runtimes. +- Runtime Tree shows only detected runtime groups. +- Focus groups AI/ML, training, workspace, web, and local project groups correctly. +- CPU, memory, Apple GPU, and machine load graph update smoothly. +- Copy/export/folder actions work. +- `TERM` works against a disposable test process owned by the current user. +- `TERM Tree` works against a disposable parent/child process tree. +- Force kill remains behind the destructive menu. +- Settings and Support open correctly. + +## If Sandbox Blocks Core Features + +The current sandbox build blocks process scanning, even with the native fallback, so there are three honest release shapes: + +1. Ship the full DevScope product outside the Mac App Store using Developer ID signing and notarization. +2. Build a reduced Mac App Store variant that does not promise process inspection/termination, or obtain an Apple-approved entitlement/path that restores the core behavior. +3. Ship both editions with distinct capability copy and separate runtime validation. + +Do not submit a degraded build that misrepresents DevScope's process-control feature set. + +## Full-Feature Public Release Fallback + +For the full DevScope feature set, use Developer ID distribution until the App Store sandbox issue is resolved: + +```bash +DEVSCOPE_DEVELOPER_ID_SIGN_IDENTITY="Developer ID Application: YOUR NAME (TEAMID)" \ +DEVSCOPE_NOTARY_KEYCHAIN_PROFILE="devscope-notary" \ +./script/package_developer_id.sh +``` + +The Developer ID path signs with hardened runtime, uses non-sandboxed entitlements, creates a zip, submits it to Apple's notary service when a notary keychain profile is configured, staples the ticket, and verifies Gatekeeper acceptance. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..cb8e4ab --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,38 @@ +# Architecture + +DevScope is a native Swift package with two primary modules: + +- `DevScopeCore` contains deterministic models, process and automation policy, + scanners, correlation, presentation state, command boundaries, and storage + abstractions. It is designed for focused unit and integration testing. +- `DevScope` is the macOS executable. It composes AppKit/SwiftUI views, system + adapters, application lifecycle, notifications, local file access, and + optional Foundation Models presentation enrichment. + +## Data and action flow + +1. System adapters gather bounded process and supported automation facts. +2. Core services normalize those facts into explicit snapshots and inventories. +3. Presentation policy filters and groups deterministic records for the UI. +4. Optional on-device model output may improve labels or notes but cannot replace + source facts or authorize actions. +5. User actions pass through scope and capability policy, explicit confirmation + where required, system execution, and post-action result verification. + +## Trust boundaries + +- Operating-system command output, file content, process identifiers, and model + text are untrusted boundary inputs and must be parsed or validated. +- Destructive process and automation operations remain user initiated. +- The App Sandbox build is a compatibility-validation surface with restricted + visibility; it is not equivalent to the full direct-download product. +- Local support data is bounded and remains on the Mac unless the user exports or + shares it. + +## Distribution + +SwiftPM builds are development artifacts. A production direct-download archive +must be universal where claimed, signed with Developer ID, hardened, notarized, +stapled, validated, and smoke-tested from the exact distributed bundle. App Store +distribution is a separate product because sandbox constraints currently block +the full process-inspection promise. diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md new file mode 100644 index 0000000..b59e100 --- /dev/null +++ b/docs/INSTALLATION.md @@ -0,0 +1,100 @@ +# Installation and Maintenance + +DevScope ships as source and as an optional community prerelease. The community +archive is ad-hoc signed and not notarized by Apple. There is no notarized public +binary and no in-app updater yet. + +## Requirements + +- macOS 14 or newer +- A current stable Xcode toolchain with Swift 6 support +- Git and a POSIX-compatible shell + +## Run from source + +From the repository root: + +```bash +./script/build_and_run.sh --verify +``` + +This builds and launches `dist/DevScope.app` as a local development artifact. + +## Download the community prerelease + +Download both the macOS universal zip and its `.sha256` file from +[GitHub Releases](https://github.com/rsitech-ai/devscope/releases), keep them in +the same directory, and verify the archive: + +```bash +shasum -a 256 -c DevScope-*.zip.sha256 +``` + +The archive contains `DevScope.app` and +`UNNOTARIZED_COMMUNITY_PREVIEW.txt`. Read that warning before opening the app. +The preview is ad-hoc signed and not notarized, so Gatekeeper may reject it. Do +not disable Gatekeeper or other macOS security protections. Build from source if +the preview is blocked, or wait for a Developer ID signed and notarized release. + +## Install the full local app + +```bash +./script/install_local_full_build.sh +``` + +The installer builds the non-sandbox process-control variant, validates its +bundle policy, places it at `/Applications/DevScope.app`, and launches it. This +is a local build, not Developer ID/notarization proof. + +## Update + +DevScope has no automatic updater. Review and fetch the desired source revision, +rerun the readiness/tests appropriate to that revision, then rerun: + +```bash +./script/install_local_full_build.sh +``` + +A future signed release must publish its own exact update and checksum guidance. + +## Uninstall + +Quit DevScope and move `/Applications/DevScope.app` to Trash in Finder. The app +does not install a privileged helper or background updater. + +DevScope keeps preferences and bounded recovery data under the current user's +Library, including `~/Library/Application Support/DevScope`. Remove that folder +only if you also want to delete local recovery/backups; inspect it first because +that cleanup is permanent once Trash is emptied. + +## Troubleshooting + +### No processes or working directories appear + +Do not use `script/sandbox_smoke.sh` as the daily install. App Sandbox blocks the +full machine-wide scanner. Install the full local build, then open **Settings > +Access** for DevScope's exact Ready, Needed, or Blocked explanation. DevScope +cannot grant itself Full Disk Access or bypass App Sandbox. + +### The app does not launch from a source build + +Confirm `xcodebuild -version` and `swift --version` satisfy the requirements, +then run `./script/build_and_run.sh --verify` and use its first build or bundle +validation error. Do not bypass Gatekeeper for an untrusted downloaded binary. + +### Restore last copy is unavailable + +Copy a process command or visible-row export from DevScope first. Recovery stores +a bounded redacted payload; it deliberately does not preserve unredacted secrets. + +### Repositories are grouped under an owner-folder name + +For layouts such as `~/dev/acme-labs/project`, launch DevScope with +`DEVSCOPE_WORKSPACE_OWNER_COMPONENTS=acme-labs` (comma-separate multiple names). +DevScope will then select the repository below that configured owner folder. +Without explicit structure, it conservatively treats the first component after +the development root as the project so nested working directories remain stable. + +For bugs, support, privacy, and security-reporting routes, see +[SUPPORT.md](../SUPPORT.md), [PRIVACY.md](../PRIVACY.md), and +[SECURITY.md](../SECURITY.md). diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md new file mode 100644 index 0000000..b7fe682 --- /dev/null +++ b/docs/RELEASE_CHECKLIST.md @@ -0,0 +1,71 @@ +# DevScope Release Checklist + +DevScope is a local process monitor and process-control utility. Treat release work as a security and distribution exercise, not only a build step. + +## Required Before GitHub Release + +- [ ] Run `swift test`. +- [ ] Run `swift build`. +- [ ] Run `swift build -c release`. +- [ ] Run `./script/build_and_run.sh --verify`. +- [ ] Run `./script/sandbox_smoke.sh`. +- [ ] Build and validate an ad-hoc full-access bundle with `config/DevScopeDeveloperID.entitlements`. +- [ ] Run a redacted full-history Gitleaks scan and inspect reachable historical images and author metadata. +- [ ] Smoke test live process refresh, search, category filters, sorting, Settings, Sponsor, graph display, copy/open-folder actions, and kill confirmation dialogs. +- [ ] Sign the app with a Developer ID Application certificate. +- [ ] Enable hardened runtime for outside-App-Store distribution. +- [ ] Notarize the signed app and staple the notarization ticket. +- [ ] For full-feature public distribution, run `./script/package_developer_id.sh` with Developer ID signing and notary credentials. +- [ ] Publish exact install, update, privacy, and uninstall instructions. +- [ ] Follow the exact sequence in `docs/RELEASING.md` and record the final commit and artifact digests outside that self-referential commit. + +## Required Before Mac App Store Submission + +- [x] Decide whether Mac App Store is compatible enough to test. DevScope now has a sandboxed package path, but process inspection and signaling must be manually verified under App Sandbox before upload. +- [x] Add App Sandbox entitlements. +- [x] Add packaged privacy manifest. +- [x] Add generated app icon to the release bundle. +- [x] Add release bundle validation script. +- [x] Add App Store package script. +- [x] Test sandbox-signed launch. +- [x] Compile and run the complete test inventory with the current release toolchain; preserve documented stable-toolchain compatibility. +- [x] Compare full-access and App-Sandbox behavior on macOS 27. Full access: 921 processes. Sandbox: 0 processes with explicit blocked state. +- [ ] Resolve App Sandbox scanner blocker. Current sandbox evidence: app launches but `/bin/ps` is blocked and the native `libproc` fallback returns no visible process records, so the app shows zero tracked processes. +- [ ] Create the App Store Connect app record for the final bundle identifier. +- [x] Install Mac App Store application and installer signing identities. +- [ ] Download or create a provisioning profile for the final bundle identifier. +- [ ] Build an App Store package with `./script/package_app_store.sh`. +- [ ] Verify which process-scanning and termination features still work in the sandboxed release app. +- [ ] If sandboxing breaks core functionality, ship via Developer ID/notarization instead of weakening user expectations in the App Store build. +- [ ] Provide App Review notes explaining process visibility, kill behavior, local-only data handling, and why requested permissions are needed. + +## Privacy and Safety Copy + +- DevScope scans local process metadata using system tools. +- DevScope does not require network access for process scanning. +- Apple naming, when available, runs through Apple platform APIs and falls back to deterministic local labels when unavailable or unsafe. +- Termination actions require explicit user interaction and use normal user-owned process signaling. + +## Useful Release Commands + +```bash +./script/sandbox_smoke.sh +./script/validate_release_bundle.sh dist/release/DevScope.app +security find-identity -p codesigning -v +``` + +```bash +DEVSCOPE_DEVELOPER_ID_SIGN_IDENTITY="Developer ID Application: YOUR NAME (TEAMID)" \ +DEVSCOPE_NOTARY_KEYCHAIN_PROFILE="devscope-notary" \ +./script/package_developer_id.sh +``` + +```bash +DEVSCOPE_BUNDLE_ID="com.s1korrrr.DevScope" \ +DEVSCOPE_MARKETING_VERSION="0.1.0" \ +DEVSCOPE_BUILD_VERSION="1" \ +DEVSCOPE_APP_STORE_SIGN_IDENTITY="Apple Distribution: YOUR NAME (TEAMID)" \ +DEVSCOPE_INSTALLER_IDENTITY="3rd Party Mac Developer Installer: YOUR NAME (TEAMID)" \ +DEVSCOPE_PROVISIONING_PROFILE="/path/to/DevScope.provisionprofile" \ +./script/package_app_store.sh +``` diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..d3c28e0 --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,100 @@ +# Maintainer Releasing + +This procedure prepares one exact DevScope source commit and its macOS artifacts. +Repository publication and release creation require explicit owner authority. +Developer ID use, notarization, and App Store submission remain separate +credentialed actions. + +## 1. Establish the candidate + +Start from the intended `main`, confirm the working tree is clean, and record the +exact commit, toolchain, supported platform claims, and unresolved owner gates. +Do not tag while `docs/release/0.1.0/BLOCKERS.md` contains an unaccepted +publication blocker. + +## 2. Validate source and policy + +```bash +./script/check_open_source_readiness.sh +swift package show-dependencies --format json +swift test -Xswiftc -warnings-as-errors +swift build -c release -Xswiftc -warnings-as-errors +bash -n script/*.sh +plutil -lint Resources/PrivacyInfo.xcprivacy config/*.entitlements +git diff --check +``` + +Run the checksum-pinned Gitleaks workflow or an equivalent local Gitleaks 8.30.1 +full-history scan with redacted output. Separately inspect reachable deleted +images and Git author metadata; a secret scanner does not close visual privacy or +identity disclosure. + +## 3. Rehearse both product policies + +Build the sandbox compatibility bundle: + +```bash +DEVSCOPE_DIST_DIR="$(mktemp -d)/sandbox" \ + ./script/build_release_bundle.sh +``` + +Validate its App Sandbox and user-selected-file entitlements with +`DEVSCOPE_REQUIRE_SANDBOX=1 ./script/validate_release_bundle.sh `. + +Build the full-access local candidate with +`config/DevScopeDeveloperID.entitlements`, then validate with +`DEVSCOPE_REQUIRE_SANDBOX=0` and `DEVSCOPE_REQUIRE_APPLE_EVENTS=1`. An ad-hoc +signature proves bundle policy only; it is not a distributable production +signature. Confirm each candidate contains `LICENSE`, `NOTICE`, and +`THIRD_PARTY_NOTICES.md` under `Contents/Resources`. + +Run `./script/build_and_run.sh --verify`, exercise the README quickstart and the +primary Processes/Automations paths, and review DevScope-owned logs. + +## 4. Rehearse the exact source archive + +Create a `git archive` from the exact candidate commit in a temporary directory. +Inspect its file list, build and test from the extracted archive, rerun the +quickstart where practical, and confirm no ignored local files are required. +Record archive and artifact SHA-256 digests in the GitHub release or another +immutable release record; do not hardcode a self-referential final commit digest +inside that same commit. + +## 5. Production distribution + +Only after the owner supplies and authorizes the signing/notary inputs, run: + +```bash +DEVSCOPE_DEVELOPER_ID_SIGN_IDENTITY="Developer ID Application: YOUR NAME (TEAMID)" \ +DEVSCOPE_NOTARY_KEYCHAIN_PROFILE="devscope-notary" \ +./script/package_developer_id.sh +``` + +Require hardened runtime, timestamped signing, notarization acceptance, stapling, +Gatekeeper approval, checksum publication, and a clean-machine install/runtime +smoke of the exact archive. Keep the reduced sandbox/App Store route separate. + +### Community prerelease fallback + +When no Developer ID identity or notary profile is available, maintainers may +produce a clearly labeled ad-hoc signed prerelease: + +```bash +DEVSCOPE_SOURCE_COMMIT="$(git rev-parse HEAD)" \ +DEVSCOPE_ACKNOWLEDGE_UNNOTARIZED_PREVIEW=1 \ + ./script/package_community_preview.sh +``` + +The script validates the universal full-access bundle, packages an explicit +`UNNOTARIZED_COMMUNITY_PREVIEW.txt`, rejects AppleDouble metadata, and writes a +SHA-256 manifest. Publish it only as a GitHub **prerelease** whose title and first +paragraph say that it is ad-hoc signed and not notarized. Never describe this +fallback as a production, Developer ID, Gatekeeper-approved, or notarized build. + +## 6. Publish and verify + +After explicit external-action approval, push the exact candidate, require green +hosted checks, protect the default branch and release tags, publish the tag and +release, and inspect the repository signed out. Recheck security settings and +protections after any visibility change. Follow +[Installation and maintenance](INSTALLATION.md) for user-facing lifecycle copy. diff --git a/docs/open-source/OPEN_SOURCE_MANIFEST.json b/docs/open-source/OPEN_SOURCE_MANIFEST.json new file mode 100644 index 0000000..32bfea2 --- /dev/null +++ b/docs/open-source/OPEN_SOURCE_MANIFEST.json @@ -0,0 +1,33 @@ +{ + "schemaVersion": 1, + "project": "DevScope", + "license": "Apache-2.0", + "copyrightOwner": "Rafal Sikora", + "publicMaintainer": "RSI Tech", + "website": "https://rsitech.ai", + "contact": "info@rsitech.ai", + "sourcePublication": { + "status": "canonical-fresh-history-organization-repository", + "publicationBlocked": false, + "canonicalRepository": "https://github.com/rsitech-ai/devscope", + "legacyHistoryIncluded": false, + "checker": "script/check_open_source_readiness.sh", + "requiresOwnerVisibilityChange": false + }, + "productionDistribution": { + "fullFeatureRoute": "developer-id-direct-download", + "status": "blocked-external-notarization", + "communityPreview": "available-ad-hoc-signed-unnotarized-prerelease", + "sandboxFeatureParity": false + }, + "dependencies": [], + "sbom": "docs/open-source/SBOM.spdx.json", + "security": { + "privateReportingDocumented": true, + "historySecretScanRequired": true, + "historySecretScanWorkflow": ".github/workflows/secret-scan.yml", + "githubActionsPinnedToFullCommitSHA": true, + "historicalPrivacyReviewRequired": true, + "historicalPrivacyReviewStatus": "legacy-history-excluded-by-fresh-publication" + } +} diff --git a/docs/open-source/OPEN_SOURCE_STATUS.md b/docs/open-source/OPEN_SOURCE_STATUS.md new file mode 100644 index 0000000..17d5697 --- /dev/null +++ b/docs/open-source/OPEN_SOURCE_STATUS.md @@ -0,0 +1,46 @@ +# Open-Source Status + +## Current verdict + +The canonical public repository is +[rsitech-ai/devscope](https://github.com/rsitech-ai/devscope). It is published +from a clean source snapshot with fresh Git history. The legacy private +`s1korrrr/devscope` history is deliberately not transferred because it contains +deleted local audit images and author metadata that are irrelevant to the public +project. + +Each public snapshot must pass the deterministic checker, strict tests, release +build, exact-tree secret scan, review, and clean source-archive inspection. The +fresh-history route resolves the legacy-history privacy blocker without a force +push or disclosure of the private objects. + +## Distribution boundary + +Source publication is independent of binary production distribution. A +downloadable community prerelease may be ad-hoc signed and unnotarized only when +its archive and release notes say so prominently. The production direct-download +route still requires a real Developer ID identity, hardened runtime validation, +Apple notarization, ticket stapling, Gatekeeper assessment, and an exact-artifact +smoke test. + +The sandbox build validates packaging constraints but currently does not provide +the full process-inspection product and must not be marketed as feature-equivalent. + +## Final local gates + +Run from a clean publication candidate: + +```bash +./script/check_open_source_readiness.sh +swift test -Xswiftc -warnings-as-errors +swift build -c release -Xswiftc -warnings-as-errors +bash -n script/*.sh +plutil -lint Resources/PrivacyInfo.xcprivacy config/*.entitlements +``` + +Also run an exact-tree secret scan, inspect a `git archive` of the exact public +commit, and verify the fresh public history contains no legacy objects. + +The current dependency declaration and source-package inventory are recorded in +`OPEN_SOURCE_MANIFEST.json` and `SBOM.spdx.json`. They must be regenerated when +an external package or redistributed component is added. diff --git a/docs/open-source/PUBLICATION_GATE_MATRIX.md b/docs/open-source/PUBLICATION_GATE_MATRIX.md new file mode 100644 index 0000000..66cb697 --- /dev/null +++ b/docs/open-source/PUBLICATION_GATE_MATRIX.md @@ -0,0 +1,18 @@ +# Publication Gate Matrix + +| Gate | Completion evidence | Authority | +|---|---|---| +| License and notices | Apache-2.0 text, NOTICE, third-party notices, checker pass | Repository maintainer | +| Community health | Contributing, conduct, support, governance, security, privacy, issue and PR templates | Repository maintainer | +| Source integrity | Strict tests and release build, clean diff, shell/plist checks | Engineering | +| Supply-chain disclosure | Dependency graph reviewed; actions pinned to full SHAs; manifest current | Engineering | +| Secret/privacy history | Fresh public history excludes legacy objects; exact-tree secret scan and source-archive inspection pass | Security/release reviewer | +| Repository settings | Visibility, branch protection, required reviews/checks, private vulnerability reporting, security features | Repository owner | +| Public source release | Exact reviewed commit tagged or published with accurate notes | Repository owner | +| Direct-download binary | Developer ID, hardened runtime, notarization, stapling, Gatekeeper, exact-bundle smoke | Apple/release owner | +| Community prerelease | Ad-hoc signature disclosed, trust warning embedded, checksum published, exact-bundle smoke | Release owner | +| Mac App Store binary | Explicit reduced product scope, provisioning, validation, upload, review | Product and Apple account owner | + +No row implies completion of a later row. In particular, public source does not +make an ad-hoc signed preview production-ready, and a packaging smoke does not +prove App Store product viability. diff --git a/docs/open-source/SBOM.spdx.json b/docs/open-source/SBOM.spdx.json new file mode 100644 index 0000000..49cfee8 --- /dev/null +++ b/docs/open-source/SBOM.spdx.json @@ -0,0 +1,33 @@ +{ + "spdxVersion": "SPDX-2.3", + "dataLicense": "CC0-1.0", + "SPDXID": "SPDXRef-DOCUMENT", + "name": "DevScope-0.1.0-source", + "documentNamespace": "https://github.com/rsitech-ai/devscope/sbom/devscope-0.1.0-source-20260720", + "creationInfo": { + "created": "2026-07-17T19:50:00Z", + "creators": [ + "Tool: DevScope open-source readiness process" + ] + }, + "packages": [ + { + "name": "DevScope", + "SPDXID": "SPDXRef-Package-DevScope", + "versionInfo": "0.1.0", + "downloadLocation": "https://github.com/rsitech-ai/devscope", + "filesAnalyzed": false, + "licenseConcluded": "Apache-2.0", + "licenseDeclared": "Apache-2.0", + "copyrightText": "Copyright 2026 Rafal Sikora", + "primaryPackagePurpose": "APPLICATION" + } + ], + "relationships": [ + { + "spdxElementId": "SPDXRef-DOCUMENT", + "relationshipType": "DESCRIBES", + "relatedSpdxElement": "SPDXRef-Package-DevScope" + } + ] +} diff --git a/docs/release/0.1.0/APP_REVIEW_NOTES.md b/docs/release/0.1.0/APP_REVIEW_NOTES.md new file mode 100644 index 0000000..ae30475 --- /dev/null +++ b/docs/release/0.1.0/APP_REVIEW_NOTES.md @@ -0,0 +1,11 @@ +# Draft App Review Notes + +**Not submission-ready; owner and product approval required.** + +DevScope is a local macOS process-inspection tool. The full product reads local process metadata, working directories, commands, and resource measurements, and can send TERM/KILL only after explicit confirmation with PID-birth validation and protected-service checks. It does not require an account or network backend. + +The current Mac App Store sandbox build cannot inspect the local process set and therefore does not deliver the full product. Do not submit it as equivalent to the full build. If a reduced App Store edition is approved, these notes must be rewritten to describe its exact reduced functionality, how reviewers reach every feature, and why the listing/screenshots accurately represent that build. + +Reviewer prerequisites for a future full-capability route: launch developer tools/services locally to populate rows. No demo account exists. No purchases or subscriptions exist. Permission guidance must explain that sandbox blocking is not fixable through System Settings. + +Owner must supply review contact, support URL, privacy URL, content-rights/export answers, DSA status, age rating, accessibility declarations, and any regional restrictions. diff --git a/docs/release/0.1.0/APP_STORE_CHECKLIST.md b/docs/release/0.1.0/APP_STORE_CHECKLIST.md new file mode 100644 index 0000000..82ec2a7 --- /dev/null +++ b/docs/release/0.1.0/APP_STORE_CHECKLIST.md @@ -0,0 +1,23 @@ +# App Store Checklist + +Official requirements rechecked 2026-07-14: [App Review Guidelines](https://developer.apple.com/app-store/review/guidelines/), [App Sandbox](https://developer.apple.com/documentation/security/app-sandbox), [upload builds](https://developer.apple.com/help/app-store-connect/manage-builds/upload-builds/), [app information](https://developer.apple.com/help/app-store-connect/reference/app-information), [screenshot specifications](https://developer.apple.com/help/app-store-connect/reference/screenshot-specifications), [app privacy](https://developer.apple.com/help/app-store-connect/manage-app-information/manage-app-privacy/), [age ratings](https://developer.apple.com/help/app-store-connect/manage-app-information/set-an-app-age-rating), [accessibility nutrition labels](https://developer.apple.com/help/app-store-connect/manage-app-accessibility/overview-of-app-accessibility-nutrition-labels/), and [EU DSA trader requirements](https://developer.apple.com/help/app-store-connect/manage-compliance-information/manage-european-union-digital-services-act-trader-requirements/). + +- [x] Version/build drafted: 0.1.0 (1) +- [x] Universal local Release bundles and privacy manifest +- [x] Icon representations have exact expected pixel dimensions +- [x] App Store packaging scripts fail closed +- [x] Xcode 27/SDK 27 strict tests, Release, AddressSanitizer, and ThreadSanitizer +- [x] Apple Distribution and installer identities installed +- [ ] Product-compatible App Sandbox behavior +- [ ] App Store Connect record and matching `com.s1korrrr.DevScope` provisioning profile +- [ ] Signed `.pkg` and Apple validation +- [ ] Name/subtitle/promo/description/keywords/categories approval +- [ ] Support, privacy, and optional marketing URLs +- [ ] Current sanitized screenshots and optional preview +- [ ] Review contact/instructions and final review notes +- [ ] Privacy labels and in-app privacy policy +- [ ] Accessibility declarations +- [ ] Age rating, export compliance, content rights, DSA status +- [ ] Pricing, territories, manual/automatic/phased release decision +- [ ] Upload, processing inspection, TestFlight smoke +- [ ] Explicit approval before Submit for Review diff --git a/docs/release/0.1.0/BLOCKERS.md b/docs/release/0.1.0/BLOCKERS.md new file mode 100644 index 0000000..8605167 --- /dev/null +++ b/docs/release/0.1.0/BLOCKERS.md @@ -0,0 +1,31 @@ +# Release Blockers + +## Source publication follow-ups + +The canonical public source is published through a fresh-history +`rsitech-ai/devscope` snapshot. Legacy private objects, internal audits, +monetization work, and author metadata are not transferred. + +1. **Repository protections — Repository owner.** Keep pull-request protection, + private vulnerability reporting, dependency alerts, and available security + features enabled. Add required hosted checks when Actions billing permits + reliable execution. +2. **Public archive — Release owner.** Inspect the exact public commit archive and + record the tag, source commit, checksum, and all limitations in release + metadata. + +## Production binary gates + +1. **Distribution route — Product owner.** The full-feature product requires direct Developer ID distribution. A reduced sandbox/App Store edition would be a separate scoped product. +2. **Notarization — Apple account owner.** The installed `Developer ID Application: Rafal Sikora (2NY8A789TN)` identity is usable. Configure a notary Keychain profile backed by App Store Connect credentials, then produce a hardened, timestamped, notarized, stapled archive that passes Gatekeeper. +3. **Clean-machine runtime — QA/release owner.** Install and exercise the exact notarized archive on a clean supported Mac, including permission guidance, core inspection, safe fixture termination, automation read paths, update/uninstall behavior, and log review. +4. **Compatibility — QA.** Minimum macOS 14 runtime and physical Intel hardware runtime remain unexecuted. Universal build validation is not a substitute for hardware/runtime coverage. +5. **Performance/accessibility — QA.** Instruments/memgraph and the complete VoiceOver, keyboard, increased-contrast, and Reduce Motion matrices remain unverified. +6. **Public metadata — Product owner.** Public ownership, maintainer, website, and contact are approved. Final release notes and any hosted update mechanism still require exact-artifact review. + +An ad-hoc signed community prerelease is permitted only when the archive and +release page prominently state that it is unnotarized and not a production +Developer ID distribution artifact. + +Local source, test, runtime, and package checks cannot close owner credentials, +hosted CI, Apple notarization, clean-machine, or physical-hardware gates. diff --git a/docs/release/0.1.0/PRIVACY_DATA_MAP.md b/docs/release/0.1.0/PRIVACY_DATA_MAP.md new file mode 100644 index 0000000..7a629a4 --- /dev/null +++ b/docs/release/0.1.0/PRIVACY_DATA_MAP.md @@ -0,0 +1,19 @@ +# Privacy Data Map + +DevScope has no repository evidence of network transmission, accounts, analytics, advertising, tracking, third-party SDKs, or backend storage. The final App Store declaration still requires owner confirmation and must not be inferred solely from code. + +| Data | Source | Purpose | Storage/retention | Deletion | Recipient/tracking/linkage | +|---|---|---|---|---|---| +| Process PID, parent PID, executable, command, CWD, resource metrics | Local macOS process APIs, `ps`, `lsof`, `ioreg` | Local process inspection and grouping | Primarily in memory; history window about 2 minutes | Cleared on app exit/history rollover | No recipient observed; no tracking; not account-linked | +| Favorite/watch identity | User action plus process identity | Restore local categorization | UserDefaults as SHA-256-derived `v2:` keys | User can remove category; legacy raw keys are migrated to hashes on launch | Local only; not account-linked | +| Recovery copy | Explicit copy/export action | Restore last redacted copy | Local Application Support file, bounded to 2 MB, mode 0600 | Replaced by subsequent copy; app has no dedicated clear UI | Local only; not tracking | +| Clipboard content | Explicit copy/export action | User-requested transfer | macOS general pasteboard retention | Controlled by OS/user/next clipboard owner | Other local apps may read pasteboard | +| Apple Foundation Models prompts/results | Optional local naming feature | Improve display labels/workflow notes | In-memory cache | App lifecycle/cache eviction | Apple on-device framework; owner must confirm intended disclosure wording | +| UserDefaults settings | User choices | UI preferences | Local until app data removal | Change setting or remove app data | Local only | +| Automation definitions and provenance | Local launchd property lists, current-user crontab, Login Items/Background Items system diagnostics | Detect, classify, correlate, and locally manage approved user-owned automations | Inventory is primarily in memory; source diagnostics are reduced to health text/fingerprints | Cleared on refresh/app exit; original definitions remain under macOS/user control | Local only; no recipient or account linkage | +| Automation recovery backups and manifests | Explicit confirmed mutation of a user-owned automation | Conflict-safe rollback and manual restoration | Local Application Support, owner-only 0600 files under a 0700 directory, bounded by count/age | Pruned by bounded retention, successful recovery cleanup, or exact user/audit cleanup | Local only; may contain the original local automation command/environment | +| Automation notification state | Locally observed strong process links and verified state transitions | Optional long-running, unexpected-exit, and repeated-failure notifications | Transition state in memory; opt-in settings in UserDefaults | Cleared on app exit or setting removal | Delivered through local macOS notifications only | + +Privacy manifest declares UserDefaults required-reason API category with reason `CA92.1`. No ATT use was found. The repository now includes the public-source policy in `PRIVACY.md`; its GitHub URL becomes usable after source publication. A separately hosted URL and in-app policy link remain App Store/product-owner decisions. The owner must still confirm privacy labels, retention promises, local Foundation Models wording, support contact, and any operational telemetry not represented in this repository. + +The 2026-07-14 macOS 27 automation audit found no network transmission, telemetry, new third-party dependency, privileged helper, or secret logging in the automation engine. Raw current-user crontab content was kept out of committed evidence; only its checksum and result state are recorded. Redacted export is the default, and explicit unredacted output is restricted to owner-only permissions. diff --git a/docs/release/0.1.0/RELEASE_MANIFEST.json b/docs/release/0.1.0/RELEASE_MANIFEST.json new file mode 100644 index 0000000..8618ef1 --- /dev/null +++ b/docs/release/0.1.0/RELEASE_MANIFEST.json @@ -0,0 +1,88 @@ +{ + "schemaVersion": 3, + "product": "DevScope", + "copyrightOwner": "Rafal Sikora", + "publicMaintainer": "RSI Tech", + "website": "https://rsitech.ai", + "contact": "info@rsitech.ai", + "platform": "macOS", + "version": "0.1.0", + "build": "1", + "date": "2026-07-20", + "verdict": "SOURCE_PUBLICATION_APPROVED_COMMUNITY_PREVIEW_PRODUCTION_BINARY_BLOCKED", + "source": { + "integratedBranch": "main", + "auditedProductBaseline": "e8f3f973dcb94f83c89ba73bbde600bc7c920e1e", + "exactPublicationCommit": null, + "exactPublicationCommitStatus": "record-in-public-release-metadata" + }, + "tests": { + "command": "swift test -Xswiftc -warnings-as-errors", + "executed": 669, + "failures": 0 + }, + "sourcePublication": { + "technicalBuildAndTestGatesPassed": true, + "publicationBlocked": false, + "canonicalRepository": "https://github.com/rsitech-ai/devscope", + "freshHistoryPublication": true, + "legacyHistoryIncluded": false, + "secretFindings": 0, + "visualHistoryPrivacyClear": true, + "dependencyCount": 0, + "license": "Apache-2.0", + "repositoryVisibilityChanged": true, + "ownerSettingsVerified": true, + "exactArchiveValidationPassed": true, + "hostedChecksStatus": "record-from-public-organization-pr" + }, + "artifacts": [ + { + "role": "local-full-feature-validation", + "distribution": "local-ad-hoc-full-access", + "architectures": ["x86_64", "arm64"], + "bundleIdentifier": "com.s1korrrr.DevScope", + "signature": "ad-hoc", + "executableSHA256": null, + "digestStatus": "record-from-exact-final-artifact", + "validated": true, + "licenseNoticesEmbedded": true, + "productionDistributable": false + }, + { + "role": "sandbox-compatibility-validation", + "distribution": "local-ad-hoc-sandbox", + "architectures": ["x86_64", "arm64"], + "bundleIdentifier": "com.s1korrrr.DevScope", + "signature": "ad-hoc", + "executableSHA256": null, + "digestStatus": "record-from-exact-final-artifact", + "validated": true, + "licenseNoticesEmbedded": true, + "fullFeatureParity": false, + "productionDistributable": false + } + ], + "communityPreview": { + "status": "AD_HOC_SIGNED_UNNOTARIZED_PRERELEASE", + "architectures": ["x86_64", "arm64"], + "trustWarningEmbedded": true, + "sha256Published": true, + "productionDistributable": false + }, + "publicBinaryDistribution": { + "status": "BLOCKED_EXTERNAL_NOTARIZATION", + "developerIDIdentity": "Developer ID Application: Rafal Sikora (2NY8A789TN)", + "developerIDIdentityAvailable": true, + "notaryKeychainProfileAvailable": false, + "developerIDSigned": false, + "notarized": false, + "stapled": false, + "gatekeeperApproved": false + }, + "upload": { + "appStoreValidated": false, + "testFlightUploaded": false, + "submittedForReview": false + } +} diff --git a/docs/release/0.1.0/RELEASE_NOTES.md b/docs/release/0.1.0/RELEASE_NOTES.md new file mode 100644 index 0000000..3e98d44 --- /dev/null +++ b/docs/release/0.1.0/RELEASE_NOTES.md @@ -0,0 +1,23 @@ +# DevScope 0.1.0 Community Preview Release Notes + +Initial release candidate hardening includes: + +- Universal Apple silicon and Intel Release bundles. +- Clear sandbox-versus-full-build access diagnostics. +- Single-window process scanner ownership. +- Scrollable settings, reduced-motion support, accessibility announcements, and accessible graph summaries. +- Truthful folder/recovery feedback. +- Hashed favorite/watch identity persistence with one-time legacy-key migration. +- Stronger credential redaction and safe spreadsheet export. +- Actionable file-mutation recovery locations with Finder and copy-path actions. +- Durable copy recovery shared by command-bar and table context-menu actions. +- Classification-independent protected-process enforcement. +- Fail-closed App Store sandbox packaging and exact entitlement validation. +- Configured macOS 26 CI gates for Apple silicon and Intel runners, covering both sandbox and full-access bundle policies. + +The downloadable community preview is ad-hoc signed and not notarized by Apple. +It is not a production Developer ID or approved App Store build. The canonical +public source uses fresh Git history under `rsitech-ai/devscope`; legacy private +audit objects and author metadata are not included. Developer ID signing, +notarization, stapling, Gatekeeper approval, clean-machine proof, and App Store +submission remain open. diff --git a/docs/release/0.1.0/RELEASE_STATUS.md b/docs/release/0.1.0/RELEASE_STATUS.md new file mode 100644 index 0000000..a37bf1f --- /dev/null +++ b/docs/release/0.1.0/RELEASE_STATUS.md @@ -0,0 +1,56 @@ +# DevScope 0.1.0 Release Status + +## Current verdict + +**SOURCE PUBLICATION APPROVED / COMMUNITY PRERELEASE ROUTE READY / PRODUCTION BINARY BLOCKED — 2026-07-20.** + +The repository contains an Apache-2.0 license, public contribution and security +policies, privacy and architecture disclosures, pinned CI automation, a source +manifest and SPDX SBOM, and a deterministic publication checker. The canonical +public repository is `rsitech-ai/devscope`, seeded from a clean source snapshot +with fresh Git history. The legacy personal repository and its deleted audit +objects are not transferred. + +The full-feature local bundle is universal (`x86_64 arm64`), ad-hoc signed, +structurally validated, and runtime proven. A checksummed community prerelease +may be published only with an embedded unnotarized warning. Production binary +distribution remains **BLOCKED:EXTERNAL** because the installed Developer ID +identity has no configured notary Keychain profile, so no notarized, stapled, +and Gatekeeper-approved artifact was produced. Sandbox +packaging passes, but the sandbox product is not feature-equivalent. + +## Verified gates + +| Gate | Status | Evidence | +|---|---|---| +| Public-source contract | PASS | Apache-2.0, NOTICE, community/security/privacy/governance files, architecture, manifest, SPDX SBOM | +| Deterministic source gate | PASS | `script/check_open_source_readiness.sh` | +| Public history and tree | PASS BY CONSTRUCTION | Canonical publication uses fresh history from the exact sanitized source archive; legacy private objects and author metadata are not transferred; exact-tree Gitleaks remains required | +| Dependency declaration | PASS | Swift package dependency graph is empty; third-party notices and SBOM agree | +| Strict test suite | PASS | 669 tests, 0 failures, warnings treated as errors | +| Strict release build | PASS | Production build completed with warnings treated as errors | +| Exact development runtime | PASS | Exact staged bundle launched, matched to its running executable, and passed strict bundle-signature verification; Processes and Automations received a real UI smoke | +| Universal full-access package | PASS (local) | `x86_64 arm64`, Apple Events entitlement, App Sandbox absent, signature/policy validation passed, license notices embedded | +| Universal sandbox package | PASS (constrained) | `x86_64 arm64`, sandbox and user-selected read-write entitlements, signature/policy validation passed, license notices embedded | +| Community prerelease | READY (unnotarized) | Explicit acknowledgement, ad-hoc signature disclosure, embedded trust warning, clean universal zip, and published SHA-256 required | +| Developer ID identity | PASS (installed) | `Developer ID Application: Rafal Sikora (2NY8A789TN)` is available with its private key | +| Notarization | BLOCKED:EXTERNAL | No `devscope-notary` Keychain profile or equivalent App Store Connect credentials are configured | +| Repository publication settings | OWNER ACTION | Verify public visibility, default-branch protection, private vulnerability reporting, and security settings after organization import | +| Remote required checks | VERIFY ON PUBLIC PR | Record organization-run results; do not inherit the legacy personal repository's billing failure as code evidence | +| Exact hardened-source archive | PASS (local) | The source tree passed readiness, 669 strict tests, warnings-as-errors release build, and exact-tree Gitleaks from a clean `git archive`; repeat from the exact public commit | + +## Log boundary + +The exact development bundle produced no DevScope-owned error or fault during +the fresh scan interval. The macOS 27 beta host emitted framework-owned App +Intents/CoreSpotlight connection and donation errors because Apple host services +were unavailable. These are documented platform diagnostics, not a claim that +the complete unified system log is silent. + +## Release policy + +- Record the exact source commit in the release tag or GitHub release; do not hardcode a self-referential commit in tracked evidence. +- Do not publish an ad-hoc signed local bundle as a production download. +- Publish an ad-hoc signed archive only as an explicitly unnotarized community prerelease with its checksum and trust warning. +- Do not market the sandbox build as the full-feature product. +- Treat source publication, Developer ID distribution, and Mac App Store distribution as independent gates. diff --git a/docs/release/0.1.0/SECURITY_STATUS.md b/docs/release/0.1.0/SECURITY_STATUS.md new file mode 100644 index 0000000..bd618f9 --- /dev/null +++ b/docs/release/0.1.0/SECURITY_STATUS.md @@ -0,0 +1,42 @@ +# Security Status + +The standard repository scan targeted immutable revision `bedc3ac5a04e974ef2a3336e40866a88453bb1ff`. Thirty ranked high-priority files received full-file receipts. Four reportable medium/low candidates were reproduced and fixed in the release worktree: + +1. Critical service protection no longer depends on presentation classification. +2. Redacted commands now cover common `redis-cli -a`, `mysql -p`, and `curl -u` credential forms. +3. TSV exports flatten row/cell separators and neutralize spreadsheet formula prefixes. +4. App Store packaging pins sandbox entitlements and sandbox-required validation; entitlement validation parses the exact plist value. + +One low least-privilege candidate remains deferred: non-TCC `lsof` failures and genuine permission denial currently collapse to the same “zero CWDs” signal, which can lead to Full Disk Access guidance. It requires provenance-aware scanner changes and targeted failure injection. One adjacency-based entitlement validator candidate was suppressed by an actual signed-bundle counterexample, though the parser was hardened anyway. + +No critical/high findings, remote attack surface, third-party dependency, committed credential, network listener, privileged helper, updater, WebView, or binary framework was found in that dated scan. Its readable report was stored outside the repository and is not a reproducible artifact of the current checkout; `RELEASE_MANIFEST.json` therefore does not claim or link current-snapshot scan proof. + +That dated main-to-working-tree security diff scan covered all 22 source-like files changed in its `bedc3ac5a04e974ef2a3336e40866a88453bb1ff` scope, including the release workflow. It validated and fixed a `curl --user` / `--proxy-user` bulk-redaction gap and pinned `actions/checkout` to the then-reviewed immutable commit `34e114876b0b11c390a56381ad16ebd13914f8d5`. Both candidates were revalidated within that dated snapshot, whose sealed report recorded zero surviving reportable findings. + +Residual risk: the earlier standard scan's lower-ranked baseline files remain outside that scan's full-file review, and the deferred `lsof` failure-provenance hardening remains open. That dated diff scan did not cover the current continuation. + +## 2026-07-20 release continuation + +The owner explicitly waived a fresh Codex Security app scan for this continuation. +Local regression work fixed complete-value redaction for Digest, AWS Signature, +proxy authorization, and cookie headers, and added exact recovery-path handling. +This is focused local evidence, not exhaustive current-snapshot scan coverage. + +Four deleted screenshots in the legacy private history were visually confirmed +to expose local identity, workstation-path, process, and workspace information. +The canonical public repository therefore uses a fresh source snapshot and does +not transfer legacy commits, deleted objects, or private author metadata. + +## 2026-07-14 automation-engine addendum + +The layered automation engine adds a higher-risk local management surface and was reviewed against explicit ownership, canonical-path, stale-generation, content-validation, backup, postcondition, rollback, and process-birth gates. Runtime audit fixes closed independent source hangs, cancellation-ignoring source deadlines, ambiguous LaunchAgent runtime errors, stale Start Now verification, incoherent running projection, implicitly enabled duplicates, and command descendants that retained output pipes past leader exit. Commit `310f54c` then closed the final review findings with a shared typed `launchctl print` absence classifier, fail-closed identical-definition provenance, per-source quarantine for cancellation-ignoring refreshes, and four-worker deduplicated runtime discovery. Ordinary, AddressSanitizer, and ThreadSanitizer suites each passed 547/547; the focused controller rerun passed 105/105; the warnings-as-errors Release build and diff check passed. + +The completed independent immutable whole-branch re-review found no Critical, Important, or Minor issue after those fixes. The changed slice has no surviving correctness or security finding. Product commands use fixed absolute executables plus argument arrays, recovery artifacts are 0700/0600 and content-authenticated, unknown launchd state is inspection-only, cross-app modern services remain read-only, imports reject symlinks/non-regular/oversized data, and unredacted export is owner-only. No shell interpolation, network transmission, new dependency, updater, privileged helper, credential, or public listener was introduced. + +Residual automation risk is reported fail-closed: `/usr/bin/crontab ` +blocks in the current macOS 27 process context, and modern Login Items/Background +Items enumeration times out. The 30-second command deadline terminates and reaps +the complete child group; the independent 10-second source deadline preserves +healthy inventory. These integrations must not be described as fully operational +on this host until a permission/toolchain change enables verified write/readback +or enumeration. diff --git a/docs/release/0.1.0/TEST_EVIDENCE.md b/docs/release/0.1.0/TEST_EVIDENCE.md new file mode 100644 index 0000000..88a9862 --- /dev/null +++ b/docs/release/0.1.0/TEST_EVIDENCE.md @@ -0,0 +1,48 @@ +# DevScope 0.1.0 Test Evidence + +- Evidence date: 2026-07-20 +- Canonical publication target: `rsitech-ai/devscope` `main` +- Audited product baseline: `e8f3f973dcb94f83c89ba73bbde600bc7c920e1e` +- Candidate delta: credential redaction, exact recovery-path presentation, durable command-copy recovery, coherent development-bundle signing, embedded release license notices, dual-policy CI, and public release documentation +- Host: macOS 27.0 (beta host) +- Toolchain: Xcode 26.6, Swift 6.3.3 + +## Source verification + +- `./script/check_open_source_readiness.sh`: passed. +- `swift test -Xswiftc -warnings-as-errors`: 669 tests, 0 failures. +- `swift build -c release -Xswiftc -warnings-as-errors`: passed. +- `bash -n script/*.sh`: passed. +- `plutil -lint Resources/PrivacyInfo.xcprivacy config/*.entitlements`: passed. +- GitHub YAML parsed with Ruby Psych: all workflow, issue-form, funding, and Dependabot files passed. +- `gitleaks dir --redact --no-banner --no-color .`: approximately 81.15 MB, no pattern findings. +- Public snapshot `gitleaks dir --redact --no-banner --no-color .`: required at the exact organization PR commit. +- Clean `git archive` verification: readiness, all 669 strict tests, the warnings-as-errors release build, and exact-tree Gitleaks must pass from the exact public commit extraction. +- Public history inspection: the organization repository is seeded from a fresh source snapshot and must contain no legacy private commits or deleted objects. +- Hosted Secret Scan and Release Gates results must be recorded from the exact organization PR rather than inherited from the legacy personal repository. +- Official Apache-2.0 text comparison: byte-for-byte match. + +The Gitleaks policy contains narrowly scoped rule, path, and line-pattern +exceptions for synthetic credential-redaction tests and published artifact +SHA-256 digests. It does not exclude an entire test or documentation tree. + +## Runtime and package verification + +- `script/build_and_run.sh --verify`: exact staged development binary launched and matched its executable; the completed bundle passed strict signature verification. +- Real UI smoke: Automations loaded with explicit diagnostics; Processes loaded live inventory; exact-PID search, process detail, self-protection, and durable command copy were exercised. +- Full-access local package: version 0.1.0 (1), `x86_64 arm64`, App Sandbox absent, Apple Events entitlement present, strict signature and policy validation passed, with license notices embedded. +- Sandbox package: version 0.1.0 (1), `x86_64 arm64`, required sandbox and user-selected read-write entitlements present, strict signature and policy validation passed, with license notices embedded. + +Artifact SHA-256 digests must be recorded from the exact merged/final artifact +in GitHub release metadata or another immutable exact-build record; none is +claimed by this candidate document. + +The reviewed recovery-cache repair modifies application source. Fresh artifact +hashes must therefore be recorded from the exact merged release build rather +than inherited from the earlier audited baseline. + +## Distribution boundary + +This is verified source and local runtime/package evidence. It is not Developer +ID signing, notarization, Gatekeeper approval, App Store validation, TestFlight +upload, clean-machine installation proof, or App Review approval. diff --git a/docs/screenshots/devscope-main-sanitized-2026-07-15.png b/docs/screenshots/devscope-main-sanitized-2026-07-15.png new file mode 100644 index 0000000..a052332 Binary files /dev/null and b/docs/screenshots/devscope-main-sanitized-2026-07-15.png differ diff --git a/script/build_and_run.sh b/script/build_and_run.sh new file mode 100755 index 0000000..bb37027 --- /dev/null +++ b/script/build_and_run.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +set -euo pipefail + +MODE="${1:-run}" +APP_NAME="DevScope" +BUNDLE_ID="dev.andrzej.DevScope" +MIN_SYSTEM_VERSION="14.0" +MARKETING_VERSION="${DEVSCOPE_MARKETING_VERSION:-0.1.0}" +BUILD_VERSION="${DEVSCOPE_BUILD_VERSION:-1}" + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DIST_DIR="$ROOT_DIR/dist" +APP_BUNDLE="$DIST_DIR/$APP_NAME.app" +APP_CONTENTS="$APP_BUNDLE/Contents" +APP_MACOS="$APP_CONTENTS/MacOS" +APP_RESOURCES="$APP_CONTENTS/Resources" +APP_BINARY="$APP_MACOS/$APP_NAME" +INFO_PLIST="$APP_CONTENTS/Info.plist" +ICONSET_DIR="$DIST_DIR/AppIcon.iconset" + +cd "$ROOT_DIR" + +pkill -x "$APP_NAME" >/dev/null 2>&1 || true + +swift build +BUILD_BINARY="$(swift build --show-bin-path)/$APP_NAME" + +rm -rf "$APP_BUNDLE" "$ICONSET_DIR" +mkdir -p "$APP_MACOS" "$APP_RESOURCES" +cp "$BUILD_BINARY" "$APP_BINARY" +chmod +x "$APP_BINARY" + +swift "$ROOT_DIR/script/generate_app_icon.swift" "$ICONSET_DIR" +iconutil -c icns "$ICONSET_DIR" -o "$APP_RESOURCES/AppIcon.icns" +cp "$ROOT_DIR/Resources/PrivacyInfo.xcprivacy" "$APP_RESOURCES/PrivacyInfo.xcprivacy" + +plutil -create xml1 "$INFO_PLIST" +plutil -insert CFBundleExecutable -string "$APP_NAME" "$INFO_PLIST" +plutil -insert CFBundleIconFile -string "AppIcon" "$INFO_PLIST" +plutil -insert CFBundleIdentifier -string "$BUNDLE_ID" "$INFO_PLIST" +plutil -insert CFBundleName -string "$APP_NAME" "$INFO_PLIST" +plutil -insert CFBundlePackageType -string "APPL" "$INFO_PLIST" +plutil -insert CFBundleShortVersionString -string "$MARKETING_VERSION" "$INFO_PLIST" +plutil -insert CFBundleVersion -string "$BUILD_VERSION" "$INFO_PLIST" +plutil -insert LSApplicationCategoryType -string "public.app-category.developer-tools" "$INFO_PLIST" +plutil -insert LSMinimumSystemVersion -string "$MIN_SYSTEM_VERSION" "$INFO_PLIST" +plutil -insert NSAppleEventsUsageDescription -string \ + "DevScope uses System Events only to inspect and manage current-user legacy login items you explicitly choose." \ + "$INFO_PLIST" +plutil -insert NSHumanReadableCopyright -string \ + "Copyright © 2026 Rafal Sikora." "$INFO_PLIST" +plutil -insert NSHighResolutionCapable -bool true "$INFO_PLIST" +plutil -insert NSPrincipalClass -string "NSApplication" "$INFO_PLIST" + +# SwiftPM signs the standalone development executable. Re-sign the completed +# bundle so its Info.plist and resources are covered by a coherent signature. +codesign --force --sign - "$APP_BUNDLE" +codesign --verify --deep --strict --verbose=2 "$APP_BUNDLE" + +open_app() { + /usr/bin/open -n -a "$APP_BUNDLE" +} + +verify_exact_app() { + local attempt pid running_command + + for ((attempt = 0; attempt < 30; attempt++)); do + while IFS= read -r pid; do + [[ -n "$pid" ]] || continue + running_command="$(/bin/ps -p "$pid" -o command= 2>/dev/null || true)" + running_command="${running_command#"${running_command%%[![:space:]]*}"}" + if [[ "$running_command" == "$APP_BINARY" ]]; then + return 0 + fi + done < <(/usr/bin/pgrep -x "$APP_NAME" || true) + sleep 0.1 + done + + echo "error: exact built app did not launch: $APP_BINARY" >&2 + return 1 +} + +case "$MODE" in + run) + open_app + ;; + --debug|debug) + lldb -- "$APP_BINARY" + ;; + --logs|logs) + open_app + /usr/bin/log stream --info --style compact --predicate "process == \"$APP_NAME\"" + ;; + --telemetry|telemetry) + open_app + /usr/bin/log stream --info --style compact --predicate "subsystem == \"$BUNDLE_ID\"" + ;; + --verify|verify) + open_app + verify_exact_app + ;; + *) + echo "usage: $0 [run|--debug|--logs|--telemetry|--verify]" >&2 + exit 2 + ;; +esac diff --git a/script/build_release_bundle.sh b/script/build_release_bundle.sh new file mode 100755 index 0000000..faa216e --- /dev/null +++ b/script/build_release_bundle.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +set -euo pipefail + +APP_NAME="DevScope" +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DIST_DIR="${DEVSCOPE_DIST_DIR:-$ROOT_DIR/dist}" +RELEASE_BUILD_ROOT="${DEVSCOPE_RELEASE_BUILD_ROOT:-$ROOT_DIR/.build/release-bundles}" +APP_BUNDLE="$DIST_DIR/$APP_NAME.app" +APP_CONTENTS="$APP_BUNDLE/Contents" +APP_MACOS="$APP_CONTENTS/MacOS" +APP_RESOURCES="$APP_CONTENTS/Resources" +APP_BINARY="$APP_MACOS/$APP_NAME" +INFO_PLIST="$APP_CONTENTS/Info.plist" +ICONSET_DIR="$RELEASE_BUILD_ROOT/AppIcon.iconset" + +BUNDLE_ID="${DEVSCOPE_BUNDLE_ID:-com.s1korrrr.DevScope}" +MIN_SYSTEM_VERSION="${DEVSCOPE_MIN_SYSTEM_VERSION:-14.0}" +MARKETING_VERSION="${DEVSCOPE_MARKETING_VERSION:-0.1.0}" +BUILD_VERSION="${DEVSCOPE_BUILD_VERSION:-1}" +COPYRIGHT="${DEVSCOPE_COPYRIGHT:-Copyright © 2026 Rafal Sikora.}" +SIGN_IDENTITY="${DEVSCOPE_SIGN_IDENTITY:--}" +ENTITLEMENTS="${DEVSCOPE_ENTITLEMENTS:-$ROOT_DIR/config/DevScope.entitlements}" +PROVISIONING_PROFILE="${DEVSCOPE_PROVISIONING_PROFILE:-}" +HARDENED_RUNTIME="${DEVSCOPE_HARDENED_RUNTIME:-0}" +ARCHITECTURES="${DEVSCOPE_ARCHITECTURES:-arm64 x86_64}" + +cd "$ROOT_DIR" + +if [[ ! -f "$ENTITLEMENTS" ]]; then + echo "error: entitlements file not found: $ENTITLEMENTS" >&2 + exit 2 +fi + +BUILD_BINARIES=() +for architecture in $ARCHITECTURES; do + scratchPath="$RELEASE_BUILD_ROOT/$architecture" + BUILD_ARGS=( + -c release + --arch "$architecture" + --build-system native + --scratch-path "$scratchPath" + ) + swift build "${BUILD_ARGS[@]}" >&2 + BUILD_BINARY="$(swift build "${BUILD_ARGS[@]}" --show-bin-path)/$APP_NAME" + if [[ ! -x "$BUILD_BINARY" ]]; then + echo "error: release binary not found for $architecture: $BUILD_BINARY" >&2 + exit 2 + fi + BUILD_BINARIES+=("$BUILD_BINARY") +done + +rm -rf "$APP_BUNDLE" "$ICONSET_DIR" +mkdir -p "$APP_MACOS" "$APP_RESOURCES" +/usr/bin/lipo -create "${BUILD_BINARIES[@]}" -output "$APP_BINARY" +for architecture in $ARCHITECTURES; do + /usr/bin/lipo "$APP_BINARY" -verify_arch "$architecture" +done +chmod +x "$APP_BINARY" + +swift "$ROOT_DIR/script/generate_app_icon.swift" "$ICONSET_DIR" +iconutil -c icns "$ICONSET_DIR" -o "$APP_RESOURCES/AppIcon.icns" +cp "$ROOT_DIR/Resources/PrivacyInfo.xcprivacy" "$APP_RESOURCES/PrivacyInfo.xcprivacy" +cp "$ROOT_DIR/Resources/AppIcon.svg" "$APP_RESOURCES/AppIcon.svg" +cp "$ROOT_DIR/LICENSE" "$APP_RESOURCES/LICENSE" +cp "$ROOT_DIR/NOTICE" "$APP_RESOURCES/NOTICE" +cp "$ROOT_DIR/THIRD_PARTY_NOTICES.md" "$APP_RESOURCES/THIRD_PARTY_NOTICES.md" + +if [[ -n "$PROVISIONING_PROFILE" ]]; then + if [[ ! -f "$PROVISIONING_PROFILE" ]]; then + echo "error: provisioning profile not found: $PROVISIONING_PROFILE" >&2 + exit 2 + fi + cp "$PROVISIONING_PROFILE" "$APP_CONTENTS/embedded.provisionprofile" +fi + +plutil -create xml1 "$INFO_PLIST" +plutil -insert CFBundleDevelopmentRegion -string "en" "$INFO_PLIST" +plutil -insert CFBundleDisplayName -string "$APP_NAME" "$INFO_PLIST" +plutil -insert CFBundleExecutable -string "$APP_NAME" "$INFO_PLIST" +plutil -insert CFBundleIconFile -string "AppIcon" "$INFO_PLIST" +plutil -insert CFBundleIdentifier -string "$BUNDLE_ID" "$INFO_PLIST" +plutil -insert CFBundleName -string "$APP_NAME" "$INFO_PLIST" +plutil -insert CFBundlePackageType -string "APPL" "$INFO_PLIST" +plutil -insert CFBundleShortVersionString -string "$MARKETING_VERSION" "$INFO_PLIST" +plutil -insert CFBundleVersion -string "$BUILD_VERSION" "$INFO_PLIST" +plutil -insert LSApplicationCategoryType -string "public.app-category.developer-tools" "$INFO_PLIST" +plutil -insert LSMinimumSystemVersion -string "$MIN_SYSTEM_VERSION" "$INFO_PLIST" +plutil -insert NSApplicationSupportsSecureRestorableState -bool true "$INFO_PLIST" +plutil -insert NSAppleEventsUsageDescription -string \ + "DevScope uses System Events only to inspect and manage current-user legacy login items you explicitly choose." \ + "$INFO_PLIST" +plutil -insert NSHighResolutionCapable -bool true "$INFO_PLIST" +plutil -insert NSHumanReadableCopyright -string "$COPYRIGHT" "$INFO_PLIST" +plutil -insert NSPrincipalClass -string "NSApplication" "$INFO_PLIST" +plutil -insert NSSupportsAutomaticTermination -bool true "$INFO_PLIST" +plutil -insert NSSupportsSuddenTermination -bool true "$INFO_PLIST" + +SIGN_ARGS=(--force --sign "$SIGN_IDENTITY" --entitlements "$ENTITLEMENTS") +if [[ "$HARDENED_RUNTIME" == "1" ]]; then + SIGN_ARGS+=(--options runtime) +fi +if [[ "$SIGN_IDENTITY" == "-" ]]; then + SIGN_ARGS+=(--timestamp=none) +else + SIGN_ARGS+=(--timestamp) +fi + +codesign "${SIGN_ARGS[@]}" "$APP_BUNDLE" >&2 +codesign --verify --deep --strict --verbose=4 "$APP_BUNDLE" >&2 + +echo "$APP_BUNDLE" diff --git a/script/check_open_source_readiness.sh b/script/check_open_source_readiness.sh new file mode 100755 index 0000000..f236c36 --- /dev/null +++ b/script/check_open_source_readiness.sh @@ -0,0 +1,457 @@ +#!/usr/bin/env bash +set -euo pipefail + +DEFAULT_ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ROOT_DIR="$DEFAULT_ROOT_DIR" +ROOT_WAS_EXPLICIT=0 +SKIP_DEPENDENCY_CHECK=0 + +usage() { + cat <<'EOF' +Usage: check_open_source_readiness.sh [--root PATH] [--skip-dependency-check] + +Validates the repository's public-source policy, collaboration metadata, and +dependency declaration. The dependency check may be skipped only for isolated +checker tests that do not contain a Swift package. +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --root) + [[ $# -ge 2 ]] || { echo "error: --root requires a path" >&2; exit 2; } + ROOT_DIR="$2" + ROOT_WAS_EXPLICIT=1 + shift 2 + ;; + --skip-dependency-check) + SKIP_DEPENDENCY_CHECK=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "error: unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +[[ -d "$ROOT_DIR" ]] || { echo "error: repository root does not exist: $ROOT_DIR" >&2; exit 2; } +ROOT_DIR="$(cd "$ROOT_DIR" && pwd)" + +if [[ "$SKIP_DEPENDENCY_CHECK" -eq 1 ]]; then + if [[ "$ROOT_WAS_EXPLICIT" -ne 1 ]]; then + echo "error: --skip-dependency-check requires an explicit isolated --root fixture" >&2 + exit 2 + fi + if git -C "$ROOT_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "error: --skip-dependency-check cannot be used for a Git working tree" >&2 + exit 2 + fi +fi + +failures=0 + +fail() { + echo "error: $*" >&2 + failures=$((failures + 1)) +} + +required_files=( + LICENSE + NOTICE + README.md + CHANGELOG.md + CODE_OF_CONDUCT.md + CONTRIBUTING.md + GOVERNANCE.md + PRIVACY.md + SECURITY.md + SUPPORT.md + THIRD_PARTY_NOTICES.md + TRADEMARKS.md + .gitleaks.toml + .github/CODEOWNERS + .github/PULL_REQUEST_TEMPLATE.md + .github/dependabot.yml + .github/ISSUE_TEMPLATE/bug.yml + .github/ISSUE_TEMPLATE/config.yml + .github/ISSUE_TEMPLATE/feature.yml + .github/workflows/release-gates.yml + .github/workflows/secret-scan.yml + script/check_open_source_readiness.sh + script/package_community_preview.sh + docs/ARCHITECTURE.md + docs/INSTALLATION.md + docs/RELEASING.md + docs/open-source/OPEN_SOURCE_MANIFEST.json + docs/open-source/OPEN_SOURCE_STATUS.md + docs/open-source/PUBLICATION_GATE_MATRIX.md + docs/open-source/SBOM.spdx.json +) + +for relative_path in "${required_files[@]}"; do + [[ -s "$ROOT_DIR/$relative_path" ]] || fail "required public-source file is missing or empty: $relative_path" +done + +if [[ -s "$ROOT_DIR/NOTICE" ]]; then + grep -Fq "Copyright 2026 Rafal Sikora" "$ROOT_DIR/NOTICE" \ + || fail "public identity is missing copyright owner Rafal Sikora from NOTICE" + grep -Fq "RSI Tech" "$ROOT_DIR/NOTICE" \ + || fail "public identity is missing maintainer RSI Tech from NOTICE" + grep -Fq "https://rsitech.ai" "$ROOT_DIR/NOTICE" \ + || fail "public identity is missing website https://rsitech.ai from NOTICE" + grep -Fq "info@rsitech.ai" "$ROOT_DIR/NOTICE" \ + || fail "public identity is missing project contact info@rsitech.ai from NOTICE" +fi + +internal_publication_paths=( + .codex + docs/audits + docs/monetization + docs/superpowers +) + +for relative_path in "${internal_publication_paths[@]}"; do + if [[ -e "$ROOT_DIR/$relative_path" ]]; then + fail "internal publication material must not be present: $relative_path" + fi +done + +if [[ -s "$ROOT_DIR/LICENSE" ]]; then + grep -Fq "Apache License" "$ROOT_DIR/LICENSE" || fail "LICENSE must identify the Apache License" + grep -Fq "Version 2.0, January 2004" "$ROOT_DIR/LICENSE" || fail "LICENSE must contain Apache-2.0 version text" + license_digest="$(shasum -a 256 "$ROOT_DIR/LICENSE" | awk '{print $1}')" + [[ "$license_digest" == "cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30" ]] \ + || fail "LICENSE differs from the official Apache-2.0 text" +fi + +public_surface=( + README.md + CHANGELOG.md + CODE_OF_CONDUCT.md + CONTRIBUTING.md + GOVERNANCE.md + PRIVACY.md + SECURITY.md + SUPPORT.md + THIRD_PARTY_NOTICES.md + TRADEMARKS.md + docs/ARCHITECTURE.md + docs/INSTALLATION.md + docs/RELEASING.md + docs/open-source/OPEN_SOURCE_STATUS.md + docs/open-source/PUBLICATION_GATE_MATRIX.md +) + +if ! python3 - "$ROOT_DIR" <<'PY' +import pathlib +import re +import sys + +root = pathlib.Path(sys.argv[1]) +paths = [] +for directory in (root / "docs", root / ".github"): + if directory.is_dir(): + paths.extend(path for path in directory.rglob("*") if path.is_file()) +paths.extend( + root / name + for name in ( + "README.md", "CHANGELOG.md", "CODE_OF_CONDUCT.md", "CONTRIBUTING.md", + "GOVERNANCE.md", "PRIVACY.md", "SECURITY.md", "SUPPORT.md", + "THIRD_PARTY_NOTICES.md", "TRADEMARKS.md", "NOTICE", + ) + if (root / name).is_file() +) +text_suffixes = {"", ".md", ".json", ".toml", ".txt", ".yaml", ".yml"} +patterns = ( + re.compile(r"/Users/(?!example(?:/|\b))[^/\s]+/"), + re.compile(r"/private/var/folders/"), + re.compile(r"/Volumes/(?!Example(?:/|\b))[^/\s]+/"), + re.compile(r"[A-Za-z]:\\Users\\(?!example(?:\\|\b))", re.IGNORECASE), +) +for path in sorted(set(paths)): + if path.suffix.lower() not in text_suffixes: + continue + try: + text = path.read_text(encoding="utf-8") + except UnicodeDecodeError: + continue + for line_number, line in enumerate(text.splitlines(), 1): + if any(pattern.search(line) for pattern in patterns): + relative = path.relative_to(root) + raise SystemExit(f"workstation-specific path in {relative}:{line_number}") +PY +then + fail "published text contains a workstation-specific path" +fi + +if command -v ruby >/dev/null 2>&1; then + if ! ruby - "$ROOT_DIR" <<'RUBY' +require "yaml" + +def validate_action_references(node, path) + case node + when Hash + node.each do |key, value| + if key.to_s == "uses" + reference = value.to_s + next if reference.start_with?("./") + if reference.start_with?("docker://") + unless reference.match?(/@sha256:[0-9a-f]{64}\z/i) + raise "Docker action is not pinned to an image digest in #{path}: #{reference}" + end + elsif !reference.match?(/@[0-9a-f]{40}\z/i) + raise "GitHub Action is not pinned to a full commit SHA in #{path}: #{reference}" + end + else + validate_action_references(value, path) + end + end + when Array + node.each { |value| validate_action_references(value, path) } + end +end + +def validate_permission_map(permissions, context) + unless permissions.is_a?(Hash) && !permissions.empty? + raise "workflow must declare an explicit permission map: #{context}" + end + unexpected = permissions.reject { |_scope, access| ["read", "none"].include?(access) } + unless unexpected.empty? + raise "workflow requests unexpected write permission: #{context}: #{unexpected.inspect}" + end +end + +root = ARGV.fetch(0) +paths = Dir[File.join(root, ".github", "**", "*.{yml,yaml}")].sort +paths.each do |path| + document = YAML.safe_load( + File.read(path, encoding: "UTF-8"), + permitted_classes: [], + permitted_symbols: [], + aliases: false + ) + next unless path.include?(File.join(".github", "workflows")) + + validate_permission_map(document["permissions"], path) + jobs = document["jobs"] + unless jobs.is_a?(Hash) && !jobs.empty? + raise "workflow must declare at least one job: #{path}" + end + jobs.each do |job_name, job| + next unless job.is_a?(Hash) && job.key?("permissions") + validate_permission_map(job["permissions"], "#{path} job #{job_name}") + end + validate_action_references(document, path) +end +RUBY + then + fail "GitHub YAML metadata is not syntactically valid" + fi +else + fail "Ruby is required to validate GitHub YAML metadata" +fi + +if ! python3 - "$ROOT_DIR" "${public_surface[@]}" <<'PY' +import pathlib +import re +import sys + +root = pathlib.Path(sys.argv[1]) +for relative in sys.argv[2:]: + path = root / relative + if not path.is_file(): + continue + text = path.read_text(encoding="utf-8") + for match in re.finditer(r"!?\[[^\]]*\]\(([^)]+)\)", text): + target = match.group(1).strip().strip("<>") + if not target or target.startswith(("http://", "https://", "mailto:", "#")): + continue + target = target.split("#", 1)[0] + resolved = (path.parent / target).resolve() + if not resolved.exists(): + raise SystemExit(f"broken local Markdown link in {relative}: {target}") +PY +then + fail "public documentation contains a broken local Markdown link" +fi + +while IFS= read -r workflow; do + [[ -s "$workflow" ]] || continue + grep -Eq '^permissions:' "$workflow" || fail "workflow must declare top-level permissions: ${workflow#"$ROOT_DIR/"}" +done < <(find "$ROOT_DIR/.github/workflows" -type f \( -name '*.yml' -o -name '*.yaml' \) -print | LC_ALL=C sort) + +manifest="$ROOT_DIR/docs/open-source/OPEN_SOURCE_MANIFEST.json" +if [[ -s "$manifest" ]]; then + if ! python3 - "$manifest" <<'PY' +import json +import pathlib +import sys + +path = sys.argv[1] +with open(path, encoding="utf-8") as handle: + manifest = json.load(handle) + +required = { + "schemaVersion", + "project", + "license", + "sourcePublication", + "productionDistribution", + "dependencies", + "security", +} +missing = sorted(required - manifest.keys()) +if missing: + raise SystemExit("missing keys: " + ", ".join(missing)) +if manifest["license"] != "Apache-2.0": + raise SystemExit("license must be Apache-2.0") +if not isinstance(manifest["dependencies"], list): + raise SystemExit("dependencies must be a list") +if manifest.get("sbom") != "docs/open-source/SBOM.spdx.json": + raise SystemExit("manifest SBOM pointer is invalid") +PY + then + fail "open-source manifest is invalid: docs/open-source/OPEN_SOURCE_MANIFEST.json" + fi +fi + +sbom="$ROOT_DIR/docs/open-source/SBOM.spdx.json" +if [[ -s "$sbom" ]]; then + if ! python3 - "$sbom" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + document = json.load(handle) +if document.get("spdxVersion") != "SPDX-2.3": + raise SystemExit("spdxVersion must be SPDX-2.3") +if document.get("dataLicense") != "CC0-1.0": + raise SystemExit("dataLicense must be CC0-1.0") +namespace = document.get("documentNamespace", "") +if not namespace.startswith("https://github.com/") or "/devscope/" not in namespace.lower(): + raise SystemExit("documentNamespace must identify the DevScope GitHub source document") +packages = document.get("packages") +if not isinstance(packages, list) or not packages: + raise SystemExit("SBOM must contain the DevScope package") +package = next((item for item in packages if item.get("name") == "DevScope"), None) +if ( + package is None + or package.get("versionInfo") != "0.1.0" + or package.get("licenseDeclared") != "Apache-2.0" + or package.get("licenseConcluded") != "Apache-2.0" +): + raise SystemExit("SBOM package identity or license is invalid") +relationships = document.get("relationships", []) +if not any( + relationship.get("spdxElementId") == "SPDXRef-DOCUMENT" + and relationship.get("relationshipType") == "DESCRIBES" + and relationship.get("relatedSpdxElement") == package.get("SPDXID") + for relationship in relationships +): + raise SystemExit("SBOM must describe the DevScope package") +PY + then + fail "SPDX SBOM is invalid: docs/open-source/SBOM.spdx.json" + fi +fi + +if [[ -d "$ROOT_DIR/docs" ]]; then + while IFS= read -r json_file; do + if ! python3 -m json.tool "$json_file" >/dev/null; then + fail "JSON document is invalid: ${json_file#"$ROOT_DIR/"}" + fi + done < <(find "$ROOT_DIR/docs" -type f -name '*.json' -print | LC_ALL=C sort) +fi + +if [[ -d "$ROOT_DIR/.git" ]] || git -C "$ROOT_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + for relative_path in "${required_files[@]}"; do + if ! git -C "$ROOT_DIR" ls-files --error-unmatch -- "$relative_path" >/dev/null 2>&1; then + fail "required public-source file is not tracked by Git: $relative_path" + fi + done + tracked_artifacts="$(git -C "$ROOT_DIR" ls-files | grep -E '(^|/)(\.build|dist|DerivedData)(/|$)|(^|/)\.DS_Store$' || true)" + [[ -z "$tracked_artifacts" ]] || fail "generated artifacts are tracked: ${tracked_artifacts//$'\n'/, }" +fi + +if [[ "$SKIP_DEPENDENCY_CHECK" -eq 0 ]]; then + [[ -s "$ROOT_DIR/Package.swift" ]] || fail "Package.swift is required for dependency validation" + if [[ -s "$ROOT_DIR/Package.swift" ]]; then + dependency_json="$(mktemp)" + trap 'rm -f "$dependency_json"' EXIT + if swift package --package-path "$ROOT_DIR" show-dependencies --format json >"$dependency_json"; then + if ! python3 - \ + "$dependency_json" \ + "$ROOT_DIR/docs/open-source/OPEN_SOURCE_MANIFEST.json" \ + "$ROOT_DIR/docs/open-source/SBOM.spdx.json" \ + "$ROOT_DIR/THIRD_PARTY_NOTICES.md" <<'PY' +import json +import pathlib +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + package = json.load(handle) +actual_entries = package.get("dependencies") +if actual_entries is None: + raise SystemExit("dependency output omitted the dependencies field") +actual_identities = set() +pending = list(actual_entries) +while pending: + entry = pending.pop() + identity = (entry.get("identity") or entry.get("name") or "").lower() + if not identity: + raise SystemExit("dependency output contains an unidentified package") + actual_identities.add(identity) + pending.extend(entry.get("dependencies") or []) +actual = sorted(actual_identities) + +with open(sys.argv[2], encoding="utf-8") as handle: + manifest = json.load(handle) +declared_entries = manifest.get("dependencies") +if not isinstance(declared_entries, list): + raise SystemExit("manifest dependencies must be a list") +declared = [] +for entry in declared_entries: + identity = entry if isinstance(entry, str) else entry.get("identity", "") + if not identity: + raise SystemExit("manifest dependency entry is missing identity") + declared.append(identity.lower()) +declared.sort() +if actual != declared: + raise SystemExit(f"dependency manifest mismatch: actual={actual}, declared={declared}") + +with open(sys.argv[3], encoding="utf-8") as handle: + sbom = json.load(handle) +sbom_dependencies = sorted( + entry.get("name", "").lower() + for entry in sbom.get("packages", []) + if entry.get("name") != "DevScope" +) +if actual != sbom_dependencies: + raise SystemExit(f"SBOM dependency mismatch: actual={actual}, sbom={sbom_dependencies}") + +notices = pathlib.Path(sys.argv[4]).read_text(encoding="utf-8").lower() +missing_notices = [identity for identity in actual if identity not in notices] +if missing_notices: + raise SystemExit("dependencies missing from third-party notices: " + ", ".join(missing_notices)) +PY + then + fail "Swift dependency declarations do not match the manifest, SBOM, and notices" + fi + else + fail "Swift dependency graph could not be generated" + fi + fi +fi + +if [[ "$failures" -ne 0 ]]; then + echo "Open-source readiness check failed with $failures issue(s)." >&2 + exit 1 +fi + +echo "Open-source readiness check passed." diff --git a/script/generate_app_icon.swift b/script/generate_app_icon.swift new file mode 100644 index 0000000..5d15599 --- /dev/null +++ b/script/generate_app_icon.swift @@ -0,0 +1,263 @@ +import AppKit +import Foundation + +let outputURL = URL( + fileURLWithPath: CommandLine.arguments.dropFirst().first ?? "dist/AppIcon.iconset") +let fileManager = FileManager.default +try? fileManager.removeItem(at: outputURL) +try fileManager.createDirectory(at: outputURL, withIntermediateDirectories: true) + +let sizes: [(name: String, points: CGFloat, scale: CGFloat)] = [ + ("icon_16x16.png", 16, 1), + ("icon_16x16@2x.png", 16, 2), + ("icon_32x32.png", 32, 1), + ("icon_32x32@2x.png", 32, 2), + ("icon_128x128.png", 128, 1), + ("icon_128x128@2x.png", 128, 2), + ("icon_256x256.png", 256, 1), + ("icon_256x256@2x.png", 256, 2), + ("icon_512x512.png", 512, 1), + ("icon_512x512@2x.png", 512, 2), +] + +func color(_ red: CGFloat, _ green: CGFloat, _ blue: CGFloat, _ alpha: CGFloat = 1) -> NSColor { + NSColor(calibratedRed: red / 255, green: green / 255, blue: blue / 255, alpha: alpha) +} + +func scaled(_ value: CGFloat, _ pixelSize: CGFloat) -> CGFloat { + value / 1024 * pixelSize +} + +func rect(_ x: CGFloat, _ y: CGFloat, _ width: CGFloat, _ height: CGFloat, _ pixelSize: CGFloat) + -> NSRect +{ + NSRect( + x: scaled(x, pixelSize), + y: scaled(y, pixelSize), + width: scaled(width, pixelSize), + height: scaled(height, pixelSize) + ) +} + +func point(_ x: CGFloat, _ y: CGFloat, _ pixelSize: CGFloat) -> NSPoint { + NSPoint(x: scaled(x, pixelSize), y: scaled(y, pixelSize)) +} + +func drawLine( + _ path: NSBezierPath, + from start: NSPoint, + to end: NSPoint +) { + path.move(to: start) + path.line(to: end) +} + +func drawIcon(pixelSize: CGFloat) throws -> NSBitmapImageRep { + let pixels = Int(pixelSize) + guard + let bitmap = NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: pixels, + pixelsHigh: pixels, + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: 0, + bitsPerPixel: 0 + ), let context = NSGraphicsContext(bitmapImageRep: bitmap) + else { + throw CocoaError(.fileWriteUnknown) + } + bitmap.size = NSSize(width: pixelSize, height: pixelSize) + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = context + defer { NSGraphicsContext.restoreGraphicsState() } + + let bounds = NSRect(x: 0, y: 0, width: pixelSize, height: pixelSize) + let background = NSBezierPath( + roundedRect: bounds, + xRadius: scaled(224, pixelSize), + yRadius: scaled(224, pixelSize) + ) + color(8, 10, 15).setFill() + background.fill() + + NSGradient(colors: [ + color(36, 50, 71), + color(18, 24, 33), + color(8, 10, 15), + ])?.draw(in: background, angle: -48) + + let topHighlight = NSBezierPath() + topHighlight.move(to: point(120, 244, pixelSize)) + topHighlight.curve( + to: point(904, 240, pixelSize), + controlPoint1: point(186, 106, pixelSize), + controlPoint2: point(778, 88, pixelSize) + ) + topHighlight.line(to: point(904, 202, pixelSize)) + topHighlight.curve( + to: point(120, 218, pixelSize), + controlPoint1: point(768, 68, pixelSize), + controlPoint2: point(246, 72, pixelSize) + ) + topHighlight.close() + color(255, 255, 255, 0.075).setFill() + topHighlight.fill() + + let panel = NSBezierPath( + roundedRect: rect(142, 158, 740, 708, pixelSize), + xRadius: scaled(116, pixelSize), + yRadius: scaled(116, pixelSize) + ) + NSGraphicsContext.current?.saveGraphicsState() + let panelShadow = NSShadow() + panelShadow.shadowColor = color(0, 0, 0, 0.35) + panelShadow.shadowBlurRadius = scaled(36, pixelSize) + panelShadow.shadowOffset = NSSize(width: 0, height: -scaled(28, pixelSize)) + panelShadow.set() + color(16, 21, 31, 0.96).setFill() + panel.fill() + NSGraphicsContext.current?.restoreGraphicsState() + + NSGradient(colors: [ + color(36, 48, 68, 0.96), + color(16, 21, 31, 0.96), + ])?.draw(in: panel, angle: -52) + + color(255, 255, 255, 0.12).setStroke() + panel.lineWidth = max(1, scaled(4, pixelSize)) + panel.stroke() + + let titleRule = NSBezierPath( + roundedRect: rect(205, 786, 614, 5, pixelSize), xRadius: scaled(2.5, pixelSize), + yRadius: scaled(2.5, pixelSize)) + color(255, 255, 255, 0.16).setFill() + titleRule.fill() + + for (x, dotColor) in [ + (251 as CGFloat, color(255, 69, 58)), + (308 as CGFloat, color(255, 214, 10)), + (365 as CGFloat, color(50, 215, 75)), + ] { + dotColor.setFill() + NSBezierPath(ovalIn: rect(x - 19, 719, 38, 38, pixelSize)).fill() + } + + let center = point(512, 510, pixelSize) + let ringRadius = scaled(232, pixelSize) + let ringWidth = max(1, scaled(58, pixelSize)) + let ringBase = NSBezierPath() + ringBase.appendArc(withCenter: center, radius: ringRadius, startAngle: 0, endAngle: 360) + ringBase.lineWidth = ringWidth + color(20, 125, 255).setStroke() + ringBase.stroke() + + let cyanArc = NSBezierPath() + cyanArc.appendArc(withCenter: center, radius: ringRadius, startAngle: 105, endAngle: 214) + cyanArc.lineCapStyle = .round + cyanArc.lineWidth = ringWidth + color(38, 217, 255).setStroke() + cyanArc.stroke() + + let greenArc = NSBezierPath() + greenArc.appendArc(withCenter: center, radius: ringRadius, startAngle: 294, endAngle: 24) + greenArc.lineCapStyle = .round + greenArc.lineWidth = ringWidth + color(31, 227, 107).setStroke() + greenArc.stroke() + + let tick = NSBezierPath() + tick.lineCapStyle = .round + tick.lineWidth = max(1, scaled(44, pixelSize)) + drawLine(tick, from: point(512, 748, pixelSize), to: point(512, 630, pixelSize)) + drawLine(tick, from: point(512, 390, pixelSize), to: point(512, 272, pixelSize)) + drawLine(tick, from: point(274, 510, pixelSize), to: point(392, 510, pixelSize)) + drawLine(tick, from: point(632, 510, pixelSize), to: point(750, 510, pixelSize)) + color(247, 251, 255, 0.9).setStroke() + tick.stroke() + + let core = NSBezierPath(ovalIn: rect(394, 392, 236, 236, pixelSize)) + color(10, 17, 27).setFill() + core.fill() + color(255, 255, 255, 0.16).setStroke() + core.lineWidth = max(1, scaled(6, pixelSize)) + core.stroke() + + let chevron = NSBezierPath() + chevron.lineCapStyle = .round + chevron.lineJoinStyle = .round + chevron.lineWidth = max(2, scaled(42, pixelSize)) + chevron.move(to: point(440, 550, pixelSize)) + chevron.line(to: point(390, 510, pixelSize)) + chevron.line(to: point(440, 470, pixelSize)) + color(247, 251, 255).setStroke() + chevron.stroke() + + let prompt = NSBezierPath() + prompt.lineCapStyle = .round + prompt.lineWidth = max(2, scaled(42, pixelSize)) + drawLine(prompt, from: point(535, 558, pixelSize), to: point(627, 558, pixelSize)) + color(38, 217, 255).setStroke() + prompt.stroke() + + let cursor = NSBezierPath() + cursor.lineCapStyle = .round + cursor.lineWidth = max(2, scaled(42, pixelSize)) + drawLine(cursor, from: point(535, 462, pixelSize), to: point(661, 462, pixelSize)) + color(31, 227, 107).setStroke() + cursor.stroke() + + let waveform = NSBezierPath() + waveform.lineCapStyle = .round + waveform.lineJoinStyle = .round + waveform.lineWidth = max(2, scaled(28, pixelSize)) + waveform.move(to: point(284, 314, pixelSize)) + for (x, y) in [ + (338 as CGFloat, 348 as CGFloat), + (386, 304), + (432, 376), + (484, 346), + (540, 432), + (592, 316), + (640, 358), + (690, 336), + (740, 394), + ] { + waveform.line(to: point(x, y, pixelSize)) + } + color(255, 176, 32).setStroke() + waveform.stroke() + + let waveformAccent = NSBezierPath() + waveformAccent.lineCapStyle = .round + waveformAccent.lineJoinStyle = .round + waveformAccent.lineWidth = max(2, scaled(16, pixelSize)) + waveformAccent.move(to: point(540, 432, pixelSize)) + waveformAccent.line(to: point(592, 316, pixelSize)) + waveformAccent.line(to: point(640, 358, pixelSize)) + waveformAccent.line(to: point(690, 336, pixelSize)) + waveformAccent.line(to: point(740, 394, pixelSize)) + color(31, 227, 107).setStroke() + waveformAccent.stroke() + + let baseline = NSBezierPath() + baseline.lineCapStyle = .round + baseline.lineWidth = max(1, scaled(12, pixelSize)) + drawLine(baseline, from: point(284, 266, pixelSize), to: point(740, 266, pixelSize)) + color(255, 255, 255, 0.18).setStroke() + baseline.stroke() + + return bitmap +} + +for size in sizes { + let pixels = size.points * size.scale + let bitmap = try drawIcon(pixelSize: pixels) + guard let png = bitmap.representation(using: .png, properties: [:]) else { + throw CocoaError(.fileWriteUnknown) + } + try png.write(to: outputURL.appendingPathComponent(size.name)) +} diff --git a/script/install_local_full_build.sh b/script/install_local_full_build.sh new file mode 100755 index 0000000..6df0e17 --- /dev/null +++ b/script/install_local_full_build.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DIST_DIR="${DEVSCOPE_DIST_DIR:-$ROOT_DIR/dist/local-full}" +APP_BUNDLE="$DIST_DIR/DevScope.app" +TARGET="${DEVSCOPE_INSTALL_TARGET:-/Applications/DevScope.app}" + +DEVSCOPE_DIST_DIR="$DIST_DIR" \ +DEVSCOPE_ENTITLEMENTS="$ROOT_DIR/config/DevScopeDeveloperID.entitlements" \ +DEVSCOPE_REQUIRE_SANDBOX=0 \ +"$ROOT_DIR/script/build_release_bundle.sh" >/dev/null + +DEVSCOPE_REQUIRE_SANDBOX=0 \ +DEVSCOPE_REQUIRE_APPLE_EVENTS=1 \ +"$ROOT_DIR/script/validate_release_bundle.sh" "$APP_BUNDLE" + +osascript -e 'tell application "DevScope" to quit' >/dev/null 2>&1 || true +sleep 1 +pkill -x DevScope >/dev/null 2>&1 || true + +if [[ -e "$TARGET" ]]; then + mkdir -p "$HOME/.Trash" + mv "$TARGET" "$HOME/.Trash/DevScope.app.$(date +%Y%m%d-%H%M%S)" +fi + +ditto "$APP_BUNDLE" "$TARGET" +xattr -c "$TARGET" 2>/dev/null || true +codesign --verify --deep --strict --verbose=2 "$TARGET" +open -n "$TARGET" + +cat <&2 + exit 2 + fi +} + +require_env DEVSCOPE_BUNDLE_ID +require_env DEVSCOPE_MARKETING_VERSION +require_env DEVSCOPE_BUILD_VERSION +require_env DEVSCOPE_APP_STORE_SIGN_IDENTITY +require_env DEVSCOPE_INSTALLER_IDENTITY +require_env DEVSCOPE_PROVISIONING_PROFILE + +if ! security find-identity -p codesigning -v | grep -F "$DEVSCOPE_APP_STORE_SIGN_IDENTITY" >/dev/null; then + echo "error: App Store application signing identity not found: $DEVSCOPE_APP_STORE_SIGN_IDENTITY" >&2 + security find-identity -p codesigning -v >&2 + exit 2 +fi + +if ! security find-identity -v | grep -F "$DEVSCOPE_INSTALLER_IDENTITY" >/dev/null; then + echo "error: App Store installer signing identity not found: $DEVSCOPE_INSTALLER_IDENTITY" >&2 + security find-identity -v >&2 + exit 2 +fi + +mkdir -p "$DIST_DIR" + +security cms -D -i "$DEVSCOPE_PROVISIONING_PROFILE" -o "$PROFILE_PLIST" >/dev/null \ + || { echo "error: provisioning profile could not be decoded" >&2; exit 2; } +PROFILE_TEAM="$(plutil -extract TeamIdentifier.0 raw -o - "$PROFILE_PLIST")" +PROFILE_PREFIX="$(plutil -extract ApplicationIdentifierPrefix.0 raw -o - "$PROFILE_PLIST")" +PROFILE_APP_ID="$(plutil -extract 'Entitlements.com\.apple\.application-identifier' raw -o - "$PROFILE_PLIST")" +if [[ "$PROFILE_APP_ID" != "$PROFILE_PREFIX.$DEVSCOPE_BUNDLE_ID" ]]; then + echo "error: provisioning profile application identifier does not match DEVSCOPE_BUNDLE_ID" >&2 + exit 2 +fi + +cp "$ROOT_DIR/config/DevScope.entitlements" "$APP_STORE_ENTITLEMENTS" +/usr/libexec/PlistBuddy -c "Add :com.apple.application-identifier string $PROFILE_APP_ID" "$APP_STORE_ENTITLEMENTS" +/usr/libexec/PlistBuddy -c "Add :com.apple.developer.team-identifier string $PROFILE_TEAM" "$APP_STORE_ENTITLEMENTS" + +DEVSCOPE_DIST_DIR="$DIST_DIR" \ +DEVSCOPE_SIGN_IDENTITY="$DEVSCOPE_APP_STORE_SIGN_IDENTITY" \ +DEVSCOPE_ENTITLEMENTS="$APP_STORE_ENTITLEMENTS" \ +DEVSCOPE_PROVISIONING_PROFILE="$DEVSCOPE_PROVISIONING_PROFILE" \ +"$ROOT_DIR/script/build_release_bundle.sh" >/dev/null + +DEVSCOPE_REQUIRE_SANDBOX=1 \ +DEVSCOPE_REQUIRE_PROVISIONING_PROFILE=1 \ +"$ROOT_DIR/script/validate_release_bundle.sh" "$APP_BUNDLE" + +rm -f "$PKG_PATH" +productbuild --component "$APP_BUNDLE" /Applications --sign "$DEVSCOPE_INSTALLER_IDENTITY" "$PKG_PATH" +pkgutil --check-signature "$PKG_PATH" + +cat <&2 <<'ERROR' +error: community preview packaging requires explicit acknowledgement + +Set DEVSCOPE_ACKNOWLEDGE_UNNOTARIZED_PREVIEW=1 only when publishing a clearly +labeled, ad-hoc signed and unnotarized prerelease. This is not a production +Developer ID distribution artifact. +ERROR + exit 2 +fi + +if [[ ! "$MARKETING_VERSION" =~ ^[0-9]+([.][0-9]+){1,2}$ ]]; then + echo "error: invalid DEVSCOPE_MARKETING_VERSION: $MARKETING_VERSION" >&2 + exit 2 +fi + +if [[ ! "$PREVIEW_LABEL" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then + echo "error: invalid DEVSCOPE_PREVIEW_LABEL: $PREVIEW_LABEL" >&2 + exit 2 +fi + +SOURCE_COMMIT="${DEVSCOPE_SOURCE_COMMIT:-$(git -C "$ROOT_DIR" rev-parse HEAD)}" +if [[ ! "$SOURCE_COMMIT" =~ ^[0-9a-f]{40}$ ]]; then + echo "error: DEVSCOPE_SOURCE_COMMIT must be a full 40-character Git SHA" >&2 + exit 2 +fi + +mkdir -p "$DIST_DIR" +TEMP_ROOT="$(mktemp -d "$DIST_DIR/.community-preview.XXXXXX")" +cleanup() { + if [[ -d "$TEMP_ROOT" ]]; then + find "$TEMP_ROOT" -depth -delete + fi +} +trap cleanup EXIT + +PACKAGE_NAME="DevScope-$MARKETING_VERSION-$PREVIEW_LABEL-macos-universal" +PACKAGE_ROOT="$TEMP_ROOT/$PACKAGE_NAME" +APP_BUNDLE="$PACKAGE_ROOT/DevScope.app" +ZIP_PATH="$DIST_DIR/$PACKAGE_NAME.zip" +CHECKSUM_PATH="$ZIP_PATH.sha256" +mkdir -p "$PACKAGE_ROOT" + +DEVSCOPE_DIST_DIR="$PACKAGE_ROOT" \ +DEVSCOPE_SIGN_IDENTITY="-" \ +DEVSCOPE_ENTITLEMENTS="$ROOT_DIR/config/DevScopeDeveloperID.entitlements" \ +DEVSCOPE_HARDENED_RUNTIME=1 \ +DEVSCOPE_MARKETING_VERSION="$MARKETING_VERSION" \ +"$ROOT_DIR/script/build_release_bundle.sh" >/dev/null + +DEVSCOPE_REQUIRE_SANDBOX=0 \ +DEVSCOPE_REQUIRE_APPLE_EVENTS=1 \ +"$ROOT_DIR/script/validate_release_bundle.sh" "$APP_BUNDLE" + +cat >"$PACKAGE_ROOT/UNNOTARIZED_COMMUNITY_PREVIEW.txt" <&2 + exit 1 +fi +grep -Fq "$PACKAGE_NAME/DevScope.app/Contents/MacOS/DevScope" <<<"$ARCHIVE_ENTRIES" \ + || { echo "error: preview archive is missing DevScope.app" >&2; exit 1; } +grep -Fq "$PACKAGE_NAME/UNNOTARIZED_COMMUNITY_PREVIEW.txt" <<<"$ARCHIVE_ENTRIES" \ + || { echo "error: preview archive is missing its trust warning" >&2; exit 1; } + +ARCHIVE_NAME="$(basename "$ZIP_PATH")" +ARCHIVE_SHA256="$(shasum -a 256 "$ZIP_PATH" | awk '{print $1}')" +printf '%s %s\n' "$ARCHIVE_SHA256" "$ARCHIVE_NAME" >"$CHECKSUM_PATH" + +printf 'Community preview archive: %s\n' "$ZIP_PATH" >&2 +printf 'SHA-256 manifest: %s\n' "$CHECKSUM_PATH" >&2 +printf '%s\n' "$ZIP_PATH" diff --git a/script/package_developer_id.sh b/script/package_developer_id.sh new file mode 100755 index 0000000..b89a69b --- /dev/null +++ b/script/package_developer_id.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DIST_DIR="${DEVSCOPE_DIST_DIR:-$ROOT_DIR/dist/developer-id}" +APP_BUNDLE="$DIST_DIR/DevScope.app" +ZIP_PATH="$DIST_DIR/DevScope.zip" +NOTARY_PROFILE="${DEVSCOPE_NOTARY_KEYCHAIN_PROFILE:-}" +ALLOW_UNNOTARIZED="${DEVSCOPE_ALLOW_UNNOTARIZED:-0}" + +if [[ -z "${DEVSCOPE_DEVELOPER_ID_SIGN_IDENTITY:-}" ]]; then + echo "error: DEVSCOPE_DEVELOPER_ID_SIGN_IDENTITY is required" >&2 + exit 2 +fi + +if ! security find-identity -p codesigning -v | grep -F "$DEVSCOPE_DEVELOPER_ID_SIGN_IDENTITY" >/dev/null; then + echo "error: Developer ID signing identity not found: $DEVSCOPE_DEVELOPER_ID_SIGN_IDENTITY" >&2 + security find-identity -p codesigning -v >&2 + exit 2 +fi + +if [[ -z "$NOTARY_PROFILE" && "$ALLOW_UNNOTARIZED" != "1" ]]; then + echo "error: notary profile is required for a release artifact; set DEVSCOPE_ALLOW_UNNOTARIZED=1 only for local development" >&2 + exit 2 +fi + +mkdir -p "$DIST_DIR" + +DEVSCOPE_DIST_DIR="$DIST_DIR" \ +DEVSCOPE_SIGN_IDENTITY="$DEVSCOPE_DEVELOPER_ID_SIGN_IDENTITY" \ +DEVSCOPE_ENTITLEMENTS="$ROOT_DIR/config/DevScopeDeveloperID.entitlements" \ +DEVSCOPE_HARDENED_RUNTIME=1 \ +"$ROOT_DIR/script/build_release_bundle.sh" >/dev/null + +DEVSCOPE_REQUIRE_SANDBOX=0 \ +DEVSCOPE_REQUIRE_APPLE_EVENTS=1 \ +"$ROOT_DIR/script/validate_release_bundle.sh" "$APP_BUNDLE" + +rm -f "$ZIP_PATH" +ditto -c -k --keepParent --norsrc --noextattr "$APP_BUNDLE" "$ZIP_PATH" + +if [[ -n "$NOTARY_PROFILE" ]]; then + xcrun notarytool submit "$ZIP_PATH" --keychain-profile "$NOTARY_PROFILE" --wait + xcrun stapler staple "$APP_BUNDLE" + spctl -a -t exec -vv "$APP_BUNDLE" + rm -f "$ZIP_PATH" + ditto -c -k --keepParent --norsrc --noextattr "$APP_BUNDLE" "$ZIP_PATH" +else + cat </dev/null 2>&1 || true +open -n "$APP_BUNDLE" +sleep 2 +pgrep -x "DevScope" >/dev/null +osascript -e 'tell application "DevScope" to quit' >/dev/null 2>&1 || true + +echo "Sandbox smoke passed: $APP_BUNDLE" diff --git a/script/validate_release_bundle.sh b/script/validate_release_bundle.sh new file mode 100755 index 0000000..6f3fcc9 --- /dev/null +++ b/script/validate_release_bundle.sh @@ -0,0 +1,166 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +APP_BUNDLE="${1:-$ROOT_DIR/dist/DevScope.app}" +APP_NAME="$(basename "$APP_BUNDLE" .app)" +APP_CONTENTS="$APP_BUNDLE/Contents" +APP_BINARY="$APP_CONTENTS/MacOS/$APP_NAME" +APP_RESOURCES="$APP_CONTENTS/Resources" +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +fail() { + echo "error: $*" >&2 + exit 1 +} + +[[ -d "$APP_BUNDLE" ]] || fail "app bundle not found: $APP_BUNDLE" +[[ -x "$APP_BINARY" ]] || fail "main executable missing or not executable: $APP_BINARY" +[[ -f "$APP_CONTENTS/Info.plist" ]] || fail "Info.plist missing" +[[ -f "$APP_RESOURCES/AppIcon.icns" ]] || fail "AppIcon.icns missing" +[[ -f "$APP_RESOURCES/PrivacyInfo.xcprivacy" ]] || fail "PrivacyInfo.xcprivacy missing" +for notice in LICENSE NOTICE THIRD_PARTY_NOTICES.md; do + [[ -s "$APP_RESOURCES/$notice" ]] || fail "$notice missing or empty" +done + +plutil -lint "$APP_CONTENTS/Info.plist" >/dev/null +plutil -lint "$APP_RESOURCES/PrivacyInfo.xcprivacy" >/dev/null + +if [[ "${DEVSCOPE_REQUIRE_PRIVACY_MANIFEST:-1}" == "1" ]]; then + PRIVACY_MANIFEST="$(plutil -convert xml1 -o - "$APP_RESOURCES/PrivacyInfo.xcprivacy")" + if ! grep -q "NSPrivacyAccessedAPICategoryUserDefaults" <<<"$PRIVACY_MANIFEST"; then + fail "PrivacyInfo.xcprivacy missing UserDefaults required-reason API declaration" + fi + + if ! grep -q "CA92.1" <<<"$PRIVACY_MANIFEST"; then + fail "PrivacyInfo.xcprivacy missing UserDefaults reason CA92.1" + fi +fi + +BUNDLE_ID="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$APP_CONTENTS/Info.plist")" +VERSION="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$APP_CONTENTS/Info.plist")" +BUILD="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$APP_CONTENTS/Info.plist")" +ARCHITECTURES="$(lipo -archs "$APP_BINARY")" + +[[ -n "$BUNDLE_ID" ]] || fail "CFBundleIdentifier is empty" +[[ -n "$VERSION" ]] || fail "CFBundleShortVersionString is empty" +[[ -n "$BUILD" ]] || fail "CFBundleVersion is empty" + +for requiredArchitecture in ${DEVSCOPE_REQUIRED_ARCHITECTURES-arm64 x86_64}; do + if [[ " $ARCHITECTURES " != *" $requiredArchitecture "* ]]; then + fail "required architecture missing: $requiredArchitecture (found: $ARCHITECTURES)" + fi +done + +codesign --verify --deep --strict --verbose=4 "$APP_BUNDLE" +codesign -dvvv --xml --entitlements - "$APP_BUNDLE" >"$TMP_DIR/entitlements.txt" 2>"$TMP_DIR/codesign.txt" + +if [[ "${DEVSCOPE_REQUIRE_SANDBOX:-1}" == "1" ]]; then + if ! SANDBOX_VALUE="$( + plutil -extract 'com\.apple\.security\.app-sandbox' raw -o - "$TMP_DIR/entitlements.txt" 2>/dev/null + )"; then + cat "$TMP_DIR/entitlements.txt" >&2 + fail "App Sandbox entitlement missing" + fi + + if [[ "$SANDBOX_VALUE" != "true" ]]; then + cat "$TMP_DIR/entitlements.txt" >&2 + fail "App Sandbox entitlement is not true" + fi + + if ! USER_SELECTED_READ_WRITE_VALUE="$( + plutil -extract 'com\.apple\.security\.files\.user-selected\.read-write' raw -o - \ + "$TMP_DIR/entitlements.txt" 2>/dev/null + )"; then + cat "$TMP_DIR/entitlements.txt" >&2 + fail "user-selected read-write entitlement missing for sandbox file import/export" + fi + if [[ "$USER_SELECTED_READ_WRITE_VALUE" != "true" ]]; then + cat "$TMP_DIR/entitlements.txt" >&2 + fail "user-selected read-write entitlement is not true" + fi +elif grep -q "com.apple.security.app-sandbox" "$TMP_DIR/entitlements.txt"; then + cat "$TMP_DIR/entitlements.txt" >&2 + fail "App Sandbox entitlement must be absent for the full process-control build" +fi + +if [[ "${DEVSCOPE_REQUIRE_APPLE_EVENTS:-0}" == "1" ]]; then + if ! APPLE_EVENTS_VALUE="$( + plutil -extract 'com\.apple\.security\.automation\.apple-events' raw -o - \ + "$TMP_DIR/entitlements.txt" 2>/dev/null + )"; then + cat "$TMP_DIR/entitlements.txt" >&2 + fail "Apple Events entitlement missing for legacy login-item integration" + fi + [[ "$APPLE_EVENTS_VALUE" == "true" ]] \ + || fail "Apple Events entitlement is not true" + APPLE_EVENTS_USAGE="$( + plutil -extract NSAppleEventsUsageDescription raw -o - "$APP_CONTENTS/Info.plist" 2>/dev/null + )" || fail "NSAppleEventsUsageDescription missing" + [[ -n "$APPLE_EVENTS_USAGE" ]] || fail "NSAppleEventsUsageDescription is empty" +fi + +if [[ "${DEVSCOPE_REQUIRE_PROVISIONING_PROFILE:-0}" == "1" ]]; then + PROFILE="$APP_CONTENTS/embedded.provisionprofile" + PROFILE_PLIST="$TMP_DIR/profile.plist" + [[ -f "$PROFILE" ]] || fail "embedded.provisionprofile missing" + security cms -D -i "$PROFILE" -o "$PROFILE_PLIST" >/dev/null \ + || fail "embedded.provisionprofile could not be decoded" + plutil -lint "$PROFILE_PLIST" >/dev/null \ + || fail "embedded.provisionprofile payload is invalid" + + PROFILE_EXPIRATION="$(plutil -extract ExpirationDate raw -o - "$PROFILE_PLIST")" + PROFILE_EXPIRATION_EPOCH="$(date -j -u -f '%Y-%m-%dT%H:%M:%SZ' "$PROFILE_EXPIRATION" +%s 2>/dev/null)" \ + || fail "embedded.provisionprofile ExpirationDate is invalid" + [[ "$PROFILE_EXPIRATION_EPOCH" -gt "$(date -u +%s)" ]] \ + || fail "embedded.provisionprofile is expired" + + PROFILE_TEAM="$(plutil -extract TeamIdentifier.0 raw -o - "$PROFILE_PLIST")" + PROFILE_PREFIX="$(plutil -extract ApplicationIdentifierPrefix.0 raw -o - "$PROFILE_PLIST")" + PROFILE_APP_ID="$(plutil -extract 'Entitlements.com\.apple\.application-identifier' raw -o - "$PROFILE_PLIST")" + PROFILE_SANDBOX="$(plutil -extract 'Entitlements.com\.apple\.security\.app-sandbox' raw -o - "$PROFILE_PLIST")" + APP_TEAM="$(plutil -extract 'com\.apple\.developer\.team-identifier' raw -o - "$TMP_DIR/entitlements.txt")" + APP_ID="$(plutil -extract 'com\.apple\.application-identifier' raw -o - "$TMP_DIR/entitlements.txt")" + + [[ "$PROFILE_APP_ID" == "$PROFILE_PREFIX.$BUNDLE_ID" ]] \ + || fail "embedded.provisionprofile application-identifier does not match bundle ID" + [[ "$APP_ID" == "$PROFILE_APP_ID" ]] \ + || fail "signed application-identifier does not match embedded.provisionprofile" + [[ "$APP_TEAM" == "$PROFILE_TEAM" ]] \ + || fail "signed TeamIdentifier does not match embedded.provisionprofile" + [[ "$PROFILE_SANDBOX" == "true" ]] \ + || fail "embedded profile App Sandbox entitlement is not true" + + codesign -d --extract-certificates "$TMP_DIR/app-signing-cert" "$APP_BUNDLE" 2>/dev/null \ + || fail "app signing certificate could not be extracted" + PROFILE_CERTIFICATE_MATCH=0 + PROFILE_CERTIFICATE_INDEX=0 + while PROFILE_CERTIFICATE="$({ + plutil -extract "DeveloperCertificates.$PROFILE_CERTIFICATE_INDEX" raw -o - "$PROFILE_PLIST" + } 2>/dev/null)"; do + printf '%s' "$PROFILE_CERTIFICATE" | base64 -D >"$TMP_DIR/profile-cert" \ + || fail "embedded.provisionprofile contains an invalid developer certificate" + if cmp -s "$TMP_DIR/app-signing-cert0" "$TMP_DIR/profile-cert"; then + PROFILE_CERTIFICATE_MATCH=1 + break + fi + PROFILE_CERTIFICATE_INDEX=$((PROFILE_CERTIFICATE_INDEX + 1)) + done + [[ "$PROFILE_CERTIFICATE_MATCH" == "1" ]] \ + || fail "app signing certificate is not authorized by embedded.provisionprofile" +fi + +if [[ "${DEVSCOPE_REQUIRE_GATEKEEPER:-0}" == "1" ]]; then + spctl -a -t exec -vv "$APP_BUNDLE" +else + spctl -a -t exec -vv "$APP_BUNDLE" >/dev/null 2>&1 || true +fi + +cat <