diff --git a/src/domain/apple/appleApplication.ts b/src/domain/apple/appleApplication.ts index 8c013dc7d2..a5b06d0dd3 100644 --- a/src/domain/apple/appleApplication.ts +++ b/src/domain/apple/appleApplication.ts @@ -241,6 +241,14 @@ const appleRootFormat = (format: string): RootFormat => { const isNativeLibrary = (path: string): boolean => /\.(?:dylib|so)$/iu.test(path); +const launchdDomain = (path: string): "agent" | "daemon" | undefined => { + if (anatomyTailMatches(path, ["Contents", "Library", "LaunchAgents", "*"])) + return "agent"; + if (anatomyTailMatches(path, ["Contents", "Library", "LaunchDaemons", "*"])) + return "daemon"; + return undefined; +}; + const classifyComponents = ( entries: readonly ProjectedInventoryEntry[], all: readonly Component[], @@ -280,29 +288,12 @@ const classifyComponents = ( privileged_helpers: matching((path) => anatomyTailMatches(path, ["Contents", "Library", "LaunchServices", "*"]), ), - launchd_plists: matching( - (path) => - path.endsWith(".plist") && - (anatomyTailMatches(path, [ - "Contents", - "Library", - "LaunchAgents", - "*", - ]) || - anatomyTailMatches(path, [ - "Contents", - "Library", - "LaunchDaemons", - "*", - ])), - ).map((component) => ({ - ...component, - domain: component.path - .split("/") - .some((segment) => anatomyNameIs(segment, "LaunchDaemons")) - ? ("daemon" as const) - : ("agent" as const), - })), + launchd_plists: matching((path) => path.endsWith(".plist")).flatMap( + (component) => { + const domain = launchdDomain(component.path); + return domain === undefined ? [] : [{ ...component, domain }]; + }, + ), helpers: matching((path) => anatomyTailMatches(path, ["Contents", "Helpers", "*"]), ), diff --git a/tests/acceptance/applications/appleLaunchdPlists.test.ts b/tests/acceptance/applications/appleLaunchdPlists.test.ts new file mode 100644 index 0000000000..c319377ef6 --- /dev/null +++ b/tests/acceptance/applications/appleLaunchdPlists.test.ts @@ -0,0 +1,93 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js"; +import { expect } from "vitest"; + +import { appleApplicationProjectionResultSchema } from "../../../src/domain/apple/appleApplication.js"; +import { compareUnicodeCodePoints } from "../../../src/domain/unicodeCodePointOrder.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest( + "Apple launchd domains follow their owning directory through CLI and MCP", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-apple-launchd-cli-"); + const environment = { HOME: root, USERPROFILE: root }; + const expected = [ + [ + "LaunchDaemons/Sample.app/Contents/Library/LaunchAgents/agent.plist", + "agent", + ], + [ + "LaunchDaemons/Sample.app/Contents/Resources/LaunchDaemons/Nested.app/Contents/Library/LaunchAgents/nested.plist", + "agent", + ], + [ + "LaunchAgents/Other.app/Contents/Library/LaunchDaemons/daemon.plist", + "daemon", + ], + [ + "launchdaemons/Case.app/contents/library/launchagents/mixed.plist", + "agent", + ], + ] as const; + const writer = new ZipWriter(new Uint8ArrayWriter()); + for (const [path] of expected) + await writer.add(path, new TextReader("")); + for (const path of [ + "LaunchDaemons/Sample.app/Contents/Library/LaunchAgents/nested/ignored.plist", + "LaunchDaemons/Sample.app/Contents/Library/LaunchAgents/not-a-plist", + "Contents/Library/LaunchAgents/unrooted.plist", + "__MACOSX/Sample.app/Contents/Library/LaunchAgents/sidecar.plist", + ]) + await writer.add(path, new TextReader("")); + await writer.add( + "LaunchDaemons/Sample.app/Contents/Library/LaunchAgents/directory.plist/", + undefined, + { directory: true }, + ); + const path = join(root, "Launchd.zip"); + await writeFile(path, await writer.close()); + const inspected = await cli.run({ + arguments: ["inspect-artifact", path, "--json"], + environment, + }); + expect(inspected.exitCode, inspected.stderr).toBe(0); + const source = parseEvidence(inspected.json); + const input = { inventory_evidence: [source] }; + const projected = await cli.run({ + arguments: [ + "project-apple-application-graph", + JSON.stringify(input), + "--json", + ], + environment, + }); + expect(projected.exitCode, projected.stderr).toBe(0); + const evidence = parseEvidence(projected.json); + const result = appleApplicationProjectionResultSchema.parse( + evidence.normalized_result, + ); + expect( + result.components.launchd_plists.map(({ path, domain }) => [ + path, + domain, + ]), + ).toEqual( + [...expected].sort(([left], [right]) => + compareUnicodeCodePoints(left, right), + ), + ); + expect(result.coverage).toEqual({ + status: "complete-within-inventory", + inventory_complete: true, + }); + const { call } = await connectLocalToolsMcp(environment); + const mcp = await call("project_apple_application_graph", input); + expect(mcp.isError).not.toBe(true); + expect(parseEvidence(mcp.structuredContent)).toEqual(evidence); + }, +); diff --git a/tests/boundary/filesystem/appleApplication.test.ts b/tests/boundary/filesystem/appleApplication.test.ts index c83dcaf589..94f4daa85c 100644 --- a/tests/boundary/filesystem/appleApplication.test.ts +++ b/tests/boundary/filesystem/appleApplication.test.ts @@ -318,6 +318,57 @@ it("applies application-root and sidecar rules to unread entries", async () => { }); describe("macOS application projection", () => { + it("classifies launchd plists by their owning directory instead of unrelated ancestors", async () => { + const root = await createTestTempDirectory("rea-apple-launchd-domain-"); + const path = join(root, "Launchd.zip"); + const expected = [ + [ + "LaunchDaemons/Sample.app/Contents/Library/LaunchAgents/agent.plist", + "agent", + ], + [ + "LaunchDaemons/Sample.app/Contents/Resources/LaunchDaemons/Nested.app/Contents/Library/LaunchAgents/nested.plist", + "agent", + ], + [ + "LaunchAgents/Other.app/Contents/Library/LaunchDaemons/daemon.plist", + "daemon", + ], + [ + "launchdaemons/Case.app/contents/library/launchagents/mixed.plist", + "agent", + ], + ] as const; + const writer = new ZipWriter(new Uint8ArrayWriter()); + for (const [entry] of expected) + await writer.add(entry, new TextReader("")); + await writer.add( + "LaunchDaemons/Sample.app/Contents/Library/LaunchAgents/nested/ignored.plist", + new TextReader(""), + ); + await writeFile(path, await writer.close()); + + const inventory = parseEvidence( + await runProviderAnalysis(path, "inventory_artifact", {}), + ); + const projection = appleApplicationProjectionResultSchema.parse( + requireSuccessfulProjection( + projectAppleApplicationEvidence({ inventory_evidence: [inventory] }), + ).normalized_result, + ); + expect( + projection.components.launchd_plists.map(({ path, domain }) => [ + path, + domain, + ]), + ).toEqual(expect.arrayContaining([...expected])); + expect(projection.components.launchd_plists).toHaveLength(expected.length); + expect(projection.coverage).toEqual({ + status: "complete-within-inventory", + inventory_complete: true, + }); + }); + it("projects a macOS .app directory inventory with nested bundle roles", async () => { const root = await createTestTempDirectory("rea-apple-macos-"); const app = join(root, "Fixture.app");