diff --git a/src/artifacts/DirectoryArtifactReader.ts b/src/artifacts/DirectoryArtifactReader.ts index 1e70c0e0e..cbbbbba69 100644 --- a/src/artifacts/DirectoryArtifactReader.ts +++ b/src/artifacts/DirectoryArtifactReader.ts @@ -14,6 +14,7 @@ import { type ArtifactEntry, type ArtifactReader, } from "./ArtifactReader.js"; +import { hostExecutableMarking } from "./hostExecutableMarking.js"; import { OwnedDirectoryHandle } from "../filesystem/OwnedDirectoryHandle.js"; import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js"; import { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js"; @@ -270,7 +271,7 @@ const artifactEntry = ( kind, declaredSize: kind === "file" ? metadata.size : null, compressedSize: null, - executable: (metadata.mode & 0o111) !== 0, + executable: hostExecutableMarking(absolute, metadata), encrypted: false, byteOffset: null, declaredSha256: null, diff --git a/src/artifacts/hostExecutableMarking.test.ts b/src/artifacts/hostExecutableMarking.test.ts new file mode 100644 index 000000000..7aff5bbda --- /dev/null +++ b/src/artifacts/hostExecutableMarking.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; + +import { hostExecutableMarking } from "./hostExecutableMarking.js"; + +const file = (mode: number) => ({ mode, isFile: () => true }); +const directory = (mode: number) => ({ mode, isFile: () => false }); + +describe("hostExecutableMarking", () => { + it("marks Windows launchable files by extension because NTFS has no execute bit", () => { + expect( + hostExecutableMarking("C:\\scratch\\whoami.exe", file(0o666), "win32"), + ).toBe(true); + expect( + hostExecutableMarking("C:\\scratch\\SETUP.CMD", file(0o444), "win32"), + ).toBe(true); + expect( + hostExecutableMarking("C:\\scratch\\library.dll", file(0o666), "win32"), + ).toBe(false); + expect( + hostExecutableMarking( + "C:\\scratch\\tools.exe", + directory(0o666), + "win32", + ), + ).toBe(false); + }); + + it("keeps POSIX permission bits authoritative regardless of extension", () => { + expect(hostExecutableMarking("/scratch/tool", file(0o755), "linux")).toBe( + true, + ); + expect( + hostExecutableMarking("/scratch/whoami.exe", file(0o644), "darwin"), + ).toBe(false); + }); +}); diff --git a/src/artifacts/hostExecutableMarking.ts b/src/artifacts/hostExecutableMarking.ts new file mode 100644 index 000000000..dcc4b4fb2 --- /dev/null +++ b/src/artifacts/hostExecutableMarking.ts @@ -0,0 +1,22 @@ +import type { Stats } from "node:fs"; +import { extname } from "node:path"; + +/** Extensions Windows launches directly through CreateProcess or cmd.exe. */ +const windowsLaunchableExtensions = new Set([".bat", ".cmd", ".com", ".exe"]); + +/** + * Whether a host filesystem entry carries the host's executable marking. + * + * POSIX hosts mark executability with permission bits. Windows has no execute + * bit (Node reports file modes from the read-only attribute alone), so it marks + * launchable files by extension instead. + */ +export const hostExecutableMarking = ( + path: string, + metadata: Pick, + platform: NodeJS.Platform = process.platform, +): boolean => + platform === "win32" + ? metadata.isFile() && + windowsLaunchableExtensions.has(extname(path).toLowerCase()) + : (metadata.mode & 0o111) !== 0; diff --git a/src/artifacts/inventory/scanCanonical.ts b/src/artifacts/inventory/scanCanonical.ts index 1eb3a74d2..ef4df6d71 100644 --- a/src/artifacts/inventory/scanCanonical.ts +++ b/src/artifacts/inventory/scanCanonical.ts @@ -34,6 +34,7 @@ import { artifactManifestId, } from "../../domain/artifactIdentity.js"; import { classifyAndHashRootForInventory } from "./classify.js"; +import { hostExecutableMarking } from "../hostExecutableMarking.js"; import type { HashResult } from "../ArtifactHash.js"; import { hashStableRootArtifact, @@ -211,7 +212,7 @@ const buildInventorySnapshot = async ( rekeyOccurrences(rootNode.artifact_id, input.occurrences); const rootOccurrence = rootOccurrenceFor(rootNode, { size: metadata.size, - executable: (metadata.mode & 0o111) !== 0, + executable: hostExecutableMarking(path, metadata), format: rootFormat, path, });