fix(artifacts): mark Windows host executables by extension (#1823)

Windows has no POSIX execute bit; Node reports file modes from the
read-only attribute alone, so every host file, including PE images,
reported `executable: false` while the same occurrence reported
`artifact_kind: executable`. Derive the host executable marking once:
POSIX permission bits stay authoritative, and Windows marks regular
files with the CreateProcess/cmd launchable extensions.

Fixes #1819

Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Kaoru0822
2026-10-11 06:26:30 -07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 76e4153d34
commit 91f843bf38
4 changed files with 62 additions and 2 deletions
+2 -1
View File
@@ -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,
@@ -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);
});
});
+22
View File
@@ -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<Stats, "mode" | "isFile">,
platform: NodeJS.Platform = process.platform,
): boolean =>
platform === "win32"
? metadata.isFile() &&
windowsLaunchableExtensions.has(extname(path).toLowerCase())
: (metadata.mode & 0o111) !== 0;
+2 -1
View File
@@ -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,
});