Merge pull request #931 from morluto/fix/apple-bridge-roots

fix(apple): pair bridge hypotheses within one application root
This commit is contained in:
Kaoru0822 2026-10-07 05:41:08 -07:00 committed by GitHub
commit 7acf4e741a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 96 additions and 15 deletions

View File

@ -96,9 +96,11 @@ The components also list:
Symlinks are reported by path in `symlinks`. Their targets are not inventoried,
so a versioned framework is described through its real `Versions/<version>/`
directories rather than `Versions/Current`. When a framework has zero or several
`Versions/*/Resources/Info.plist` files, its `info_plist_path` is `null` and a
limitation explains why.
directories rather than `Versions/Current`. A framework's `info_plist_path` is
`null`, with a limitation explaining why, when it has more than one real
`Versions/<version>/` directory (even if only one of them holds a plist,
because `Versions/Current` decides which one applies) or when its single
version directory has no `Resources/Info.plist`.
AppleDouble sidecar files (`._*` and `__MACOSX/`) are inventory facts, but they
describe neighbouring files. They are excluded from roots, bundle roles, and the

View File

@ -309,6 +309,29 @@ describe("Apple bundle layouts and containers", () => {
expect(
result.bundles.find(({ path }) => path === framework)?.info_plist_path,
).toBeNull();
// Files beside the only real version are not versions.
const withFiles = project(
inventoryEvidence("zip", "Fixture.zip", [
macho("Fixture.app/Contents/MacOS/Fixture"),
{ path: `${framework}/Versions/A/Resources/Info.plist` },
{ path: `${framework}/Versions/.DS_Store` },
{ path: `${framework}/Versions/Current`, kind: "symlink" },
]),
);
expect(
withFiles.bundles.find(({ path }) => path === framework)?.info_plist_path,
).toBe(`${framework}/Versions/A/Resources/Info.plist`);
// Only A has a plist, but Versions/Current may select B.
const onlyA = project(
inventoryEvidence("zip", "Fixture.zip", [
macho("Fixture.app/Contents/MacOS/Fixture"),
{ path: `${framework}/Versions/A/Resources/Info.plist` },
macho(`${framework}/Versions/B/Core`),
]),
);
expect(
onlyA.bundles.find(({ path }) => path === framework)?.info_plist_path,
).toBeNull();
expect(result.limitations).toContainEqual(
expect.stringContaining("Versions/Current selects is unknown"),
);
@ -438,3 +461,37 @@ describe("macOS application scope", () => {
});
});
});
describe("Apple application bridge candidates", () => {
it("pairs scripts and native code only within one application root", () => {
const result = project(
inventoryEvidence("zip", "Suite.zip", [
macho("First.app/Contents/MacOS/First"),
{
path: "First.app/Contents/Resources/first.js",
format: "javascript-bundle",
},
macho("Second.app/Contents/MacOS/Second"),
{
path: "Second.app/Contents/Resources/second.js",
format: "javascript-bundle",
},
]),
);
expect(result.application_roots).toEqual(["First.app", "Second.app"]);
expect(
result.bridge_candidates.map(
({ source_path: source, native_path: target }) => [source, target],
),
).toEqual([
[
"First.app/Contents/Resources/first.js",
"First.app/Contents/MacOS/First",
],
[
"Second.app/Contents/Resources/second.js",
"Second.app/Contents/MacOS/Second",
],
]);
});
});

View File

@ -149,6 +149,7 @@ export const projectAppleApplication = (
...classified.executables,
...classified.native_libraries,
]),
roots,
);
const uninventoriedImage =
rootFormat === "dmg" && inventory.occurrences.length === 1;
@ -305,18 +306,27 @@ const identifyRuntimeFamilies = (all: readonly Component[]) => {
return [...families].sort(compare);
};
/** Pair scripts and native code only within the same application root. */
const identifyBridgeCandidates = (
scripts: readonly Component[],
native: readonly Component[],
) => ({
candidates: scripts.flatMap((script) =>
native.map((item) => ({
source_path: script.path,
native_path: item.path,
basis: bridgeBasis(item.path),
})),
),
});
roots: readonly string[],
) => {
const owner = (path: string): string | null =>
roots.find((root) => isWithin(path, root)) ?? null;
return {
candidates: scripts.flatMap((script) => {
const root = owner(script.path);
return native
.filter(({ path }) => owner(path) === root)
.map((item) => ({
source_path: script.path,
native_path: item.path,
basis: bridgeBasis(item.path),
}));
}),
};
};
const bridgeBasis = (
path: string,
@ -360,7 +370,7 @@ const projectionLimitations = (facts: {
layout === "versioned-framework" && plist === null,
)
? [
"A versioned framework has zero or several Versions/*/Resources/Info.plist files; the one Versions/Current selects is unknown because symlink targets are not inventoried.",
"A versioned framework has several version directories or no Versions/*/Resources/Info.plist; the Info.plist that Versions/Current selects is unknown because symlink targets are not inventoried.",
]
: []),
...(facts.symlinks

View File

@ -141,14 +141,18 @@ export const detectBundles = (
interface Tree {
readonly children: ReadonlyMap<string, ReadonlySet<string>>;
readonly files: ReadonlyMap<string, Component>;
/** Paths observed as directories, or implied by a deeper path. */
readonly directories: ReadonlySet<string>;
}
const indexTree = (entries: readonly Entry[]): Tree => {
const children = new Map<string, Set<string>>();
const files = new Map<string, Component>();
const directories = new Set<string>();
for (const { path, kind, component } of entries) {
if (isSidecar(path)) continue;
if (kind === "file" && component !== undefined) files.set(path, component);
if (kind === "directory") directories.add(path);
const segments = path.split("/");
let parent = "";
for (const segment of segments) {
@ -156,9 +160,10 @@ const indexTree = (entries: readonly Entry[]): Tree => {
names.add(segment);
children.set(parent, names);
parent = joinPath(parent, segment);
if (parent !== path) directories.add(parent);
}
}
return { children, files };
return { children, files, directories };
};
const joinPath = (directory: string, name: string): string =>
@ -223,9 +228,12 @@ const contentDirectories = (
if (layout === "macos-deep") return [joinPath(directory, "Contents")];
if (layout === "shallow") return [directory];
const versions = joinPath(directory, "Versions");
// Only real version directories count: Current is a symlink, and files
// such as .DS_Store are not versions.
return [...(tree.children.get(versions) ?? [])]
.filter((version) => version !== "Current")
.map((version) => joinPath(versions, version))
.filter((version) => tree.directories.has(version))
.sort(compare);
};
@ -234,7 +242,11 @@ const infoPlistPath = (
layout: Bundle["layout"],
tree: Tree,
): string | null => {
const candidates = contentDirectories(bundle, layout, tree)
const directories = contentDirectories(bundle, layout, tree);
// With several real versions, Versions/Current (a symlink outside the
// inventory) decides which plist applies, so the plist stays unknown.
if (directories.length !== 1) return null;
const candidates = directories
.map((directory) =>
joinPath(
directory,