From b698931eb5ccf4ef4b32a8b20326a6aca6b33a5f Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Mon, 14 Sep 2026 10:35:14 +0000 Subject: [PATCH 1/8] test(prototypes): cover Windows ACL packaging --- prototypes/windows-acl/package-prototype.cjs | 119 +++++++++++ prototypes/windows-acl/stage.cjs | 89 ++++++++ prototypes/windows-acl/test/bridge.test.ts | 196 ++++++++++++++++++ .../test/package-prototype.test.ts | 157 ++++++++++++++ prototypes/windows-acl/test/stage.test.ts | 99 +++++++++ prototypes/windows-acl/test/vitest.config.mts | 18 ++ .../windows-acl/test/windows-native.test.ts | 92 ++++++++ 7 files changed, 770 insertions(+) create mode 100644 prototypes/windows-acl/package-prototype.cjs create mode 100644 prototypes/windows-acl/stage.cjs create mode 100644 prototypes/windows-acl/test/bridge.test.ts create mode 100644 prototypes/windows-acl/test/package-prototype.test.ts create mode 100644 prototypes/windows-acl/test/stage.test.ts create mode 100644 prototypes/windows-acl/test/vitest.config.mts create mode 100644 prototypes/windows-acl/test/windows-native.test.ts diff --git a/prototypes/windows-acl/package-prototype.cjs b/prototypes/windows-acl/package-prototype.cjs new file mode 100644 index 0000000000..154d993c22 --- /dev/null +++ b/prototypes/windows-acl/package-prototype.cjs @@ -0,0 +1,119 @@ +#!/usr/bin/env node +"use strict"; + +const childProcess = require("node:child_process"); +const fs = require("node:fs"); +const path = require("node:path"); + +const ROOT = __dirname; +const ARTIFACT_ROOT = path.join(ROOT, "artifacts"); +const VSCE = path.join(ROOT, "..", "..", "node_modules", ".bin", "vsce"); +const VARIANTS = new Set(["helper", "addon"]); +const WINDOWS_ARCHITECTURES = ["x64", "arm64"]; + +function parseArguments(args) { + if (args.length !== 1 || !VARIANTS.has(args[0])) { + throw new Error("Usage: package-prototype.cjs "); + } + return args[0]; +} + +function copyRequiredFile(source, destination) { + if (!fs.statSync(source, { throwIfNoEntry: false })?.isFile()) { + throw new Error(`Missing required prototype file: ${source}`); + } + fs.mkdirSync(path.dirname(destination), { recursive: true }); + fs.copyFileSync(source, destination); +} + +function packageManifest(variant) { + return { + name: `windows-acl-prototype-${variant}`, + displayName: `Windows ACL Prototype (${variant})`, + version: "0.0.0", + publisher: "coder-prototype", + description: "Experimental Windows ACL packaging prototype.", + engines: { vscode: "^1.95.0" }, + main: "./extension.cjs", + activationEvents: [], + }; +} + +function assemble(variant, { root = ROOT, artifactRoot = ARTIFACT_ROOT } = {}) { + if (!VARIANTS.has(variant)) + throw new Error(`Unknown ACL prototype: ${variant}`); + + const assemblyDirectory = path.join(artifactRoot, `vsix-${variant}`); + fs.rmSync(assemblyDirectory, { recursive: true, force: true }); + fs.mkdirSync(assemblyDirectory, { recursive: true }); + + copyRequiredFile( + path.join(root, "bridge.cjs"), + path.join(assemblyDirectory, "bridge.cjs"), + ); + fs.writeFileSync( + path.join(assemblyDirectory, "extension.cjs"), + "exports.activate = () => {};\nexports.deactivate = () => {};\n", + ); + fs.writeFileSync( + path.join(assemblyDirectory, "package.json"), + `${JSON.stringify(packageManifest(variant), null, "\t")}\n`, + ); + + const nativeName = variant === "helper" ? "acl-helper.exe" : "acl.node"; + for (const arch of WINDOWS_ARCHITECTURES) { + copyRequiredFile( + path.join(artifactRoot, `win32-${arch}`, nativeName), + path.join(assemblyDirectory, "artifacts", `win32-${arch}`, nativeName), + ); + } + return assemblyDirectory; +} + +function packagePrototype(variant, options = {}) { + const root = options.root ?? ROOT; + const artifactRoot = options.artifactRoot ?? ARTIFACT_ROOT; + const vsce = options.vsce ?? VSCE; + const assemblyDirectory = assemble(variant, { root, artifactRoot }); + if (!fs.statSync(vsce, { throwIfNoEntry: false })?.isFile()) { + throw new Error(`VS Code packaging executable is unavailable: ${vsce}`); + } + + const output = path.join( + artifactRoot, + `windows-acl-prototype-${variant}-0.0.0.vsix`, + ); + fs.rmSync(output, { force: true }); + childProcess.execFileSync( + vsce, + ["package", "--no-dependencies", "--out", output], + { + cwd: assemblyDirectory, + stdio: "inherit", + }, + ); + return output; +} + +function main() { + console.log(packagePrototype(parseArguments(process.argv.slice(2)))); +} + +if (require.main === module) { + try { + main(); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} + +module.exports = { + ARTIFACT_ROOT, + VARIANTS, + WINDOWS_ARCHITECTURES, + assemble, + packageManifest, + packagePrototype, + parseArguments, +}; diff --git a/prototypes/windows-acl/stage.cjs b/prototypes/windows-acl/stage.cjs new file mode 100644 index 0000000000..71061cb469 --- /dev/null +++ b/prototypes/windows-acl/stage.cjs @@ -0,0 +1,89 @@ +#!/usr/bin/env node +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); + +const ROOT = __dirname; +const ARTIFACT_ROOT = path.join(ROOT, "artifacts"); + +const TARGETS = new Map([ + ["x86_64-pc-windows-msvc", { platform: "win32", arch: "x64" }], + ["aarch64-pc-windows-msvc", { platform: "win32", arch: "arm64" }], + ["x86_64-unknown-linux-gnu", { platform: "linux", arch: "x64" }], + ["aarch64-unknown-linux-gnu", { platform: "linux", arch: "arm64" }], +]); + +function parseArguments(args) { + let target; + for (let index = 0; index < args.length; index += 1) { + if (args[index] !== "--target") { + throw new Error(`Unknown argument: ${args[index]}`); + } + target = args[index + 1]; + if (!target) throw new Error("--target requires a Rust target triple"); + index += 1; + } + if (!target) throw new Error("--target is required"); + if (!TARGETS.has(target)) + throw new Error(`Unsupported Rust target: ${target}`); + return target; +} + +function nativeOutputs(platform) { + return platform === "win32" + ? [ + ["acl-prototype-helper.exe", "acl-helper.exe"], + ["acl_prototype_addon.dll", "acl.node"], + ] + : [ + ["acl-prototype-helper", "acl-helper"], + ["libacl_prototype_addon.so", "acl.node"], + ]; +} + +function stage(target, { root = ROOT, artifactRoot = ARTIFACT_ROOT } = {}) { + const destination = TARGETS.get(target); + if (!destination) throw new Error(`Unsupported Rust target: ${target}`); + + const releaseDirectory = path.join(root, "target", target, "release"); + const artifactDirectory = path.join( + artifactRoot, + `${destination.platform}-${destination.arch}`, + ); + fs.mkdirSync(artifactDirectory, { recursive: true }); + + for (const [sourceName, destinationName] of nativeOutputs( + destination.platform, + )) { + const source = path.join(releaseDirectory, sourceName); + if (!fs.statSync(source, { throwIfNoEntry: false })?.isFile()) { + throw new Error(`Missing Cargo release output: ${source}`); + } + fs.copyFileSync(source, path.join(artifactDirectory, destinationName)); + } + + return artifactDirectory; +} + +function main() { + const target = parseArguments(process.argv.slice(2)); + console.log(stage(target)); +} + +if (require.main === module) { + try { + main(); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} + +module.exports = { + ARTIFACT_ROOT, + TARGETS, + nativeOutputs, + parseArguments, + stage, +}; diff --git a/prototypes/windows-acl/test/bridge.test.ts b/prototypes/windows-acl/test/bridge.test.ts new file mode 100644 index 0000000000..c30f94dda2 --- /dev/null +++ b/prototypes/windows-acl/test/bridge.test.ts @@ -0,0 +1,196 @@ +import * as path from "node:path"; +import { describe, expect, it, vi } from "vitest"; + +import bridgeModule from "../bridge.cjs"; + +const { createBridge } = bridgeModule; +const artifactRoot = "C:\\prototype\\artifacts"; +const target = "C:\\scratch\\target; & injected.txt"; + +describe("createBridge", () => { + it.each([ + ["darwin", "x64"], + ["darwin", "arm64"], + ["linux", "x64"], + ["linux", "arm64"], + ] as const)( + "skips native work on %s/%s without resolving missing artifacts", + async (platform, arch) => { + const loadAddon = vi.fn(); + const runHelper = vi.fn(); + const bridge = createBridge({ + variant: "addon", + artifactRoot: "/missing/artifacts", + platform, + arch, + loadAddon, + runHelper, + }); + + await expect(bridge.inspect("not a Windows path")).resolves.toEqual({ + skipped: true, + }); + expect(loadAddon).not.toHaveBeenCalled(); + expect(runHelper).not.toHaveBeenCalled(); + }, + ); + + it.each(["ia32", "arm", "riscv64"])( + "rejects unsupported Windows architecture %s before native work", + async (arch) => { + const loadAddon = vi.fn(); + const runHelper = vi.fn(); + const bridge = createBridge({ + variant: "helper", + artifactRoot, + platform: "win32", + arch, + loadAddon, + runHelper, + }); + + await expect(bridge.secure("C:\\scratch\\target")).rejects.toThrow( + `Unsupported Windows architecture: ${arch}`, + ); + expect(loadAddon).not.toHaveBeenCalled(); + expect(runHelper).not.toHaveBeenCalled(); + }, + ); + + it.each(["x64", "arm64"] as const)( + "selects the Windows %s addon artifact and loads it lazily once", + async (arch) => { + const inspect = vi.fn().mockResolvedValue("D:(A;;FA;;;SY)"); + const loadAddon = vi.fn().mockReturnValue({ inspect, secure: vi.fn() }); + const bridge = createBridge({ + variant: "addon", + artifactRoot, + platform: "win32", + arch, + loadAddon, + }); + + expect(loadAddon).not.toHaveBeenCalled(); + await expect(bridge.inspect("C:\\scratch\\first")).resolves.toBe( + "D:(A;;FA;;;SY)", + ); + await bridge.inspect("C:\\scratch\\second"); + + expect(loadAddon).toHaveBeenCalledTimes(1); + expect(loadAddon).toHaveBeenCalledWith( + path.join(artifactRoot, `win32-${arch}`, "acl.node"), + ); + expect(inspect).toHaveBeenNthCalledWith(1, "C:\\scratch\\first"); + expect(inspect).toHaveBeenNthCalledWith(2, "C:\\scratch\\second"); + }, + ); + + it("propagates a missing addon binary error when it is first invoked", async () => { + const missing = new Error("Cannot find module acl.node"); + const loadAddon = vi.fn(() => { + throw missing; + }); + const bridge = createBridge({ + variant: "addon", + artifactRoot, + platform: "win32", + arch: "x64", + loadAddon, + }); + + await expect(bridge.secure("C:\\scratch\\target")).rejects.toBe(missing); + await expect(bridge.secure("C:\\scratch\\target")).rejects.toBe(missing); + // A failed lazy require is retried by Node on the next invocation. + expect(loadAddon).toHaveBeenCalledTimes(2); + }); + + it("passes helper operation and injection-shaped target as separate arguments", async () => { + const runHelper = vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ version: 1, ok: true, sddl: null }), + }); + const bridge = createBridge({ + variant: "helper", + artifactRoot, + platform: "win32", + arch: "arm64", + runHelper, + }); + + await expect(bridge.secure(target)).resolves.toBeUndefined(); + + expect(runHelper).toHaveBeenCalledWith( + path.join(artifactRoot, "win32-arm64", "acl-helper.exe"), + ["secure", target], + { windowsHide: true, timeout: 10_000, maxBuffer: 64 * 1024 }, + ); + }); + + it("returns the helper inspection result", async () => { + const runHelper = vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ version: 1, ok: true, sddl: "D:P(A;;FA;;;SY)" }), + }); + const bridge = createBridge({ + variant: "helper", + artifactRoot, + platform: "win32", + arch: "x64", + runHelper, + }); + + await expect(bridge.inspect("C:\\scratch\\target")).resolves.toBe( + "D:P(A;;FA;;;SY)", + ); + }); + + it.each([ + JSON.stringify({ version: 2, ok: true }), + JSON.stringify({ version: 1, ok: false }), + ] as const)("rejects invalid helper responses", async (stdout) => { + const bridge = createBridge({ + variant: "helper", + artifactRoot, + platform: "win32", + arch: "x64", + runHelper: vi.fn().mockResolvedValue({ stdout }), + }); + + await expect(bridge.secure("C:\\scratch\\target")).rejects.toThrow( + "Invalid ACL helper response", + ); + }); + + it("propagates helper failures", async () => { + const failure = Object.assign(new Error("helper exited"), { code: 1 }); + const bridge = createBridge({ + variant: "helper", + artifactRoot, + platform: "win32", + arch: "x64", + runHelper: vi.fn().mockRejectedValue(failure), + }); + + await expect(bridge.inspect("C:\\scratch\\target")).rejects.toBe(failure); + }); + + it.each(["relative\\path", "C:\\scratch\\nul\0path"])( + "rejects invalid Windows target paths before native work", + async (invalidTarget) => { + const loadAddon = vi.fn(); + const runHelper = vi.fn(); + const bridge = createBridge({ + variant: "helper", + artifactRoot, + platform: "win32", + arch: "x64", + loadAddon, + runHelper, + }); + + await expect(bridge.secure(invalidTarget)).rejects.toThrow( + "An absolute Windows path without NUL is required", + ); + expect(loadAddon).not.toHaveBeenCalled(); + expect(runHelper).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/prototypes/windows-acl/test/package-prototype.test.ts b/prototypes/windows-acl/test/package-prototype.test.ts new file mode 100644 index 0000000000..20a52efbc1 --- /dev/null +++ b/prototypes/windows-acl/test/package-prototype.test.ts @@ -0,0 +1,157 @@ +import * as childProcess from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import packageModule from "../package-prototype.cjs"; + +const { assemble, packageManifest, packagePrototype, parseArguments } = + packageModule; +const temporaryDirectories: string[] = []; + +function temporaryDirectory(): string { + const directory = fs.mkdtempSync( + path.join(os.tmpdir(), "windows-acl-package-"), + ); + temporaryDirectories.push(directory); + return directory; +} + +function writeArtifact( + artifactRoot: string, + arch: "x64" | "arm64", + name: "acl-helper.exe" | "acl.node", + contents = name, +): void { + const directory = path.join(artifactRoot, `win32-${arch}`); + fs.mkdirSync(directory, { recursive: true }); + fs.writeFileSync(path.join(directory, name), contents); +} + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +describe("package-prototype", () => { + it("requires exactly one known prototype variant", () => { + expect(parseArguments(["helper"])).toBe("helper"); + expect(() => parseArguments([])).toThrow("Usage:"); + expect(() => parseArguments(["helper", "addon"])).toThrow("Usage:"); + expect(() => parseArguments(["unknown"])).toThrow("Usage:"); + }); + + it("generates an independent inert extension manifest", () => { + expect(packageManifest("helper")).toMatchObject({ + name: "windows-acl-prototype-helper", + publisher: "coder-prototype", + main: "./extension.cjs", + activationEvents: [], + }); + expect(packageManifest("helper").name).not.toBe("coder-remote"); + }); + + it("assembles only bridge code and the selected Windows helper binaries", () => { + const root = temporaryDirectory(); + const artifactRoot = path.join(root, "artifacts"); + fs.writeFileSync(path.join(root, "bridge.cjs"), "module.exports = {};\n"); + for (const arch of ["x64", "arm64"] as const) { + writeArtifact(artifactRoot, arch, "acl-helper.exe", `${arch}-helper`); + writeArtifact(artifactRoot, arch, "acl.node", `${arch}-addon`); + } + fs.mkdirSync(path.join(artifactRoot, "linux-x64"), { recursive: true }); + fs.writeFileSync( + path.join(artifactRoot, "linux-x64", "acl-helper"), + "linux", + ); + + const assembly = assemble("helper", { root, artifactRoot }); + + expect(fs.readFileSync(path.join(assembly, "bridge.cjs"), "utf8")).toBe( + "module.exports = {};\n", + ); + expect( + fs.readFileSync( + path.join(assembly, "artifacts", "win32-x64", "acl-helper.exe"), + "utf8", + ), + ).toBe("x64-helper"); + expect( + fs.readFileSync( + path.join(assembly, "artifacts", "win32-arm64", "acl-helper.exe"), + "utf8", + ), + ).toBe("arm64-helper"); + expect(fs.existsSync(path.join(assembly, "artifacts", "linux-x64"))).toBe( + false, + ); + expect( + fs.existsSync(path.join(assembly, "artifacts", "win32-x64", "acl.node")), + ).toBe(false); + expect( + fs.readFileSync(path.join(assembly, "extension.cjs"), "utf8"), + ).toContain("exports.activate = () => {};"); + }); + + it("fails before packaging when the selected universal Windows payload is incomplete", () => { + const root = temporaryDirectory(); + const artifactRoot = path.join(root, "artifacts"); + fs.writeFileSync(path.join(root, "bridge.cjs"), "module.exports = {};\n"); + writeArtifact(artifactRoot, "x64", "acl.node"); + + expect(() => assemble("addon", { root, artifactRoot })).toThrow( + "Missing required prototype file", + ); + }); +}); + +const projectRoot = path.resolve(import.meta.dirname, "..", "..", ".."); +const realArtifactRoot = path.join( + projectRoot, + "prototypes", + "windows-acl", + "artifacts", +); +const stagedVariants = (["helper", "addon"] as const).filter((variant) => { + const name = variant === "helper" ? "acl-helper.exe" : "acl.node"; + return ["x64", "arm64"].every((arch) => + fs + .statSync(path.join(realArtifactRoot, `win32-${arch}`, name), { + throwIfNoEntry: false, + }) + ?.isFile(), + ); +}); + +describe.runIf(stagedVariants.length === 2)("universal VSIX archives", () => { + it.each(stagedVariants)( + "packages the real staged %s payload with the expected manifest and contents", + (variant) => { + const output = packagePrototype(variant); + const listing = childProcess.execFileSync("unzip", ["-Z1", output], { + encoding: "utf8", + }); + const entries = listing.split(/\r?\n/).filter(Boolean); + const manifest = childProcess.execFileSync( + "unzip", + ["-p", output, "extension/package.json"], + { encoding: "utf8" }, + ); + + expect(JSON.parse(manifest)).toMatchObject(packageManifest(variant)); + expect(entries).toEqual( + expect.arrayContaining([ + "extension/bridge.cjs", + "extension/extension.cjs", + "extension/artifacts/win32-x64/" + + (variant === "helper" ? "acl-helper.exe" : "acl.node"), + "extension/artifacts/win32-arm64/" + + (variant === "helper" ? "acl-helper.exe" : "acl.node"), + ]), + ); + expect(entries.some((entry) => entry.includes("linux-"))).toBe(false); + }, + ); +}); diff --git a/prototypes/windows-acl/test/stage.test.ts b/prototypes/windows-acl/test/stage.test.ts new file mode 100644 index 0000000000..dcc2f588c3 --- /dev/null +++ b/prototypes/windows-acl/test/stage.test.ts @@ -0,0 +1,99 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import stageModule from "../stage.cjs"; + +const { parseArguments, stage } = stageModule; +const temporaryDirectories: string[] = []; + +function temporaryDirectory(): string { + const directory = fs.mkdtempSync( + path.join(os.tmpdir(), "windows-acl-stage-"), + ); + temporaryDirectories.push(directory); + return directory; +} + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +describe("stage", () => { + it("accepts only an explicit supported Cargo target", () => { + expect(parseArguments(["--target", "x86_64-pc-windows-msvc"])).toBe( + "x86_64-pc-windows-msvc", + ); + expect(() => parseArguments([])).toThrow("--target is required"); + expect(() => + parseArguments(["--target", "mipsel-unknown-linux-gnu"]), + ).toThrow("Unsupported Rust target"); + }); + + it("copies Windows helper and addon Cargo outputs into a matching artifact directory", () => { + const root = temporaryDirectory(); + const artifactRoot = path.join(root, "artifacts"); + const release = path.join( + root, + "target", + "x86_64-pc-windows-msvc", + "release", + ); + fs.mkdirSync(release, { recursive: true }); + fs.writeFileSync(path.join(release, "acl-prototype-helper.exe"), "helper"); + fs.writeFileSync(path.join(release, "acl_prototype_addon.dll"), "addon"); + + const output = stage("x86_64-pc-windows-msvc", { root, artifactRoot }); + + expect(output).toBe(path.join(artifactRoot, "win32-x64")); + expect(fs.readFileSync(path.join(output, "acl-helper.exe"), "utf8")).toBe( + "helper", + ); + expect(fs.readFileSync(path.join(output, "acl.node"), "utf8")).toBe( + "addon", + ); + }); + + it("stages Linux outputs for transport probes without renaming them as Windows products", () => { + const root = temporaryDirectory(); + const artifactRoot = path.join(root, "artifacts"); + const release = path.join( + root, + "target", + "x86_64-unknown-linux-gnu", + "release", + ); + fs.mkdirSync(release, { recursive: true }); + fs.writeFileSync(path.join(release, "acl-prototype-helper"), "helper"); + fs.writeFileSync(path.join(release, "libacl_prototype_addon.so"), "addon"); + + const output = stage("x86_64-unknown-linux-gnu", { root, artifactRoot }); + + expect(output).toBe(path.join(artifactRoot, "linux-x64")); + expect(fs.existsSync(path.join(output, "acl-helper"))).toBe(true); + expect(fs.existsSync(path.join(output, "acl.node"))).toBe(true); + expect(fs.existsSync(path.join(artifactRoot, "win32-x64"))).toBe(false); + }); + + it("fails strictly when any Cargo release output is missing", () => { + const root = temporaryDirectory(); + const release = path.join( + root, + "target", + "aarch64-pc-windows-msvc", + "release", + ); + fs.mkdirSync(release, { recursive: true }); + fs.writeFileSync(path.join(release, "acl-prototype-helper.exe"), "helper"); + + expect(() => + stage("aarch64-pc-windows-msvc", { + root, + artifactRoot: path.join(root, "artifacts"), + }), + ).toThrow("Missing Cargo release output"); + }); +}); diff --git a/prototypes/windows-acl/test/vitest.config.mts b/prototypes/windows-acl/test/vitest.config.mts new file mode 100644 index 0000000000..5fdda736ea --- /dev/null +++ b/prototypes/windows-acl/test/vitest.config.mts @@ -0,0 +1,18 @@ +import * as os from "node:os"; +import path from "node:path"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + root: import.meta.dirname, + cacheDir: path.join(os.tmpdir(), "windows-acl-vitest-cache"), + test: { + cache: false, + environment: "node", + include: ["**/*.test.ts"], + }, + resolve: { + alias: { + "@prototype": path.resolve(import.meta.dirname, ".."), + }, + }, +}); diff --git a/prototypes/windows-acl/test/windows-native.test.ts b/prototypes/windows-acl/test/windows-native.test.ts new file mode 100644 index 0000000000..74a24e9e9a --- /dev/null +++ b/prototypes/windows-acl/test/windows-native.test.ts @@ -0,0 +1,92 @@ +import { execFile as execFileCallback } from "node:child_process"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { promisify } from "node:util"; +import { afterEach, describe, expect, it } from "vitest"; + +import bridgeModule from "../bridge.cjs"; + +const { createBridge } = bridgeModule; +const execFile = promisify(execFileCallback); +const artifactRoot = path.resolve(import.meta.dirname, ".."); +const arch = + process.arch === "arm64" + ? "arm64" + : process.arch === "x64" + ? "x64" + : undefined; +const nativeArtifactsAvailable = + process.platform === "win32" && + arch !== undefined && + ["acl-helper.exe", "acl.node"].every((name) => + require("node:fs").existsSync( + path.join(artifactRoot, "artifacts", `win32-${arch}`, name), + ), + ); +const temporaryDirectories: string[] = []; + +async function temporaryDirectory(): Promise { + const directory = await fs.mkdtemp( + path.join(os.tmpdir(), "windows-acl-native-"), + ); + temporaryDirectories.push(directory); + return directory; +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => fs.rm(directory, { recursive: true, force: true })), + ); +}); + +describe.runIf(nativeArtifactsAvailable)( + "staged Windows ACL implementations", + () => { + it("apply equivalent protected ACLs repeatedly and retain OpenSSH Include support", async () => { + const directory = await temporaryDirectory(); + const parentConfig = path.join(directory, "parent.conf"); + const includedConfig = path.join(directory, "included.conf"); + await fs.writeFile( + parentConfig, + "Host acl-prototype\n User prototype-user\n", + ); + await fs.writeFile( + includedConfig, + "Include parent.conf\nHost *\n Compression no\n", + ); + + // The disposable file starts permissive so both implementations must replace it. + await execFile("icacls", [includedConfig, "/grant", "*S-1-1-0:(F)"]); + + const bridges = ["helper", "addon"] as const; + const inspected = await Promise.all( + bridges.map(async (variant) => { + const bridge = createBridge({ + variant, + artifactRoot, + platform: "win32", + arch, + }); + await bridge.secure(includedConfig); + const first = await bridge.inspect(includedConfig); + await bridge.secure(includedConfig); + const second = await bridge.inspect(includedConfig); + expect(second).toBe(first); + const { stdout } = await execFile("ssh", [ + "-G", + "-F", + includedConfig, + "acl-prototype", + ]); + expect(stdout).toContain("user prototype-user"); + return first; + }), + ); + + expect(inspected[0]).toBe(inspected[1]); + }); + }, +); From ce17b98ed8754a42bbc42460099acc5fbb3490d3 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Mon, 14 Sep 2026 10:50:06 +0000 Subject: [PATCH 2/8] chore(prototypes): compare native Windows ACL interfaces in CI --- .github/workflows/windows-acl-prototypes.yaml | 116 ++++ .prettierignore | 2 + .vscodeignore | 1 + eslint.config.mjs | 2 + prototypes/windows-acl/.gitignore | 3 + prototypes/windows-acl/Cargo.lock | 374 +++++++++++++ prototypes/windows-acl/Cargo.toml | 8 + prototypes/windows-acl/README.md | 139 +++++ prototypes/windows-acl/addon/Cargo.toml | 16 + prototypes/windows-acl/addon/build.rs | 3 + prototypes/windows-acl/addon/src/lib.rs | 61 +++ prototypes/windows-acl/bridge.cjs | 56 ++ prototypes/windows-acl/bridge.d.cts | 41 ++ prototypes/windows-acl/core/Cargo.toml | 16 + prototypes/windows-acl/core/src/lib.rs | 505 ++++++++++++++++++ prototypes/windows-acl/helper/Cargo.toml | 9 + prototypes/windows-acl/helper/src/main.rs | 40 ++ prototypes/windows-acl/package-prototype.cjs | 17 +- .../windows-acl/package-prototype.d.cts | 35 ++ prototypes/windows-acl/stage.cjs | 1 + prototypes/windows-acl/stage.d.cts | 23 + .../test/package-prototype.test.ts | 9 +- prototypes/windows-acl/test/tsconfig.json | 9 + .../windows-acl/test/windows-native.test.ts | 128 +++-- prototypes/windows-acl/transport-probe.cjs | 59 ++ prototypes/windows-acl/tsconfig.json | 9 + 26 files changed, 1608 insertions(+), 74 deletions(-) create mode 100644 .github/workflows/windows-acl-prototypes.yaml create mode 100644 prototypes/windows-acl/.gitignore create mode 100644 prototypes/windows-acl/Cargo.lock create mode 100644 prototypes/windows-acl/Cargo.toml create mode 100644 prototypes/windows-acl/README.md create mode 100644 prototypes/windows-acl/addon/Cargo.toml create mode 100644 prototypes/windows-acl/addon/build.rs create mode 100644 prototypes/windows-acl/addon/src/lib.rs create mode 100644 prototypes/windows-acl/bridge.cjs create mode 100644 prototypes/windows-acl/bridge.d.cts create mode 100644 prototypes/windows-acl/core/Cargo.toml create mode 100644 prototypes/windows-acl/core/src/lib.rs create mode 100644 prototypes/windows-acl/helper/Cargo.toml create mode 100644 prototypes/windows-acl/helper/src/main.rs create mode 100644 prototypes/windows-acl/package-prototype.d.cts create mode 100644 prototypes/windows-acl/stage.d.cts create mode 100644 prototypes/windows-acl/test/tsconfig.json create mode 100644 prototypes/windows-acl/transport-probe.cjs create mode 100644 prototypes/windows-acl/tsconfig.json diff --git a/.github/workflows/windows-acl-prototypes.yaml b/.github/workflows/windows-acl-prototypes.yaml new file mode 100644 index 0000000000..1214cadd58 --- /dev/null +++ b/.github/workflows/windows-acl-prototypes.yaml @@ -0,0 +1,116 @@ +name: Windows ACL Prototypes + +on: + pull_request: + paths: + - "prototypes/windows-acl/**" + - ".github/workflows/windows-acl-prototypes.yaml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: acl-prototypes-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + native: + name: Native ACL (${{ matrix.arch }}) + strategy: + fail-fast: false + matrix: + include: + - os: windows-2025-vs2026 + arch: x64 + target: x86_64-pc-windows-msvc + - os: windows-11-vs2026-arm + arch: arm64 + target: aarch64-pc-windows-msvc + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: ./.github/actions/setup + - name: Verify native runtime architecture + run: node -e 'if (process.arch !== "${{ matrix.arch }}") throw new Error(process.arch); console.log(process.versions)' + - name: Install pinned Rust toolchain + run: | + rustup toolchain install 1.98.1 --profile minimal --component clippy --component rustfmt + rustup target add --toolchain 1.98.1 ${{ matrix.target }} + - name: Format and lint native code + run: | + cargo +1.98.1 fmt --all --manifest-path prototypes/windows-acl/Cargo.toml --check + cargo +1.98.1 clippy --workspace --all-targets --target ${{ matrix.target }} --manifest-path prototypes/windows-acl/Cargo.toml --locked -- -D warnings + - name: Test native ACL core + run: cargo +1.98.1 test -p acl-prototype-core --target ${{ matrix.target }} --manifest-path prototypes/windows-acl/Cargo.toml --locked + - name: Build and stage both interfaces + run: | + cargo +1.98.1 build --release --workspace --target ${{ matrix.target }} --manifest-path prototypes/windows-acl/Cargo.toml --locked + node prototypes/windows-acl/stage.cjs --target ${{ matrix.target }} + - name: Test real Windows OpenSSH and both interfaces in Node + run: pnpm exec vitest run --config prototypes/windows-acl/test/vitest.config.mts + - name: Test both interfaces in current Electron + run: pnpm exec electron "$(node -p 'require("node:path").resolve("node_modules/vitest/vitest.mjs")')" run --config prototypes/windows-acl/test/vitest.config.mts + env: + ELECTRON_RUN_AS_NODE: "1" + - name: Test both interfaces in Electron 37 + run: pnpm dlx electron@37.10.3 "$(node -p 'require("node:path").resolve("node_modules/vitest/vitest.mjs")')" run --config prototypes/windows-acl/test/vitest.config.mts + env: + ELECTRON_RUN_AS_NODE: "1" + - name: Upload native artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: acl-native-${{ matrix.arch }} + path: prototypes/windows-acl/artifacts/win32-${{ matrix.arch }}/ + if-no-files-found: error + retention-days: 7 + + universal: + name: Universal VSIX comparison + needs: native + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: ./.github/actions/setup + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: acl-native-x64 + path: prototypes/windows-acl/artifacts/win32-x64 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: acl-native-arm64 + path: prototypes/windows-acl/artifacts/win32-arm64 + - name: Package and inspect both universal VSIXs + run: pnpm exec vitest run --config prototypes/windows-acl/test/vitest.config.mts + - name: Upload experimental universal packages + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: acl-universal-prototypes + path: prototypes/windows-acl/artifacts/*.vsix + if-no-files-found: error + retention-days: 7 + + non-windows: + name: Native bypass (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, macos-15] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: ./.github/actions/setup + - name: Test without any native artifacts installed + run: pnpm exec vitest run --config prototypes/windows-acl/test/vitest.config.mts diff --git a/.prettierignore b/.prettierignore index e40f7d14f8..f11380558e 100644 --- a/.prettierignore +++ b/.prettierignore @@ -10,6 +10,8 @@ flake.lock pnpm-debug.log pnpm-lock.yaml /storybook-static/ +/prototypes/windows-acl/target/ +/prototypes/windows-acl/artifacts/ # Golden snapshots are written verbatim; prettier would drift them out of sync. **/__golden__/ diff --git a/.vscodeignore b/.vscodeignore index 2844c8de11..101dcf28b0 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -4,6 +4,7 @@ coverage/** .nyc_output/** # Development files +prototypes/** src/** test/** scripts/** diff --git a/eslint.config.mjs b/eslint.config.mjs index 34f247abdf..c97949fe3e 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -22,6 +22,8 @@ export default defineConfig( ".vscode-test/**", "test/fixtures/scripts/**", "storybook-static/**", + "prototypes/windows-acl/target/**", + "prototypes/windows-acl/artifacts/**", ]), // Base ESLint recommended rules (for JS/TS/TSX files only) diff --git a/prototypes/windows-acl/.gitignore b/prototypes/windows-acl/.gitignore new file mode 100644 index 0000000000..54c22b07d4 --- /dev/null +++ b/prototypes/windows-acl/.gitignore @@ -0,0 +1,3 @@ +/target/ +/artifacts/ +/*.vsix diff --git a/prototypes/windows-acl/Cargo.lock b/prototypes/windows-acl/Cargo.lock new file mode 100644 index 0000000000..e7459a6dba --- /dev/null +++ b/prototypes/windows-acl/Cargo.lock @@ -0,0 +1,374 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "acl-prototype-addon" +version = "0.1.0" +dependencies = [ + "acl-prototype-core", + "napi", + "napi-build", + "napi-derive", +] + +[[package]] +name = "acl-prototype-core" +version = "0.1.0" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "acl-prototype-helper" +version = "0.1.0" +dependencies = [ + "acl-prototype-core", + "serde_json", +] + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "convert_case" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1af709f1f33454bf52eadfc8c78b3b9ef9cb26fb54d16dc9cd9a7299f899fd1b" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "ctor" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libloading" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "napi" +version = "3.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d2c1d080c842ec1347ebf3cfb9347c15f8c2262f143cc30eea41ddf1c7da720" +dependencies = [ + "bitflags", + "ctor", + "futures", + "libc", + "napi-build", + "napi-sys", + "nohash-hasher", + "rustc-hash", +] + +[[package]] +name = "napi-build" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "860e7c40864f95cfb83cde99f9ebadd88ef3d9bdccd7dd2cee0cc96a2dd4ffa7" + +[[package]] +name = "napi-derive" +version = "3.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "350057056a30368aa76c11a0d406b0aa61321710be2c36656ab4f6efe0b785a2" +dependencies = [ + "convert_case", + "ctor", + "napi-derive-backend", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "napi-derive-backend" +version = "6.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c1c87a71568f3fe5c736b10878ff55064b250bb4ea4b48c8055713e07b9e463" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "semver", + "syn 2.0.119", +] + +[[package]] +name = "napi-sys" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d29a242104e456960e6c6479847570be731c1a92b8f8c05ddfb5439e5c09915" +dependencies = [ + "libloading", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/prototypes/windows-acl/Cargo.toml b/prototypes/windows-acl/Cargo.toml new file mode 100644 index 0000000000..759c630612 --- /dev/null +++ b/prototypes/windows-acl/Cargo.toml @@ -0,0 +1,8 @@ +[workspace] +members = ["core", "helper", "addon"] +resolver = "2" + +[profile.release] +lto = true +codegen-units = 1 +strip = true diff --git a/prototypes/windows-acl/README.md b/prototypes/windows-acl/README.md new file mode 100644 index 0000000000..3b936e3458 --- /dev/null +++ b/prototypes/windows-acl/README.md @@ -0,0 +1,139 @@ +# Windows ACL interface comparison + +Experimental only. Neither prototype is connected to extension activation or the +SSH writer. Do not apply these prototypes to real user config directories. + +## Scope + +Compare a Rust executable against a Rust Node-API addon using the same Win32 core. +The intended distribution remains one universal VSIX containing Windows x64 and +ARM64 assets. Linux/macOS return before resolving, loading, or executing native +code. Native Windows builds are separate from VSIX packaging; platform-specific +Marketplace releases are not required. + +- `core`: direct `windows-sys` ACL operations on an existing file or directory. +- `helper`: process interface with a versioned JSON response. +- `addon`: Node-API 8 interface using `napi-rs`, with work off the JS thread. +- `bridge.cjs`: lazy, Windows-only selection and error propagation. +- `stage.cjs`: copies build products into the explicit artifact layout. +- `package-prototype.cjs`: assembles an inert, independently named universal VSIX + for one variant. Both Windows architectures must exist. No native downloads + or installation scripts run for extension users. + +The two experimental VSIXs are alternatives for comparison, not separate +platform releases. A selected production implementation would ship only one +variant, with both Windows architectures in the same universal extension. + +## Results on September 14, 2026 + +Performed in a Linux workspace using Rust 1.98.1: + +| Check | Result | +| ----------------------------------------------- | -------------------------------------------------- | +| Linux release build: helper + addon | Passed | +| Linux Rust unit tests | Passed (unsupported-platform behavior only) | +| Clippy, all targets, warnings denied | Passed on Linux and Windows source checks | +| Windows MSVC source/test check, x64 | Passed; not linked or executed | +| Windows MSVC source/test check, ARM64 | Passed; not linked or executed | +| Bridge/staging/assembly tests | 25 passed | +| Windows OpenSSH integration | Not run on Linux | +| Actual Windows universal VSIX archives | Not built; Windows binary artifacts missing | +| macOS/Linux native bypass | Passed using injected platform/architecture values | +| Actual macOS runtime | Not tested | +| Node 24.15.0 transport probe | Passed | +| Electron 37.10.3 / Node 22.21.1 transport probe | Passed | +| Electron 42.5.1 / Node 24.17.0 transport probe | Passed | +| Production VSIX listing excludes prototypes | Passed with `vsce ls --no-dependencies` | + +The same Linux `.node` binary was loaded in all three runtimes, without rebuild. +This validates interface loading/error handling, NOT Windows ACL correctness. +The explicit transport probe is development-only: it deliberately loads a Linux +build that always returns Unsupported for ACL operations. No Linux native asset +is intended for distribution. + +## Reproduce checks + +From the repository root: + +```sh +cargo build --release --workspace --manifest-path prototypes/windows-acl/Cargo.toml --locked +cargo test --workspace --manifest-path prototypes/windows-acl/Cargo.toml --locked +cargo fmt --all --manifest-path prototypes/windows-acl/Cargo.toml --check +cargo clippy --workspace --all-targets --manifest-path prototypes/windows-acl/Cargo.toml --locked -- -D warnings +cargo check --workspace --tests --target x86_64-pc-windows-msvc --manifest-path prototypes/windows-acl/Cargo.toml --locked +cargo check --workspace --tests --target aarch64-pc-windows-msvc --manifest-path prototypes/windows-acl/Cargo.toml --locked +pnpm exec vitest run --config prototypes/windows-acl/test/vitest.config.mts +node prototypes/windows-acl/transport-probe.cjs +ELECTRON_RUN_AS_NODE=1 pnpm exec electron prototypes/windows-acl/transport-probe.cjs +ELECTRON_RUN_AS_NODE=1 pnpm dlx electron@37.10.3 prototypes/windows-acl/transport-probe.cjs +``` + +The last three commands are Linux-only transport checks. The two Windows targets +must be installed through rustup for cross-checking. Real Windows binaries need a +Windows SDK/linker toolchain, not merely `rustup target add`. + +## Required Windows comparison + +Build each architecture using an MSVC-capable environment, then stage: + +```sh +cargo build --release --workspace --target x86_64-pc-windows-msvc --manifest-path prototypes/windows-acl/Cargo.toml --locked +node prototypes/windows-acl/stage.cjs --target x86_64-pc-windows-msvc +cargo build --release --workspace --target aarch64-pc-windows-msvc --manifest-path prototypes/windows-acl/Cargo.toml --locked +node prototypes/windows-acl/stage.cjs --target aarch64-pc-windows-msvc +``` + +Run native core tests on each matching architecture, plus the Vitest suite with +both variants staged. Windows tests require Windows OpenSSH; missing binaries or +OpenSSH fail a Windows run rather than masquerading as a pass. The fixture grants +Everyone write access on a disposable file, proves an SSH Include is rejected, +repairs it through each interface independently, and repeats the check after a +sibling-temp-file replacement. `icacls` is used only to arrange that bad test ACL; +neither implementation invokes it. + +With real binaries for both architectures present: + +```sh +node prototypes/windows-acl/package-prototype.cjs helper +node prototypes/windows-acl/package-prototype.cjs addon +pnpm exec vitest run --config prototypes/windows-acl/test/vitest.config.mts +``` + +The experimental extensions have inert activation. Installing them alone does +not exercise ACL behavior; use the bridge/native test harness explicitly. +The archive tests currently use `unzip`; run them on the Linux assembly host. + +Before selecting a production implementation, also inspect PE DLL dependencies, +validate signing/application-control behavior, exercise real macOS activation +with no native assets, and test the minimum supported Windows editor runtime. + +## Interpretation + +Both approaches can preserve a universal VSIX. The helper avoids loading native +code into the extension host and provides a process timeout, at the cost of a +subprocess interface. The addon avoids process launch and successfully loaded +across the tested Electron versions, but loads the native implementation into +the host process. Neither option has been proven operationally superior on real +Windows yet. The macOS keyring history argues for strict platform gating and +package-level regression tests, not a claim that shipping binaries is risk-free. + +## Prototype limitations + +- Existing-path ACL setter only; no secure-at-creation or atomic writer API. +- Final reparse-point rejection and owner validation use the opened handle; + parent directory chains and hard-link safety are not fully validated. +- Does not integrate generated-file migration or user-config ACL preservation. +- No Windows runtime, real ARM64, signing, or enterprise-policy validation yet. +- No production CI/release workflow changes were made. +- Package assembly unit tests use synthetic fixture bytes; those are not Windows + binaries and are never presented as functional native VSIXs. + +## Repository state + +Work is on `chore/compare-windows-acl-prototypes`. A delegated agent unexpectedly +committed and pushed the initial test harness as `b698931` despite explicit +instructions not to commit or push. That commit is retained without rewriting or deleting remote history. The user +subsequently authorized a draft PR to execute this comparison on Windows runners. +This branch is experimental and does not publish a production fix. + +Generated by Coder Agents. diff --git a/prototypes/windows-acl/addon/Cargo.toml b/prototypes/windows-acl/addon/Cargo.toml new file mode 100644 index 0000000000..695178ed7a --- /dev/null +++ b/prototypes/windows-acl/addon/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "acl-prototype-addon" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +acl-prototype-core = { path = "../core" } +napi = { version = "=3.12.4", default-features = false, features = ["napi8"] } +napi-derive = "=3.6.5" + +[build-dependencies] +napi-build = "=2.4.2" diff --git a/prototypes/windows-acl/addon/build.rs b/prototypes/windows-acl/addon/build.rs new file mode 100644 index 0000000000..0f1b01002b --- /dev/null +++ b/prototypes/windows-acl/addon/build.rs @@ -0,0 +1,3 @@ +fn main() { + napi_build::setup(); +} diff --git a/prototypes/windows-acl/addon/src/lib.rs b/prototypes/windows-acl/addon/src/lib.rs new file mode 100644 index 0000000000..c6abf53b45 --- /dev/null +++ b/prototypes/windows-acl/addon/src/lib.rs @@ -0,0 +1,61 @@ +use std::path::PathBuf; + +use napi::{bindgen_prelude::AsyncTask, Env, Error, Result, Task}; +use napi_derive::napi; + +#[napi] +pub fn probe() -> String { + acl_prototype_core::backend().to_owned() +} + +pub struct SecureTask { + path: PathBuf, +} + +impl Task for SecureTask { + type Output = (); + type JsValue = (); + + fn compute(&mut self) -> Result<()> { + acl_prototype_core::secure_path(&self.path).map_err(|error| { + Error::from_reason(format!("{} (osCode={:?})", error, error.raw_os_error())) + }) + } + + fn resolve(&mut self, _env: Env, _output: ()) -> Result<()> { + Ok(()) + } +} + +#[napi] +pub fn secure(path: String) -> AsyncTask { + AsyncTask::new(SecureTask { + path: PathBuf::from(path), + }) +} + +pub struct InspectTask { + path: PathBuf, +} + +impl Task for InspectTask { + type Output = String; + type JsValue = String; + + fn compute(&mut self) -> Result { + acl_prototype_core::inspect_path(&self.path).map_err(|error| { + Error::from_reason(format!("{} (osCode={:?})", error, error.raw_os_error())) + }) + } + + fn resolve(&mut self, _env: Env, output: String) -> Result { + Ok(output) + } +} + +#[napi] +pub fn inspect(path: String) -> AsyncTask { + AsyncTask::new(InspectTask { + path: PathBuf::from(path), + }) +} diff --git a/prototypes/windows-acl/bridge.cjs b/prototypes/windows-acl/bridge.cjs new file mode 100644 index 0000000000..295e24b04f --- /dev/null +++ b/prototypes/windows-acl/bridge.cjs @@ -0,0 +1,56 @@ +/* global process */ + +const { execFile } = require("node:child_process"); +const path = require("node:path"); +const { promisify } = require("node:util"); + +const execute = promisify(execFile); + +function createBridge({ + variant, + artifactRoot, + platform = process.platform, + arch = process.arch, + loadAddon = require, + runHelper = execute, +}) { + if (!["helper", "addon"].includes(variant)) { + throw new Error(`Unknown ACL prototype: ${variant}`); + } + let addon; + async function invoke(operation, target) { + // No native resolution, loading, or execution outside Windows. + if (platform !== "win32") return { skipped: true }; + if (!["x64", "arm64"].includes(arch)) { + throw new Error(`Unsupported Windows architecture: ${arch}`); + } + if (!path.win32.isAbsolute(target) || target.includes("\0")) { + throw new Error("An absolute Windows path without NUL is required"); + } + const directory = path.join(artifactRoot, `win32-${arch}`); + if (variant === "addon") { + addon ??= loadAddon(path.join(directory, "acl.node")); + return addon[operation](target); + } + const { stdout } = await runHelper( + path.join(directory, "acl-helper.exe"), + [operation, target], + { windowsHide: true, timeout: 10_000, maxBuffer: 64 * 1024 }, + ); + const result = JSON.parse(stdout); + if ( + result.version !== 1 || + result.ok !== true || + (operation === "inspect" && typeof result.sddl !== "string") + ) { + throw new Error("Invalid ACL helper response"); + } + return operation === "inspect" ? result.sddl : undefined; + } + return { + secure: (target) => invoke("secure", target), + inspect: (target) => invoke("inspect", target), + }; +} + +module.exports = { createBridge }; diff --git a/prototypes/windows-acl/bridge.d.cts b/prototypes/windows-acl/bridge.d.cts new file mode 100644 index 0000000000..de00748503 --- /dev/null +++ b/prototypes/windows-acl/bridge.d.cts @@ -0,0 +1,41 @@ +export type AclOperation = "secure" | "inspect"; +export type AclVariant = "helper" | "addon"; +export type WindowsArchitecture = "x64" | "arm64"; + +export interface NativeAddon { + secure(target: string): void | Promise; + inspect(target: string): string | Promise; +} + +export interface HelperResponse { + version: number; + ok: boolean; + sddl?: string | null; +} + +export type AddonLoader = (filename: string) => NativeAddon; +export type HelperRunner = ( + file: string, + args: string[], + options: { + windowsHide: boolean; + timeout: number; + maxBuffer: number; + }, +) => Promise<{ stdout: string }>; + +export interface CreateBridgeOptions { + variant: AclVariant; + artifactRoot: string; + platform?: string; + arch?: string; + loadAddon?: AddonLoader; + runHelper?: HelperRunner; +} + +export interface AclBridge { + secure(target: string): Promise; + inspect(target: string): Promise; +} + +export function createBridge(options: CreateBridgeOptions): AclBridge; diff --git a/prototypes/windows-acl/core/Cargo.toml b/prototypes/windows-acl/core/Cargo.toml new file mode 100644 index 0000000000..5a0a762b0a --- /dev/null +++ b/prototypes/windows-acl/core/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "acl-prototype-core" +version = "0.1.0" +edition = "2024" +rust-version = "1.85" +description = "Narrow Windows ACL read/write prototype core" +publish = false + +[dependencies] +windows-sys = { version = "=0.61.2", features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_Security_Authorization", + "Win32_Storage_FileSystem", + "Win32_System_Threading", +] } diff --git a/prototypes/windows-acl/core/src/lib.rs b/prototypes/windows-acl/core/src/lib.rs new file mode 100644 index 0000000000..74dae569c2 --- /dev/null +++ b/prototypes/windows-acl/core/src/lib.rs @@ -0,0 +1,505 @@ +//! Narrow Windows ACL prototype core. +//! +//! This crate intentionally protects only the final path component. It does not +//! validate parent directories. Opening and inspecting the final object by handle +//! closes the lookup-to-mutation race for that object, but a concurrent actor can +//! still replace the path after the operation. Do not use it as production hardening. + +use std::io; +use std::path::Path; + +/// Applies the prototype protected DACL to an existing absolute path. +/// +/// On Windows, the final object must not be a reparse point and must be owned by +/// the current user, LocalSystem, or the built-in Administrators group. +#[cfg(windows)] +pub fn secure_path(path: &Path) -> io::Result<()> { + windows::secure_path(path) +} + +/// Returns the protected DACL as an SDDL string. +#[cfg(windows)] +pub fn inspect_path(path: &Path) -> io::Result { + windows::inspect_path(path) +} + +/// Identifies the platform implementation. +#[cfg(windows)] +pub fn backend() -> &'static str { + "windows-sys" +} + +/// This prototype has no non-Windows permission emulation. +#[cfg(not(windows))] +pub fn secure_path(_path: &Path) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "Windows ACL protection is unsupported on this platform", + )) +} + +/// This prototype has no non-Windows security descriptor inspection. +#[cfg(not(windows))] +pub fn inspect_path(_path: &Path) -> io::Result { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "Windows ACL inspection is unsupported on this platform", + )) +} + +/// Identifies the unsupported implementation. +#[cfg(not(windows))] +pub fn backend() -> &'static str { + "unsupported" +} + +#[cfg(windows)] +mod windows { + use std::ffi::OsString; + use std::io; + use std::mem::size_of; + use std::os::windows::ffi::{OsStrExt, OsStringExt}; + use std::path::Path; + use std::ptr::{null, null_mut}; + use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE, LocalFree}; + use windows_sys::Win32::Security::Authorization::{ + ConvertSecurityDescriptorToStringSecurityDescriptorW, GetSecurityInfo, SE_FILE_OBJECT, + SetSecurityInfo, + }; + use windows_sys::Win32::Security::{ + ACL, ACL_REVISION, AddAccessAllowedAceEx, CopySid, CreateWellKnownSid, + DACL_SECURITY_INFORMATION, EqualSid, GROUP_SECURITY_INFORMATION, GetLengthSid, + GetSecurityDescriptorOwner, GetTokenInformation, InitializeAcl, IsValidSid, + OBJECT_SECURITY_INFORMATION, OWNER_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID, + TOKEN_QUERY, TOKEN_USER, TokenUser, WinBuiltinAdministratorsSid, WinLocalSystemSid, + }; + use windows_sys::Win32::Storage::FileSystem::{ + BY_HANDLE_FILE_INFORMATION, CreateFileW, FILE_ALL_ACCESS, FILE_ATTRIBUTE_DIRECTORY, + FILE_ATTRIBUTE_REPARSE_POINT, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, + FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, GetFileInformationByHandle, + OPEN_EXISTING, READ_CONTROL, WRITE_DAC, + }; + use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + + const PROTECTED_DACL_SECURITY_INFORMATION: u32 = 0x8000_0000; + const PROTECTED_FILE_ACCESS: u32 = FILE_ALL_ACCESS; + const CONTAINER_INHERIT_ACE: u8 = 0x02; + const OBJECT_INHERIT_ACE: u8 = 0x01; + const SDDL_REVISION_1: u32 = 1; + + pub fn secure_path(path: &Path) -> io::Result<()> { + let path = WidePath::new(path)?; + let handle = FileHandle::open_for_write(&path)?; + let attributes = handle.attributes()?; + if attributes & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(invalid_input( + "final path component must not be a reparse point", + )); + } + + let current_user = SidBuffer::current_user()?; + let descriptor = SecurityDescriptor::get(handle.0)?; + validate_owner(descriptor.owner()?, ¤t_user)?; + + let inheritance = if attributes & FILE_ATTRIBUTE_DIRECTORY != 0 { + CONTAINER_INHERIT_ACE | OBJECT_INHERIT_ACE + } else { + 0 + }; + let system = SidBuffer::well_known(WinLocalSystemSid)?; + let administrators = SidBuffer::well_known(WinBuiltinAdministratorsSid)?; + let acl = ProtectedAcl::new([¤t_user, &system, &administrators], inheritance)?; + unsafe { + check(SetSecurityInfo( + handle.0, + SE_FILE_OBJECT, + (DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION) + as OBJECT_SECURITY_INFORMATION, + null_mut(), + null_mut(), + acl.as_ptr(), + null(), + ))?; + } + Ok(()) + } + + pub fn inspect_path(path: &Path) -> io::Result { + let path = WidePath::new(path)?; + let handle = FileHandle::open_for_read(&path)?; + SecurityDescriptor::get(handle.0)?.to_sddl() + } + + struct WidePath(Vec); + + impl WidePath { + fn new(path: &Path) -> io::Result { + if !path.is_absolute() { + return Err(invalid_input("path must be absolute")); + } + let units: Vec = path.as_os_str().encode_wide().collect(); + if units.contains(&0) { + return Err(invalid_input("path contains a NUL character")); + } + Ok(Self(units.into_iter().chain(Some(0)).collect())) + } + } + + struct FileHandle(HANDLE); + + impl FileHandle { + fn open_for_read(path: &WidePath) -> io::Result { + Self::open(path, READ_CONTROL) + } + + fn open_for_write(path: &WidePath) -> io::Result { + Self::open(path, READ_CONTROL | WRITE_DAC) + } + + fn open(path: &WidePath, desired_access: u32) -> io::Result { + let handle = unsafe { + CreateFileW( + path.0.as_ptr(), + desired_access, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + null(), + OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, + null_mut(), + ) + }; + if handle == INVALID_HANDLE_VALUE { + return Err(io::Error::last_os_error()); + } + Ok(Self(handle)) + } + + fn attributes(&self) -> io::Result { + let mut information = BY_HANDLE_FILE_INFORMATION::default(); + unsafe { check_bool(GetFileInformationByHandle(self.0, &mut information))? }; + Ok(information.dwFileAttributes) + } + } + + impl Drop for FileHandle { + fn drop(&mut self) { + unsafe { CloseHandle(self.0) }; + } + } + + struct TokenHandle(HANDLE); + + impl TokenHandle { + fn current_process() -> io::Result { + let mut token = null_mut(); + unsafe { + check_bool(OpenProcessToken( + GetCurrentProcess(), + TOKEN_QUERY, + &mut token, + ))? + }; + Ok(Self(token)) + } + } + + impl Drop for TokenHandle { + fn drop(&mut self) { + unsafe { CloseHandle(self.0) }; + } + } + + /// Stores SID bytes in usize elements so the buffer remains properly aligned. + struct SidBuffer { + storage: Vec, + } + + impl SidBuffer { + fn current_user() -> io::Result { + let token = TokenHandle::current_process()?; + let mut length = 0; + unsafe { GetTokenInformation(token.0, TokenUser, null_mut(), 0, &mut length) }; + if length == 0 { + return Err(io::Error::last_os_error()); + } + + let mut token_user = vec![0usize; (length as usize).div_ceil(size_of::())]; + unsafe { + check_bool(GetTokenInformation( + token.0, + TokenUser, + token_user.as_mut_ptr().cast(), + length, + &mut length, + ))?; + + let source_sid = (*token_user.as_ptr().cast::()).User.Sid; + if source_sid.is_null() || IsValidSid(source_sid) == 0 { + return Err(invalid_input( + "current process token contains an invalid user SID", + )); + } + + let sid = Self::with_byte_capacity(GetLengthSid(source_sid) as usize); + check_bool(CopySid( + (sid.storage.len() * size_of::()) as u32, + sid.as_psid(), + source_sid, + ))?; + Ok(sid) + } + } + + fn well_known(kind: i32) -> io::Result { + let mut length = 0; + unsafe { CreateWellKnownSid(kind, null_mut(), null_mut(), &mut length) }; + if length == 0 { + return Err(io::Error::last_os_error()); + } + + let mut sid = Self::with_byte_capacity(length as usize); + unsafe { + check_bool(CreateWellKnownSid( + kind, + null_mut(), + sid.storage.as_mut_ptr().cast(), + &mut length, + ))?; + if IsValidSid(sid.as_psid()) == 0 { + return Err(invalid_input("well-known SID API returned an invalid SID")); + } + } + Ok(sid) + } + + fn with_byte_capacity(bytes: usize) -> Self { + Self { + storage: vec![0usize; bytes.div_ceil(size_of::())], + } + } + + fn as_psid(&self) -> PSID { + self.storage.as_ptr().cast_mut().cast() + } + } + + struct SecurityDescriptor(PSECURITY_DESCRIPTOR); + + impl SecurityDescriptor { + fn get(handle: HANDLE) -> io::Result { + let mut descriptor = null_mut(); + unsafe { + check(GetSecurityInfo( + handle, + SE_FILE_OBJECT, + (OWNER_SECURITY_INFORMATION + | GROUP_SECURITY_INFORMATION + | DACL_SECURITY_INFORMATION) + as OBJECT_SECURITY_INFORMATION, + null_mut(), + null_mut(), + null_mut(), + null_mut(), + &mut descriptor, + ))?; + } + Ok(Self(descriptor)) + } + + fn owner(&self) -> io::Result { + let mut owner = null_mut(); + let mut owner_defaulted = 0; + unsafe { + check_bool(GetSecurityDescriptorOwner( + self.0, + &mut owner, + &mut owner_defaulted, + ))? + }; + if owner.is_null() { + return Err(invalid_input("path security descriptor has no owner")); + } + Ok(owner) + } + + fn to_sddl(&self) -> io::Result { + let mut value = null_mut(); + let mut length = 0; + unsafe { + check_bool(ConvertSecurityDescriptorToStringSecurityDescriptorW( + self.0, + SDDL_REVISION_1, + DACL_SECURITY_INFORMATION as OBJECT_SECURITY_INFORMATION, + &mut value, + &mut length, + ))?; + let mut units = std::slice::from_raw_parts(value, length as usize).to_vec(); + while units.last() == Some(&0) { + units.pop(); + } + let sddl = OsString::from_wide(&units).to_string_lossy().into_owned(); + LocalFree(value.cast()); + Ok(sddl) + } + } + } + + impl Drop for SecurityDescriptor { + fn drop(&mut self) { + unsafe { LocalFree(self.0.cast()) }; + } + } + + fn validate_owner(owner: PSID, current_user: &SidBuffer) -> io::Result<()> { + let system = SidBuffer::well_known(WinLocalSystemSid)?; + let administrators = SidBuffer::well_known(WinBuiltinAdministratorsSid)?; + let trusted = unsafe { + EqualSid(owner, current_user.as_psid()) != 0 + || EqualSid(owner, system.as_psid()) != 0 + || EqualSid(owner, administrators.as_psid()) != 0 + }; + if trusted { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "path owner is not the current user, LocalSystem, or Administrators", + )) + } + } + + struct ProtectedAcl { + // usize elements keep the ACL allocation aligned for all Win32 structures. + storage: Vec, + } + + impl ProtectedAcl { + fn new(sids: [&SidBuffer; 3], inheritance: u8) -> io::Result { + let bytes = size_of::() + + sids + .iter() + .map(|sid| { + // ACE_HEADER + ACCESS_MASK, followed by the SID. + 8 + unsafe { GetLengthSid(sid.as_psid()) as usize } + }) + .sum::(); + let mut storage = vec![0usize; bytes.div_ceil(size_of::())]; + let acl = storage.as_mut_ptr().cast::(); + unsafe { + check_bool(InitializeAcl(acl, bytes as u32, ACL_REVISION))?; + for sid in sids { + check_bool(AddAccessAllowedAceEx( + acl, + ACL_REVISION, + inheritance as u32, + PROTECTED_FILE_ACCESS, + sid.as_psid(), + ))?; + } + } + Ok(Self { storage }) + } + + fn as_ptr(&self) -> *const ACL { + self.storage.as_ptr().cast() + } + } + + #[cfg(test)] + fn sid_to_sddl(sid: PSID) -> io::Result { + let mut value = null_mut(); + unsafe { + check_bool( + windows_sys::Win32::Security::Authorization::ConvertSidToStringSidW( + sid, &mut value, + ), + )?; + let mut length = 0; + while *value.add(length) != 0 { + length += 1; + } + let sid = OsString::from_wide(std::slice::from_raw_parts(value, length)) + .to_string_lossy() + .into_owned(); + LocalFree(value.cast()); + Ok(sid) + } + } + + fn check(result: u32) -> io::Result<()> { + if result == 0 { + Ok(()) + } else { + Err(io::Error::from_raw_os_error(result as i32)) + } + } + + fn check_bool(result: i32) -> io::Result<()> { + if result != 0 { + Ok(()) + } else { + Err(io::Error::last_os_error()) + } + } + + fn invalid_input(message: &'static str) -> io::Error { + io::Error::new(io::ErrorKind::InvalidInput, message) + } + + #[cfg(test)] + mod tests { + use super::*; + use std::fs; + + #[test] + fn secure_path_protects_a_real_file_with_the_current_user_ace() { + let current_user = SidBuffer::current_user().unwrap(); + assert_ne!(unsafe { IsValidSid(current_user.as_psid()) }, 0); + let current_user_sddl = sid_to_sddl(current_user.as_psid()).unwrap(); + let path = std::env::temp_dir().join(format!( + "acl-prototype-{}-{}.txt", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::write(&path, "test").unwrap(); + + secure_path(&path).unwrap(); + secure_path(&path).unwrap(); + let sddl = inspect_path(&path).unwrap(); + + assert!( + !sddl.ends_with('\0'), + "SDDL must not retain the API terminator" + ); + assert_eq!( + sddl, + format!("D:P(A;;FA;;;{current_user_sddl})(A;;FA;;;SY)(A;;FA;;;BA)"), + ); + + fs::remove_file(path).unwrap(); + } + } +} + +#[cfg(all(test, not(windows)))] +mod tests { + use super::*; + use std::path::Path; + + #[test] + fn non_windows_calls_are_explicitly_unsupported() { + let path = Path::new("/tmp/acl-prototype"); + assert_eq!(backend(), "unsupported"); + assert_eq!( + secure_path(path).unwrap_err().kind(), + std::io::ErrorKind::Unsupported + ); + assert_eq!( + inspect_path(path).unwrap_err().kind(), + std::io::ErrorKind::Unsupported + ); + } +} diff --git a/prototypes/windows-acl/helper/Cargo.toml b/prototypes/windows-acl/helper/Cargo.toml new file mode 100644 index 0000000000..5957ddbfa9 --- /dev/null +++ b/prototypes/windows-acl/helper/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "acl-prototype-helper" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +acl-prototype-core = { path = "../core" } +serde_json = "1" diff --git a/prototypes/windows-acl/helper/src/main.rs b/prototypes/windows-acl/helper/src/main.rs new file mode 100644 index 0000000000..1d19f988c4 --- /dev/null +++ b/prototypes/windows-acl/helper/src/main.rs @@ -0,0 +1,40 @@ +use std::{env, path::Path, process::ExitCode}; + +use serde_json::json; + +fn main() -> ExitCode { + let args: Vec<_> = env::args_os().skip(1).collect(); + if args.len() == 1 && args[0] == "probe" { + println!( + "{}", + json!({ "version": 1, "backend": acl_prototype_core::backend() }) + ); + return ExitCode::SUCCESS; + } + if args.len() != 2 || (args[0] != "secure" && args[0] != "inspect") { + eprintln!( + "{}", + json!({ "version": 1, "error": "usage: acl-prototype-helper probe | secure | inspect " }) + ); + return ExitCode::from(2); + } + let path = Path::new(&args[1]); + let result = if args[0] == "secure" { + acl_prototype_core::secure_path(path).map(|()| None) + } else { + acl_prototype_core::inspect_path(path).map(Some) + }; + match result { + Ok(sddl) => { + println!("{}", json!({ "version": 1, "ok": true, "sddl": sddl })); + ExitCode::SUCCESS + } + Err(error) => { + eprintln!( + "{}", + json!({ "version": 1, "error": error.to_string(), "osCode": error.raw_os_error(), "kind": format!("{:?}", error.kind()) }) + ); + ExitCode::FAILURE + } + } +} diff --git a/prototypes/windows-acl/package-prototype.cjs b/prototypes/windows-acl/package-prototype.cjs index 154d993c22..c3a24e53f7 100644 --- a/prototypes/windows-acl/package-prototype.cjs +++ b/prototypes/windows-acl/package-prototype.cjs @@ -1,5 +1,6 @@ #!/usr/bin/env node "use strict"; +/* global __dirname, console, process */ const childProcess = require("node:child_process"); const fs = require("node:fs"); @@ -7,7 +8,7 @@ const path = require("node:path"); const ROOT = __dirname; const ARTIFACT_ROOT = path.join(ROOT, "artifacts"); -const VSCE = path.join(ROOT, "..", "..", "node_modules", ".bin", "vsce"); +const VSCE = require.resolve("@vscode/vsce/vsce"); const VARIANTS = new Set(["helper", "addon"]); const WINDOWS_ARCHITECTURES = ["x64", "arm64"]; @@ -33,7 +34,7 @@ function packageManifest(variant) { version: "0.0.0", publisher: "coder-prototype", description: "Experimental Windows ACL packaging prototype.", - engines: { vscode: "^1.95.0" }, + engines: { vscode: "^1.105.0" }, main: "./extension.cjs", activationEvents: [], }; @@ -85,8 +86,16 @@ function packagePrototype(variant, options = {}) { ); fs.rmSync(output, { force: true }); childProcess.execFileSync( - vsce, - ["package", "--no-dependencies", "--out", output], + process.execPath, + [ + vsce, + "package", + "--no-dependencies", + "--allow-missing-repository", + "--skip-license", + "--out", + output, + ], { cwd: assemblyDirectory, stdio: "inherit", diff --git a/prototypes/windows-acl/package-prototype.d.cts b/prototypes/windows-acl/package-prototype.d.cts new file mode 100644 index 0000000000..4d3dcded6f --- /dev/null +++ b/prototypes/windows-acl/package-prototype.d.cts @@ -0,0 +1,35 @@ +export type AclVariant = "helper" | "addon"; + +export interface PrototypeManifest { + name: string; + displayName: string; + version: string; + publisher: string; + description: string; + engines: { vscode: string }; + main: string; + activationEvents: []; +} + +export interface AssembleOptions { + root?: string; + artifactRoot?: string; +} + +export interface PackagePrototypeOptions extends AssembleOptions { + vsce?: string; +} + +export const ARTIFACT_ROOT: string; +export const VARIANTS: Set; +export const WINDOWS_ARCHITECTURES: string[]; +export function assemble( + variant: AclVariant, + options?: AssembleOptions, +): string; +export function packageManifest(variant: AclVariant): PrototypeManifest; +export function packagePrototype( + variant: AclVariant, + options?: PackagePrototypeOptions, +): string; +export function parseArguments(args: string[]): AclVariant; diff --git a/prototypes/windows-acl/stage.cjs b/prototypes/windows-acl/stage.cjs index 71061cb469..91d8d0d332 100644 --- a/prototypes/windows-acl/stage.cjs +++ b/prototypes/windows-acl/stage.cjs @@ -1,5 +1,6 @@ #!/usr/bin/env node "use strict"; +/* global __dirname, console, process */ const fs = require("node:fs"); const path = require("node:path"); diff --git a/prototypes/windows-acl/stage.d.cts b/prototypes/windows-acl/stage.d.cts new file mode 100644 index 0000000000..bb88a9e03b --- /dev/null +++ b/prototypes/windows-acl/stage.d.cts @@ -0,0 +1,23 @@ +export interface TargetDestination { + platform: "win32" | "linux"; + arch: "x64" | "arm64"; +} + +export type RustTarget = + | "x86_64-pc-windows-msvc" + | "aarch64-pc-windows-msvc" + | "x86_64-unknown-linux-gnu" + | "aarch64-unknown-linux-gnu"; + +export interface StageOptions { + root?: string; + artifactRoot?: string; +} + +export const ARTIFACT_ROOT: string; +export const TARGETS: Map; +export function nativeOutputs( + platform: TargetDestination["platform"], +): string[][]; +export function parseArguments(args: string[]): RustTarget; +export function stage(target: RustTarget, options?: StageOptions): string; diff --git a/prototypes/windows-acl/test/package-prototype.test.ts b/prototypes/windows-acl/test/package-prototype.test.ts index 20a52efbc1..63a5ffdb26 100644 --- a/prototypes/windows-acl/test/package-prototype.test.ts +++ b/prototypes/windows-acl/test/package-prototype.test.ts @@ -22,7 +22,7 @@ function writeArtifact( artifactRoot: string, arch: "x64" | "arm64", name: "acl-helper.exe" | "acl.node", - contents = name, + contents: string = name, ): void { const directory = path.join(artifactRoot, `win32-${arch}`); fs.mkdirSync(directory, { recursive: true }); @@ -107,14 +107,15 @@ describe("package-prototype", () => { }); }); -const projectRoot = path.resolve(import.meta.dirname, "..", "..", ".."); +const projectRoot = path.resolve(import.meta.dirname, "..", "..", "..", ".."); const realArtifactRoot = path.join( projectRoot, "prototypes", "windows-acl", "artifacts", ); -const stagedVariants = (["helper", "addon"] as const).filter((variant) => { +const variants = ["helper", "addon"] as const; +const stagedVariants = variants.filter((variant) => { const name = variant === "helper" ? "acl-helper.exe" : "acl.node"; return ["x64", "arm64"].every((arch) => fs @@ -126,7 +127,7 @@ const stagedVariants = (["helper", "addon"] as const).filter((variant) => { }); describe.runIf(stagedVariants.length === 2)("universal VSIX archives", () => { - it.each(stagedVariants)( + it.each(variants)( "packages the real staged %s payload with the expected manifest and contents", (variant) => { const output = packagePrototype(variant); diff --git a/prototypes/windows-acl/test/tsconfig.json b/prototypes/windows-acl/test/tsconfig.json new file mode 100644 index 0000000000..23a8ff0e64 --- /dev/null +++ b/prototypes/windows-acl/test/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "..", + "types": ["node", "vitest/globals"] + }, + "include": [".", "../*.d.cts"] +} diff --git a/prototypes/windows-acl/test/windows-native.test.ts b/prototypes/windows-acl/test/windows-native.test.ts index 74a24e9e9a..667aeb6f44 100644 --- a/prototypes/windows-acl/test/windows-native.test.ts +++ b/prototypes/windows-acl/test/windows-native.test.ts @@ -9,31 +9,9 @@ import bridgeModule from "../bridge.cjs"; const { createBridge } = bridgeModule; const execFile = promisify(execFileCallback); -const artifactRoot = path.resolve(import.meta.dirname, ".."); -const arch = - process.arch === "arm64" - ? "arm64" - : process.arch === "x64" - ? "x64" - : undefined; -const nativeArtifactsAvailable = - process.platform === "win32" && - arch !== undefined && - ["acl-helper.exe", "acl.node"].every((name) => - require("node:fs").existsSync( - path.join(artifactRoot, "artifacts", `win32-${arch}`, name), - ), - ); +const artifactRoot = path.resolve(import.meta.dirname, "..", "artifacts"); const temporaryDirectories: string[] = []; -async function temporaryDirectory(): Promise { - const directory = await fs.mkdtemp( - path.join(os.tmpdir(), "windows-acl-native-"), - ); - temporaryDirectories.push(directory); - return directory; -} - afterEach(async () => { await Promise.all( temporaryDirectories @@ -42,51 +20,69 @@ afterEach(async () => { ); }); -describe.runIf(nativeArtifactsAvailable)( +// A Windows run must fail, not skip, when native artifacts or OpenSSH are absent. +// Select Windows' OpenSSH explicitly rather than a Git/MSYS ssh from PATH. +describe.runIf(process.platform === "win32")( "staged Windows ACL implementations", () => { - it("apply equivalent protected ACLs repeatedly and retain OpenSSH Include support", async () => { - const directory = await temporaryDirectory(); - const parentConfig = path.join(directory, "parent.conf"); - const includedConfig = path.join(directory, "included.conf"); - await fs.writeFile( - parentConfig, - "Host acl-prototype\n User prototype-user\n", - ); - await fs.writeFile( - includedConfig, - "Include parent.conf\nHost *\n Compression no\n", + it("repair an included file and preserve the policy through an atomic rewrite", async () => { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), "windows-acl-native-"), ); - - // The disposable file starts permissive so both implementations must replace it. - await execFile("icacls", [includedConfig, "/grant", "*S-1-1-0:(F)"]); - - const bridges = ["helper", "addon"] as const; - const inspected = await Promise.all( - bridges.map(async (variant) => { - const bridge = createBridge({ - variant, - artifactRoot, - platform: "win32", - arch, - }); - await bridge.secure(includedConfig); - const first = await bridge.inspect(includedConfig); - await bridge.secure(includedConfig); - const second = await bridge.inspect(includedConfig); - expect(second).toBe(first); - const { stdout } = await execFile("ssh", [ - "-G", - "-F", - includedConfig, - "acl-prototype", - ]); - expect(stdout).toContain("user prototype-user"); - return first; - }), - ); - - expect(inspected[0]).toBe(inspected[1]); - }); + temporaryDirectories.push(root); + const systemRoot = process.env.SystemRoot; + if (!systemRoot) + throw new Error("SystemRoot is required for Windows tests"); + const ssh = path.join(systemRoot, "System32", "OpenSSH", "ssh.exe"); + const icacls = path.join(systemRoot, "System32", "icacls.exe"); + const descriptors: string[] = []; + for (const variant of ["helper", "addon"] as const) { + const directory = path.join(root, variant); + await fs.mkdir(directory); + const parentConfig = path.join(directory, "parent.conf"); + const includedConfig = path.join(directory, "included.conf"); + await fs.writeFile( + parentConfig, + `Include "${includedConfig.replaceAll("\\", "/")}"\n`, + ); + await fs.writeFile( + includedConfig, + "Host acl-prototype\n User prototype-user\n", + ); + const args = ["-G", "-F", parentConfig, "acl-prototype"]; + // icacls is only fixture setup; neither native implementation depends on it. + await execFile(icacls, [includedConfig, "/grant", "*S-1-1-0:(F)"]); + await expect(execFile(ssh, args)).rejects.toMatchObject({ + stderr: expect.stringMatching(/Bad owner or permissions/), + }); + const bridge = createBridge({ variant, artifactRoot }); + await bridge.secure(directory); + await bridge.secure(includedConfig); + const first = await bridge.inspect(includedConfig); + if (typeof first !== "string") { + throw new Error("Windows ACL inspection unexpectedly skipped"); + } + expect(first).toContain("D:P"); + await bridge.secure(includedConfig); + expect(await bridge.inspect(includedConfig)).toBe(first); + descriptors.push(first); + expect((await execFile(ssh, args)).stdout).toContain( + "user prototype-user", + ); + const replacement = path.join(directory, "replacement.tmp"); + await fs.writeFile( + replacement, + "Host acl-prototype\n User rewritten-user\n", + { flag: "wx" }, + ); + await bridge.secure(replacement); + await fs.rename(replacement, includedConfig); + expect(await bridge.inspect(includedConfig)).toBe(first); + expect((await execFile(ssh, args)).stdout).toContain( + "user rewritten-user", + ); + } + expect(descriptors[0]).toBe(descriptors[1]); + }, 60_000); }, ); diff --git a/prototypes/windows-acl/transport-probe.cjs b/prototypes/windows-acl/transport-probe.cjs new file mode 100644 index 0000000000..825c11c2f4 --- /dev/null +++ b/prototypes/windows-acl/transport-probe.cjs @@ -0,0 +1,59 @@ +/* global __dirname, console, process */ + +// Explicit development probe only: loads Linux artifacts to test the interfaces. +// The application bridge never loads native code on Linux or macOS. +const assert = require("node:assert/strict"); +const { execFileSync } = require("node:child_process"); +const { copyFileSync, mkdtempSync, rmSync } = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +async function main() { + if (process.platform !== "linux") { + throw new Error("This transport-only probe expects Linux build artifacts"); + } + const release = path.join(__dirname, "target", "release"); + const scratch = mkdtempSync(path.join(os.tmpdir(), "acl-transport-")); + try { + const binary = path.join(release, "acl-prototype-helper"); + const result = JSON.parse( + execFileSync(binary, ["probe"], { encoding: "utf8" }), + ); + assert.equal(result.version, 1); + assert.equal(result.backend, "unsupported"); + assert.throws( + () => execFileSync(binary, ["secure", scratch], { stdio: "pipe" }), + (error) => { + const response = JSON.parse(error.stderr.toString()); + return error.status === 1 && response.kind === "Unsupported"; + }, + ); + const modulePath = path.join(scratch, "acl.node"); + copyFileSync(path.join(release, "libacl_prototype_addon.so"), modulePath); + const addon = require(modulePath); + assert.equal(addon.probe(), "unsupported"); + await assert.rejects(addon.secure(scratch), /unsupported/); + await assert.rejects(addon.inspect(scratch), /unsupported/); + console.log( + JSON.stringify( + { + platform: process.platform, + node: process.versions.node, + electron: process.versions.electron ?? null, + napi: process.versions.napi, + helper: "probe and unsupported error passed", + addon: "load, probe, async unsupported errors passed", + windowsAclValidated: false, + }, + null, + 2, + ), + ); + } finally { + rmSync(scratch, { recursive: true, force: true }); + } +} +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/prototypes/windows-acl/tsconfig.json b/prototypes/windows-acl/tsconfig.json new file mode 100644 index 0000000000..6eed45a843 --- /dev/null +++ b/prototypes/windows-acl/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": ".", + "types": ["node"] + }, + "files": ["bridge.d.cts", "stage.d.cts", "package-prototype.d.cts"] +} From cfcf25772f2525039daa4e7d5668c1ccf644e585 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Mon, 14 Sep 2026 10:57:56 +0000 Subject: [PATCH 3/8] test(prototypes): validate Windows ACL semantics instead of SDDL spelling --- prototypes/windows-acl/core/src/lib.rs | 106 ++++++++++++++++++------- 1 file changed, 79 insertions(+), 27 deletions(-) diff --git a/prototypes/windows-acl/core/src/lib.rs b/prototypes/windows-acl/core/src/lib.rs index 74dae569c2..253b342042 100644 --- a/prototypes/windows-acl/core/src/lib.rs +++ b/prototypes/windows-acl/core/src/lib.rs @@ -405,27 +405,6 @@ mod windows { } } - #[cfg(test)] - fn sid_to_sddl(sid: PSID) -> io::Result { - let mut value = null_mut(); - unsafe { - check_bool( - windows_sys::Win32::Security::Authorization::ConvertSidToStringSidW( - sid, &mut value, - ), - )?; - let mut length = 0; - while *value.add(length) != 0 { - length += 1; - } - let sid = OsString::from_wide(std::slice::from_raw_parts(value, length)) - .to_string_lossy() - .into_owned(); - LocalFree(value.cast()); - Ok(sid) - } - } - fn check(result: u32) -> io::Result<()> { if result == 0 { Ok(()) @@ -450,12 +429,88 @@ mod windows { mod tests { use super::*; use std::fs; + use std::ptr::null_mut; + use windows_sys::Win32::Security::{ + ACCESS_ALLOWED_ACE, GetAce, GetSecurityDescriptorControl, GetSecurityDescriptorDacl, + SE_DACL_PROTECTED, + }; + + const ACCESS_ALLOWED_ACE_TYPE: u8 = 0; + + fn assert_protected_file_acl(path: &Path, expected_sids: [&SidBuffer; 3]) { + let path = WidePath::new(path).unwrap(); + let handle = FileHandle::open_for_read(&path).unwrap(); + let descriptor = SecurityDescriptor::get(handle.0).unwrap(); + let mut control = 0; + let mut revision = 0; + unsafe { + check_bool(GetSecurityDescriptorControl( + descriptor.0, + &mut control, + &mut revision, + )) + .unwrap(); + } + assert_ne!(control & SE_DACL_PROTECTED, 0, "DACL must be protected"); + + let mut dacl_present = 0; + let mut dacl = null_mut(); + let mut dacl_defaulted = 0; + unsafe { + check_bool(GetSecurityDescriptorDacl( + descriptor.0, + &mut dacl_present, + &mut dacl, + &mut dacl_defaulted, + )) + .unwrap(); + } + assert_ne!(dacl_present, 0, "security descriptor must contain a DACL"); + assert!( + !dacl.is_null(), + "security descriptor must contain a non-null DACL" + ); + assert_eq!( + unsafe { (*dacl).AceCount }, + 3, + "DACL must contain three ACEs" + ); + + for (index, expected_sid) in expected_sids.into_iter().enumerate() { + let mut ace = null_mut(); + unsafe { + check_bool(GetAce(dacl, index as u32, &mut ace)).unwrap(); + let ace = ace.cast::(); + assert_eq!( + (*ace).Header.AceType, + ACCESS_ALLOWED_ACE_TYPE, + "ACE {index} must allow access", + ); + assert_eq!( + (*ace).Header.AceFlags, + 0, + "file ACE {index} must not inherit", + ); + assert_eq!( + (*ace).Mask, + PROTECTED_FILE_ACCESS, + "ACE {index} must grant full control", + ); + let sid = std::ptr::addr_of!((*ace).SidStart).cast_mut().cast(); + assert_ne!( + EqualSid(sid, expected_sid.as_psid()), + 0, + "ACE {index} SID did not match", + ); + } + } + } #[test] fn secure_path_protects_a_real_file_with_the_current_user_ace() { let current_user = SidBuffer::current_user().unwrap(); - assert_ne!(unsafe { IsValidSid(current_user.as_psid()) }, 0); - let current_user_sddl = sid_to_sddl(current_user.as_psid()).unwrap(); + let system = SidBuffer::well_known(WinLocalSystemSid).unwrap(); + let administrators = SidBuffer::well_known(WinBuiltinAdministratorsSid).unwrap(); let path = std::env::temp_dir().join(format!( "acl-prototype-{}-{}.txt", std::process::id(), @@ -474,10 +529,7 @@ mod windows { !sddl.ends_with('\0'), "SDDL must not retain the API terminator" ); - assert_eq!( - sddl, - format!("D:P(A;;FA;;;{current_user_sddl})(A;;FA;;;SY)(A;;FA;;;BA)"), - ); + assert_protected_file_acl(&path, [¤t_user, &system, &administrators]); fs::remove_file(path).unwrap(); } From 22a751efc2fc859641e387921e115d01829db1ac Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Mon, 14 Sep 2026 11:07:36 +0000 Subject: [PATCH 4/8] test(prototypes): require real universal payload archive checks --- .github/workflows/windows-acl-prototypes.yaml | 2 + prototypes/windows-acl/README.md | 38 +++++++++++++++---- .../test/package-prototype.test.ts | 17 +++++---- 3 files changed, 42 insertions(+), 15 deletions(-) diff --git a/.github/workflows/windows-acl-prototypes.yaml b/.github/workflows/windows-acl-prototypes.yaml index 1214cadd58..08634035a1 100644 --- a/.github/workflows/windows-acl-prototypes.yaml +++ b/.github/workflows/windows-acl-prototypes.yaml @@ -91,6 +91,8 @@ jobs: path: prototypes/windows-acl/artifacts/win32-arm64 - name: Package and inspect both universal VSIXs run: pnpm exec vitest run --config prototypes/windows-acl/test/vitest.config.mts + env: + ACL_REQUIRE_UNIVERSAL: "1" - name: Upload experimental universal packages uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: diff --git a/prototypes/windows-acl/README.md b/prototypes/windows-acl/README.md index 3b936e3458..d561c277ac 100644 --- a/prototypes/windows-acl/README.md +++ b/prototypes/windows-acl/README.md @@ -26,7 +26,8 @@ variant, with both Windows architectures in the same universal extension. ## Results on September 14, 2026 -Performed in a Linux workspace using Rust 1.98.1: +Performed using Rust 1.98.1. Native Windows results are from Actions run +`34835778683`; package inspection was repeated locally with its real payloads: | Check | Result | | ----------------------------------------------- | -------------------------------------------------- | @@ -35,11 +36,11 @@ Performed in a Linux workspace using Rust 1.98.1: | Clippy, all targets, warnings denied | Passed on Linux and Windows source checks | | Windows MSVC source/test check, x64 | Passed; not linked or executed | | Windows MSVC source/test check, ARM64 | Passed; not linked or executed | -| Bridge/staging/assembly tests | 25 passed | -| Windows OpenSSH integration | Not run on Linux | -| Actual Windows universal VSIX archives | Not built; Windows binary artifacts missing | +| Bridge/staging/assembly tests | 27 passed, 1 Windows-only skip with real payloads | +| Windows OpenSSH integration | Passed on Windows x64 and ARM64 | +| Actual Windows universal VSIX archives | Passed locally with both real Windows payloads | | macOS/Linux native bypass | Passed using injected platform/architecture values | -| Actual macOS runtime | Not tested | +| Actual macOS runtime | Bridge bypass tests passed on macos-15 | | Node 24.15.0 transport probe | Passed | | Electron 37.10.3 / Node 22.21.1 transport probe | Passed | | Electron 42.5.1 / Node 24.17.0 transport probe | Passed | @@ -113,17 +114,38 @@ Both approaches can preserve a universal VSIX. The helper avoids loading native code into the extension host and provides a process timeout, at the cost of a subprocess interface. The addon avoids process launch and successfully loaded across the tested Electron versions, but loads the native implementation into -the host process. Neither option has been proven operationally superior on real -Windows yet. The macOS keyring history argues for strict platform gating and +the host process. Both passed the same Windows x64/ARM64 tests under Node 22 and Electron +37/42. Neither has been proven operationally superior on end-user machines. The macOS keyring history argues for strict platform gating and package-level regression tests, not a claim that shipping binaries is risk-free. +## Measured payloads + +The experimental universal packages built from run `34835778683` contain: + +| Payload | Helper | Addon | +| --------------------------- | --------------------- | --------------------- | +| Windows x64 | 186 KiB | 291.5 KiB | +| Windows ARM64 | 178.5 KiB | 268.5 KiB | +| Universal VSIX (compressed) | approximately 191 KiB | approximately 247 KiB | + +These are standalone prototype package sizes, not the production extension size. +`objdump -p` showed that both x64 variants import `VCRUNTIME140.dll` and Universal +CRT API DLLs. Hosted-runner success therefore does not establish that either +payload is self-contained on a clean user machine. ARM64 DLL inspection remains +outstanding. No runtime-linking or redistribution choice has been made. + +The first native run exposed a test-only SDDL spelling assumption (SID aliases and +auto-inheritance descriptor flags); the test now inspects actual protection and +ACE semantics. The first package job exposed an incorrect artifact lookup path; +the package job now requires both architectures instead of silently skipping. + ## Prototype limitations - Existing-path ACL setter only; no secure-at-creation or atomic writer API. - Final reparse-point rejection and owner validation use the opened handle; parent directory chains and hard-link safety are not fully validated. - Does not integrate generated-file migration or user-config ACL preservation. -- No Windows runtime, real ARM64, signing, or enterprise-policy validation yet. +- No signing, clean end-user Windows, or enterprise-policy validation yet. - No production CI/release workflow changes were made. - Package assembly unit tests use synthetic fixture bytes; those are not Windows binaries and are never presented as functional native VSIXs. diff --git a/prototypes/windows-acl/test/package-prototype.test.ts b/prototypes/windows-acl/test/package-prototype.test.ts index 63a5ffdb26..e42329e385 100644 --- a/prototypes/windows-acl/test/package-prototype.test.ts +++ b/prototypes/windows-acl/test/package-prototype.test.ts @@ -107,13 +107,7 @@ describe("package-prototype", () => { }); }); -const projectRoot = path.resolve(import.meta.dirname, "..", "..", "..", ".."); -const realArtifactRoot = path.join( - projectRoot, - "prototypes", - "windows-acl", - "artifacts", -); +const realArtifactRoot = path.resolve(import.meta.dirname, "..", "artifacts"); const variants = ["helper", "addon"] as const; const stagedVariants = variants.filter((variant) => { const name = variant === "helper" ? "acl-helper.exe" : "acl.node"; @@ -126,6 +120,15 @@ const stagedVariants = variants.filter((variant) => { ); }); +if ( + process.env.ACL_REQUIRE_UNIVERSAL === "1" && + stagedVariants.length !== variants.length +) { + throw new Error( + "Both Windows architectures and both interfaces are required", + ); +} + describe.runIf(stagedVariants.length === 2)("universal VSIX archives", () => { it.each(variants)( "packages the real staged %s payload with the expected manifest and contents", From cd0734557bcd330c61fa3a8f769e1dd97a9e2dda Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Mon, 14 Sep 2026 11:47:59 +0000 Subject: [PATCH 5/8] chore(prototypes): measure ACL maintenance and native build tradeoffs --- .github/workflows/windows-acl-prototypes.yaml | 53 +- prototypes/windows-acl/Cargo.lock | 113 +++++ prototypes/windows-acl/Cargo.toml | 2 +- prototypes/windows-acl/addon/Cargo.toml | 4 + prototypes/windows-acl/addon/src/lib.rs | 11 +- .../windows-acl/core-windows/Cargo.toml | 16 + .../windows-acl/core-windows/src/lib.rs | 380 ++++++++++++++ prototypes/windows-acl/core/src/lib.rs | 476 +++++++++++------- prototypes/windows-acl/experiment-report.cjs | 142 ++++++ .../windows-acl/experiment-report.d.cts | 17 + prototypes/windows-acl/experiment.cjs | 437 ++++++++++++++++ prototypes/windows-acl/experiment.d.cts | 38 ++ prototypes/windows-acl/helper/Cargo.toml | 4 + prototypes/windows-acl/helper/src/main.rs | 14 +- .../test/experiment-report.test.ts | 97 ++++ .../windows-acl/test/experiment.test.ts | 169 +++++++ .../windows-acl/test/windows-native.test.ts | 17 + prototypes/windows-acl/tsconfig.json | 8 +- 18 files changed, 1787 insertions(+), 211 deletions(-) create mode 100644 prototypes/windows-acl/core-windows/Cargo.toml create mode 100644 prototypes/windows-acl/core-windows/src/lib.rs create mode 100644 prototypes/windows-acl/experiment-report.cjs create mode 100644 prototypes/windows-acl/experiment-report.d.cts create mode 100644 prototypes/windows-acl/experiment.cjs create mode 100644 prototypes/windows-acl/experiment.d.cts create mode 100644 prototypes/windows-acl/test/experiment-report.test.ts create mode 100644 prototypes/windows-acl/test/experiment.test.ts diff --git a/.github/workflows/windows-acl-prototypes.yaml b/.github/workflows/windows-acl-prototypes.yaml index 08634035a1..d88ba6a70a 100644 --- a/.github/workflows/windows-acl-prototypes.yaml +++ b/.github/workflows/windows-acl-prototypes.yaml @@ -28,7 +28,7 @@ jobs: arch: arm64 target: aarch64-pc-windows-msvc runs-on: ${{ matrix.os }} - timeout-minutes: 30 + timeout-minutes: 60 defaults: run: shell: bash @@ -37,6 +37,12 @@ jobs: with: persist-credentials: false - uses: ./.github/actions/setup + - name: Check out comparison baseline + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: 22a751efc2fc859641e387921e115d01829db1ac + path: .acl-baseline + persist-credentials: false - name: Verify native runtime architecture run: node -e 'if (process.arch !== "${{ matrix.arch }}") throw new Error(process.arch); console.log(process.versions)' - name: Install pinned Rust toolchain @@ -46,28 +52,23 @@ jobs: - name: Format and lint native code run: | cargo +1.98.1 fmt --all --manifest-path prototypes/windows-acl/Cargo.toml --check - cargo +1.98.1 clippy --workspace --all-targets --target ${{ matrix.target }} --manifest-path prototypes/windows-acl/Cargo.toml --locked -- -D warnings + cargo +1.98.1 clippy --workspace --all-features --all-targets --target ${{ matrix.target }} --manifest-path prototypes/windows-acl/Cargo.toml --locked -- -D warnings - name: Test native ACL core - run: cargo +1.98.1 test -p acl-prototype-core --target ${{ matrix.target }} --manifest-path prototypes/windows-acl/Cargo.toml --locked - - name: Build and stage both interfaces - run: | - cargo +1.98.1 build --release --workspace --target ${{ matrix.target }} --manifest-path prototypes/windows-acl/Cargo.toml --locked - node prototypes/windows-acl/stage.cjs --target ${{ matrix.target }} - - name: Test real Windows OpenSSH and both interfaces in Node - run: pnpm exec vitest run --config prototypes/windows-acl/test/vitest.config.mts - - name: Test both interfaces in current Electron - run: pnpm exec electron "$(node -p 'require("node:path").resolve("node_modules/vitest/vitest.mjs")')" run --config prototypes/windows-acl/test/vitest.config.mts + run: cargo +1.98.1 test -p acl-prototype-core -p acl-prototype-core-windows --target ${{ matrix.target }} --manifest-path prototypes/windows-acl/Cargo.toml --locked + - name: Resolve Electron 37 executable + run: pnpm dlx electron@37.10.3 -e 'require("node:fs").appendFileSync(process.env.GITHUB_ENV, "ACL_ELECTRON37_PATH=" + process.execPath + "\n")' env: ELECTRON_RUN_AS_NODE: "1" - - name: Test both interfaces in Electron 37 - run: pnpm dlx electron@37.10.3 "$(node -p 'require("node:path").resolve("node_modules/vitest/vitest.mjs")')" run --config prototypes/windows-acl/test/vitest.config.mts + - name: Compare source, size profiles, and CRT linkage + run: node prototypes/windows-acl/experiment.cjs --target ${{ matrix.target }} env: - ELECTRON_RUN_AS_NODE: "1" - - name: Upload native artifacts + ACL_BASELINE_ROOT: ${{ github.workspace }}/.acl-baseline/prototypes/windows-acl + - name: Upload experimental native payloads and reports + if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: acl-native-${{ matrix.arch }} - path: prototypes/windows-acl/artifacts/win32-${{ matrix.arch }}/ + name: acl-experiments-${{ matrix.arch }} + path: prototypes/windows-acl/artifacts/experiments/ if-no-files-found: error retention-days: 7 @@ -83,21 +84,21 @@ jobs: - uses: ./.github/actions/setup - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: acl-native-x64 - path: prototypes/windows-acl/artifacts/win32-x64 + name: acl-experiments-x64 + path: prototypes/windows-acl/artifacts/experiments - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: acl-native-arm64 - path: prototypes/windows-acl/artifacts/win32-arm64 - - name: Package and inspect both universal VSIXs - run: pnpm exec vitest run --config prototypes/windows-acl/test/vitest.config.mts - env: - ACL_REQUIRE_UNIVERSAL: "1" + name: acl-experiments-arm64 + path: prototypes/windows-acl/artifacts/experiments + - name: Package and inspect each universal comparison payload + run: node prototypes/windows-acl/experiment-report.cjs - name: Upload experimental universal packages uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: acl-universal-prototypes - path: prototypes/windows-acl/artifacts/*.vsix + path: | + prototypes/windows-acl/artifacts/experiments/**/*.vsix + prototypes/windows-acl/artifacts/experiments/*.json if-no-files-found: error retention-days: 7 diff --git a/prototypes/windows-acl/Cargo.lock b/prototypes/windows-acl/Cargo.lock index e7459a6dba..96a4a9706d 100644 --- a/prototypes/windows-acl/Cargo.lock +++ b/prototypes/windows-acl/Cargo.lock @@ -7,6 +7,7 @@ name = "acl-prototype-addon" version = "0.1.0" dependencies = [ "acl-prototype-core", + "acl-prototype-core-windows", "napi", "napi-build", "napi-derive", @@ -19,11 +20,19 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "acl-prototype-core-windows" +version = "0.1.0" +dependencies = [ + "windows", +] + [[package]] name = "acl-prototype-helper" version = "0.1.0" dependencies = [ "acl-prototype-core", + "acl-prototype-core-windows", "serde_json", ] @@ -352,12 +361,107 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -367,6 +471,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/prototypes/windows-acl/Cargo.toml b/prototypes/windows-acl/Cargo.toml index 759c630612..cf09371914 100644 --- a/prototypes/windows-acl/Cargo.toml +++ b/prototypes/windows-acl/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["core", "helper", "addon"] +members = ["core", "core-windows", "helper", "addon"] resolver = "2" [profile.release] diff --git a/prototypes/windows-acl/addon/Cargo.toml b/prototypes/windows-acl/addon/Cargo.toml index 695178ed7a..5b5618e4b1 100644 --- a/prototypes/windows-acl/addon/Cargo.toml +++ b/prototypes/windows-acl/addon/Cargo.toml @@ -7,8 +7,12 @@ publish = false [lib] crate-type = ["cdylib"] +[features] +projection = ["dep:acl-prototype-core-windows"] + [dependencies] acl-prototype-core = { path = "../core" } +acl-prototype-core-windows = { path = "../core-windows", optional = true } napi = { version = "=3.12.4", default-features = false, features = ["napi8"] } napi-derive = "=3.6.5" diff --git a/prototypes/windows-acl/addon/src/lib.rs b/prototypes/windows-acl/addon/src/lib.rs index c6abf53b45..dcceb5aa45 100644 --- a/prototypes/windows-acl/addon/src/lib.rs +++ b/prototypes/windows-acl/addon/src/lib.rs @@ -1,3 +1,8 @@ +#[cfg(not(feature = "projection"))] +use acl_prototype_core as core; +#[cfg(feature = "projection")] +use acl_prototype_core_windows as core; + use std::path::PathBuf; use napi::{bindgen_prelude::AsyncTask, Env, Error, Result, Task}; @@ -5,7 +10,7 @@ use napi_derive::napi; #[napi] pub fn probe() -> String { - acl_prototype_core::backend().to_owned() + core::backend().to_owned() } pub struct SecureTask { @@ -17,7 +22,7 @@ impl Task for SecureTask { type JsValue = (); fn compute(&mut self) -> Result<()> { - acl_prototype_core::secure_path(&self.path).map_err(|error| { + core::secure_path(&self.path).map_err(|error| { Error::from_reason(format!("{} (osCode={:?})", error, error.raw_os_error())) }) } @@ -43,7 +48,7 @@ impl Task for InspectTask { type JsValue = String; fn compute(&mut self) -> Result { - acl_prototype_core::inspect_path(&self.path).map_err(|error| { + core::inspect_path(&self.path).map_err(|error| { Error::from_reason(format!("{} (osCode={:?})", error, error.raw_os_error())) }) } diff --git a/prototypes/windows-acl/core-windows/Cargo.toml b/prototypes/windows-acl/core-windows/Cargo.toml new file mode 100644 index 0000000000..15004c6b75 --- /dev/null +++ b/prototypes/windows-acl/core-windows/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "acl-prototype-core-windows" +version = "0.1.0" +edition = "2024" +rust-version = "1.85" +description = "Narrow Windows ACL prototype core using the windows projection" +publish = false + +[target.'cfg(windows)'.dependencies] +windows = { version = "=0.62.2", default-features = false, features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_Security_Authorization", + "Win32_Storage_FileSystem", + "Win32_System_Threading", +] } diff --git a/prototypes/windows-acl/core-windows/src/lib.rs b/prototypes/windows-acl/core-windows/src/lib.rs new file mode 100644 index 0000000000..f529dc40ec --- /dev/null +++ b/prototypes/windows-acl/core-windows/src/lib.rs @@ -0,0 +1,380 @@ +//! Narrow Windows ACL prototype core using the `windows` projection. + +/// Applies the prototype protected DACL to an existing absolute path. +/// +/// On Windows, the final object must not be a reparse point and must be owned by +/// the current user, LocalSystem, or the built-in Administrators group. +#[cfg(windows)] +pub fn secure_path(path: &std::path::Path) -> std::io::Result<()> { + windows::secure_path(path) +} + +/// Returns the protected DACL as an SDDL string. +#[cfg(windows)] +pub fn inspect_path(path: &std::path::Path) -> std::io::Result { + windows::inspect_path(path) +} + +/// Identifies the platform implementation. +#[cfg(windows)] +pub fn backend() -> &'static str { + "windows" +} + +/// This prototype has no non-Windows permission emulation. +#[cfg(not(windows))] +pub fn secure_path(_path: &std::path::Path) -> std::io::Result<()> { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "Windows ACL protection is unsupported on this platform", + )) +} + +/// This prototype has no non-Windows security descriptor inspection. +#[cfg(not(windows))] +pub fn inspect_path(_path: &std::path::Path) -> std::io::Result { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "Windows ACL inspection is unsupported on this platform", + )) +} + +/// Identifies the unsupported implementation. +#[cfg(not(windows))] +pub fn backend() -> &'static str { + "unsupported" +} + +#[cfg(windows)] +mod windows { + use std::fs::{File, OpenOptions}; + use std::io; + use std::os::windows::fs::OpenOptionsExt; + use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; + use std::path::Path; + use std::ptr::null_mut; + use windows::Win32::Foundation::{HANDLE, HLOCAL, LocalFree, WIN32_ERROR}; + use windows::Win32::Security::Authorization::{ + ConvertSecurityDescriptorToStringSecurityDescriptorW, ConvertSidToStringSidW, + ConvertStringSecurityDescriptorToSecurityDescriptorW, GetSecurityInfo, SE_FILE_OBJECT, + SetSecurityInfo, + }; + use windows::Win32::Security::{ + ACL, CopySid, DACL_SECURITY_INFORMATION, EqualSid, GROUP_SECURITY_INFORMATION, + GetLengthSid, GetSecurityDescriptorDacl, GetSecurityDescriptorOwner, GetTokenInformation, + IsValidSid, IsWellKnownSid, OWNER_SECURITY_INFORMATION, + PROTECTED_DACL_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID, TOKEN_QUERY, TOKEN_USER, + TokenUser, WinBuiltinAdministratorsSid, WinLocalSystemSid, + }; + use windows::Win32::Storage::FileSystem::{ + BY_HANDLE_FILE_INFORMATION, FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_REPARSE_POINT, + FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_DELETE, + FILE_SHARE_READ, FILE_SHARE_WRITE, GetFileInformationByHandle, READ_CONTROL, WRITE_DAC, + }; + use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + use windows::core::{PCWSTR, PWSTR}; + + const SDDL_REVISION_1: u32 = 1; + + pub fn secure_path(path: &Path) -> io::Result<()> { + let file = open(path, (READ_CONTROL | WRITE_DAC).0)?; + let facts = facts(&file)?; + reject_reparse(facts.dwFileAttributes)?; + + let user = current_user_sid()?; + let existing = SecurityDescriptor::get(&file)?; + validate_owner(existing.owner()?, user.as_psid())?; + + let inheritance = if facts.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY.0 != 0 { + "OICI" + } else { + "" + }; + let sddl = format!( + "D:P(A;{inheritance};FA;;;{})(A;{inheritance};FA;;;SY)(A;{inheritance};FA;;;BA)", + user.to_sddl()? + ); + let protected = SecurityDescriptor::from_sddl(&sddl)?; + let dacl = protected.dacl()?; + + check(unsafe { + SetSecurityInfo( + HANDLE(file.as_raw_handle()), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, + None, + None, + Some(dacl), + None, + ) + }) + } + + pub fn inspect_path(path: &Path) -> io::Result { + let file = open(path, READ_CONTROL.0)?; + SecurityDescriptor::get(&file)?.to_sddl() + } + + fn open(path: &Path, access: u32) -> io::Result { + if !path.is_absolute() { + return Err(invalid_input("path must be absolute")); + } + OpenOptions::new() + .access_mode(access) + .share_mode((FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE).0) + .custom_flags((FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT).0) + .open(path) + } + + fn facts(file: &File) -> io::Result { + let mut facts = BY_HANDLE_FILE_INFORMATION::default(); + unsafe { GetFileInformationByHandle(HANDLE(file.as_raw_handle()), &mut facts) } + .map_err(windows_error)?; + Ok(facts) + } + + fn reject_reparse(attributes: u32) -> io::Result<()> { + if attributes & FILE_ATTRIBUTE_REPARSE_POINT.0 != 0 { + Err(invalid_input( + "final path component must not be a reparse point", + )) + } else { + Ok(()) + } + } + + struct Token(OwnedHandle); + + impl Token { + fn current_process() -> io::Result { + let mut handle = HANDLE::default(); + unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut handle) } + .map_err(windows_error)?; + Ok(Self(unsafe { OwnedHandle::from_raw_handle(handle.0) })) + } + } + + struct Sid(Vec); + + impl Sid { + fn as_psid(&self) -> PSID { + PSID(self.0.as_ptr().cast_mut().cast()) + } + + fn to_sddl(&self) -> io::Result { + let mut value = PWSTR::null(); + unsafe { ConvertSidToStringSidW(self.as_psid(), &mut value) }.map_err(windows_error)?; + if value.0.is_null() { + return Err(io::Error::last_os_error()); + } + let result = unsafe { value.to_string() } + .map_err(|_| invalid_input("Windows returned invalid SID text")); + unsafe { LocalFree(Some(HLOCAL(value.0.cast()))) }; + result + } + } + + fn current_user_sid() -> io::Result { + let token = Token::current_process()?; + let mut length = 0; + let _ = unsafe { + GetTokenInformation( + HANDLE(token.0.as_raw_handle()), + TokenUser, + None, + 0, + &mut length, + ) + }; + if length == 0 { + return Err(io::Error::last_os_error()); + } + + let mut storage = vec![0usize; (length as usize).div_ceil(std::mem::size_of::())]; + unsafe { + GetTokenInformation( + HANDLE(token.0.as_raw_handle()), + TokenUser, + Some(storage.as_mut_ptr().cast()), + length, + &mut length, + ) + .map_err(windows_error)?; + let source = (*storage.as_ptr().cast::()).User.Sid; + if source.0.is_null() || !IsValidSid(source).as_bool() { + return Err(invalid_input( + "current process token contains an invalid user SID", + )); + } + let bytes = GetLengthSid(source) as usize; + let mut sid = vec![0usize; bytes.div_ceil(std::mem::size_of::())]; + CopySid( + (sid.len() * std::mem::size_of::()) as u32, + PSID(sid.as_mut_ptr().cast()), + source, + ) + .map_err(windows_error)?; + Ok(Sid(sid)) + } + } + + struct SecurityDescriptor(PSECURITY_DESCRIPTOR); + + impl SecurityDescriptor { + fn get(file: &File) -> io::Result { + let mut descriptor = PSECURITY_DESCRIPTOR::default(); + check(unsafe { + GetSecurityInfo( + HANDLE(file.as_raw_handle()), + SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION + | GROUP_SECURITY_INFORMATION + | DACL_SECURITY_INFORMATION, + None, + None, + None, + None, + Some(&mut descriptor), + ) + })?; + Ok(Self(descriptor)) + } + + fn from_sddl(sddl: &str) -> io::Result { + let value: Vec = sddl.encode_utf16().chain(Some(0)).collect(); + let mut descriptor = PSECURITY_DESCRIPTOR::default(); + unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + PCWSTR(value.as_ptr()), + SDDL_REVISION_1, + &mut descriptor, + None, + ) + } + .map_err(windows_error)?; + Ok(Self(descriptor)) + } + + fn owner(&self) -> io::Result { + let mut owner = PSID::default(); + let mut defaulted = false.into(); + unsafe { GetSecurityDescriptorOwner(self.0, &mut owner, &mut defaulted) } + .map_err(windows_error)?; + if owner.0.is_null() || !unsafe { IsValidSid(owner).as_bool() } { + Err(invalid_input( + "path security descriptor has an invalid owner", + )) + } else { + Ok(owner) + } + } + + fn dacl(&self) -> io::Result<&ACL> { + let mut present = false.into(); + let mut dacl = null_mut(); + let mut defaulted = false.into(); + unsafe { GetSecurityDescriptorDacl(self.0, &mut present, &mut dacl, &mut defaulted) } + .map_err(windows_error)?; + if !present.as_bool() || dacl.is_null() { + return Err(invalid_input( + "security descriptor must contain a non-null DACL", + )); + } + Ok(unsafe { &*dacl }) + } + + fn to_sddl(&self) -> io::Result { + let mut value = PWSTR::null(); + let mut length = 0; + unsafe { + ConvertSecurityDescriptorToStringSecurityDescriptorW( + self.0, + SDDL_REVISION_1, + DACL_SECURITY_INFORMATION, + &mut value, + Some(&mut length), + ) + } + .map_err(windows_error)?; + if value.0.is_null() { + return Err(io::Error::last_os_error()); + } + let result = unsafe { value.to_string() } + .map_err(|_| invalid_input("Windows returned invalid SDDL")); + unsafe { LocalFree(Some(HLOCAL(value.0.cast()))) }; + result + } + } + + impl Drop for SecurityDescriptor { + fn drop(&mut self) { + unsafe { LocalFree(Some(HLOCAL(self.0.0.cast()))) }; + } + } + + fn validate_owner(owner: PSID, current_user: PSID) -> io::Result<()> { + let trusted = unsafe { + // The generated projection maps EqualSid's false BOOL result to an error. + EqualSid(owner, current_user).is_ok() + || IsWellKnownSid(owner, WinLocalSystemSid).as_bool() + || IsWellKnownSid(owner, WinBuiltinAdministratorsSid).as_bool() + }; + if trusted { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "path owner is not the current user, LocalSystem, or Administrators", + )) + } + } + + fn check(result: WIN32_ERROR) -> io::Result<()> { + if result.is_ok() { + Ok(()) + } else { + Err(io::Error::from_raw_os_error(result.0 as i32)) + } + } + + fn windows_error(error: windows::core::Error) -> io::Error { + let code = error.code().0 as u32; + if (code >> 16) & 0x1fff == 7 { + io::Error::from_raw_os_error((code & 0xffff) as i32) + } else { + io::Error::other(error.message()) + } + } + + fn invalid_input(message: &'static str) -> io::Error { + io::Error::new(io::ErrorKind::InvalidInput, message) + } + + #[cfg(test)] + mod tests { + use super::*; + use std::fs; + + #[test] + fn secure_path_applies_an_idempotent_protected_dacl() { + let path = std::env::temp_dir().join(format!( + "acl-prototype-windows-{}-{}.conf", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(), + )); + fs::write(&path, "Host acl-prototype\n").unwrap(); + + secure_path(&path).unwrap(); + let first = inspect_path(&path).unwrap(); + secure_path(&path).unwrap(); + let second = inspect_path(&path).unwrap(); + + assert!(first.contains("D:P"), "DACL must be protected: {first}"); + assert_eq!(second, first, "secure_path must be idempotent"); + fs::remove_file(path).unwrap(); + } + } +} diff --git a/prototypes/windows-acl/core/src/lib.rs b/prototypes/windows-acl/core/src/lib.rs index 253b342042..b0588fb21b 100644 --- a/prototypes/windows-acl/core/src/lib.rs +++ b/prototypes/windows-acl/core/src/lib.rs @@ -56,138 +56,105 @@ pub fn backend() -> &'static str { #[cfg(windows)] mod windows { use std::ffi::OsString; + use std::fs::{File, OpenOptions}; use std::io; - use std::mem::size_of; - use std::os::windows::ffi::{OsStrExt, OsStringExt}; + use std::os::windows::ffi::OsStringExt; + use std::os::windows::fs::OpenOptionsExt; + use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle, RawHandle}; use std::path::Path; use std::ptr::{null, null_mut}; - use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE, LocalFree}; + use windows_sys::Win32::Foundation::{HANDLE, LocalFree}; use windows_sys::Win32::Security::Authorization::{ - ConvertSecurityDescriptorToStringSecurityDescriptorW, GetSecurityInfo, SE_FILE_OBJECT, + ConvertSecurityDescriptorToStringSecurityDescriptorW, ConvertSidToStringSidW, + ConvertStringSecurityDescriptorToSecurityDescriptorW, GetSecurityInfo, SE_FILE_OBJECT, SetSecurityInfo, }; use windows_sys::Win32::Security::{ - ACL, ACL_REVISION, AddAccessAllowedAceEx, CopySid, CreateWellKnownSid, DACL_SECURITY_INFORMATION, EqualSid, GROUP_SECURITY_INFORMATION, GetLengthSid, - GetSecurityDescriptorOwner, GetTokenInformation, InitializeAcl, IsValidSid, - OBJECT_SECURITY_INFORMATION, OWNER_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID, - TOKEN_QUERY, TOKEN_USER, TokenUser, WinBuiltinAdministratorsSid, WinLocalSystemSid, + GetSecurityDescriptorDacl, GetSecurityDescriptorOwner, GetTokenInformation, IsValidSid, + OWNER_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID, TOKEN_QUERY, TOKEN_USER, TokenUser, + WinBuiltinAdministratorsSid, WinLocalSystemSid, }; use windows_sys::Win32::Storage::FileSystem::{ - BY_HANDLE_FILE_INFORMATION, CreateFileW, FILE_ALL_ACCESS, FILE_ATTRIBUTE_DIRECTORY, - FILE_ATTRIBUTE_REPARSE_POINT, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, - FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, GetFileInformationByHandle, - OPEN_EXISTING, READ_CONTROL, WRITE_DAC, + BY_HANDLE_FILE_INFORMATION, FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_REPARSE_POINT, + FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_DELETE, + FILE_SHARE_READ, FILE_SHARE_WRITE, GetFileInformationByHandle, READ_CONTROL, WRITE_DAC, }; use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; const PROTECTED_DACL_SECURITY_INFORMATION: u32 = 0x8000_0000; - const PROTECTED_FILE_ACCESS: u32 = FILE_ALL_ACCESS; - const CONTAINER_INHERIT_ACE: u8 = 0x02; - const OBJECT_INHERIT_ACE: u8 = 0x01; const SDDL_REVISION_1: u32 = 1; pub fn secure_path(path: &Path) -> io::Result<()> { - let path = WidePath::new(path)?; - let handle = FileHandle::open_for_write(&path)?; - let attributes = handle.attributes()?; - if attributes & FILE_ATTRIBUTE_REPARSE_POINT != 0 { - return Err(invalid_input( - "final path component must not be a reparse point", - )); - } + let handle = open_path(path, READ_CONTROL | WRITE_DAC)?; + let attributes = attributes(&handle)?; + reject_reparse_point(attributes)?; let current_user = SidBuffer::current_user()?; - let descriptor = SecurityDescriptor::get(handle.0)?; - validate_owner(descriptor.owner()?, ¤t_user)?; + let system = SidBuffer::well_known(WinLocalSystemSid)?; + let administrators = SidBuffer::well_known(WinBuiltinAdministratorsSid)?; + let descriptor = SecurityDescriptor::get(handle.as_raw_handle().cast())?; + validate_owner( + descriptor.owner()?, + [¤t_user, &system, &administrators], + )?; let inheritance = if attributes & FILE_ATTRIBUTE_DIRECTORY != 0 { - CONTAINER_INHERIT_ACE | OBJECT_INHERIT_ACE + "OICI" } else { - 0 + "" }; - let system = SidBuffer::well_known(WinLocalSystemSid)?; - let administrators = SidBuffer::well_known(WinBuiltinAdministratorsSid)?; - let acl = ProtectedAcl::new([¤t_user, &system, &administrators], inheritance)?; + let sddl = format!( + "D:P(A;{inheritance};FA;;;{})(A;{inheritance};FA;;;SY)(A;{inheritance};FA;;;BA)", + current_user.to_sddl()? + ); + let protected_descriptor = SecurityDescriptor::from_sddl(&sddl)?; unsafe { check(SetSecurityInfo( - handle.0, + handle.as_raw_handle().cast(), SE_FILE_OBJECT, - (DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION) - as OBJECT_SECURITY_INFORMATION, + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, null_mut(), null_mut(), - acl.as_ptr(), + protected_descriptor.dacl()? as *const _, null(), - ))?; + )) } - Ok(()) } pub fn inspect_path(path: &Path) -> io::Result { - let path = WidePath::new(path)?; - let handle = FileHandle::open_for_read(&path)?; - SecurityDescriptor::get(handle.0)?.to_sddl() + let handle = open_path(path, READ_CONTROL)?; + SecurityDescriptor::get(handle.as_raw_handle().cast())?.to_sddl() } - struct WidePath(Vec); - - impl WidePath { - fn new(path: &Path) -> io::Result { - if !path.is_absolute() { - return Err(invalid_input("path must be absolute")); - } - let units: Vec = path.as_os_str().encode_wide().collect(); - if units.contains(&0) { - return Err(invalid_input("path contains a NUL character")); - } - Ok(Self(units.into_iter().chain(Some(0)).collect())) + fn open_path(path: &Path, access_mode: u32) -> io::Result { + if !path.is_absolute() { + return Err(invalid_input("path must be absolute")); } + OpenOptions::new() + .access_mode(access_mode) + .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) + .open(path) } - struct FileHandle(HANDLE); - - impl FileHandle { - fn open_for_read(path: &WidePath) -> io::Result { - Self::open(path, READ_CONTROL) - } - - fn open_for_write(path: &WidePath) -> io::Result { - Self::open(path, READ_CONTROL | WRITE_DAC) - } - - fn open(path: &WidePath, desired_access: u32) -> io::Result { - let handle = unsafe { - CreateFileW( - path.0.as_ptr(), - desired_access, - FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, - null(), - OPEN_EXISTING, - FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, - null_mut(), - ) - }; - if handle == INVALID_HANDLE_VALUE { - return Err(io::Error::last_os_error()); - } - Ok(Self(handle)) - } - - fn attributes(&self) -> io::Result { - let mut information = BY_HANDLE_FILE_INFORMATION::default(); - unsafe { check_bool(GetFileInformationByHandle(self.0, &mut information))? }; - Ok(information.dwFileAttributes) - } + fn attributes(file: &File) -> io::Result { + let mut information = BY_HANDLE_FILE_INFORMATION::default(); + unsafe { + check_bool(GetFileInformationByHandle( + file.as_raw_handle().cast(), + &mut information, + ))? + }; + Ok(information.dwFileAttributes) } - impl Drop for FileHandle { - fn drop(&mut self) { - unsafe { CloseHandle(self.0) }; - } + fn owned_handle(handle: HANDLE) -> OwnedHandle { + // CreateFileW and OpenProcessToken return CloseHandle-owned handles. + unsafe { OwnedHandle::from_raw_handle(handle.cast::<()>() as RawHandle) } } - struct TokenHandle(HANDLE); + struct TokenHandle(OwnedHandle); impl TokenHandle { fn current_process() -> io::Result { @@ -199,17 +166,18 @@ mod windows { &mut token, ))? }; - Ok(Self(token)) + if token.is_null() { + return Err(io::Error::last_os_error()); + } + Ok(Self(owned_handle(token))) } - } - impl Drop for TokenHandle { - fn drop(&mut self) { - unsafe { CloseHandle(self.0) }; + fn raw(&self) -> HANDLE { + self.0.as_raw_handle().cast() } } - /// Stores SID bytes in usize elements so the buffer remains properly aligned. + /// Stores copied SID bytes in usize elements so the buffer remains aligned. struct SidBuffer { storage: Vec, } @@ -218,7 +186,7 @@ mod windows { fn current_user() -> io::Result { let token = TokenHandle::current_process()?; let mut length = 0; - unsafe { GetTokenInformation(token.0, TokenUser, null_mut(), 0, &mut length) }; + unsafe { GetTokenInformation(token.raw(), TokenUser, null_mut(), 0, &mut length) }; if length == 0 { return Err(io::Error::last_os_error()); } @@ -226,7 +194,7 @@ mod windows { let mut token_user = vec![0usize; (length as usize).div_ceil(size_of::())]; unsafe { check_bool(GetTokenInformation( - token.0, + token.raw(), TokenUser, token_user.as_mut_ptr().cast(), length, @@ -241,7 +209,7 @@ mod windows { } let sid = Self::with_byte_capacity(GetLengthSid(source_sid) as usize); - check_bool(CopySid( + check_bool(windows_sys::Win32::Security::CopySid( (sid.storage.len() * size_of::()) as u32, sid.as_psid(), source_sid, @@ -252,14 +220,21 @@ mod windows { fn well_known(kind: i32) -> io::Result { let mut length = 0; - unsafe { CreateWellKnownSid(kind, null_mut(), null_mut(), &mut length) }; + unsafe { + windows_sys::Win32::Security::CreateWellKnownSid( + kind, + null_mut(), + null_mut(), + &mut length, + ) + }; if length == 0 { return Err(io::Error::last_os_error()); } let mut sid = Self::with_byte_capacity(length as usize); unsafe { - check_bool(CreateWellKnownSid( + check_bool(windows_sys::Win32::Security::CreateWellKnownSid( kind, null_mut(), sid.storage.as_mut_ptr().cast(), @@ -272,6 +247,19 @@ mod windows { Ok(sid) } + fn to_sddl(&self) -> io::Result { + let mut value = null_mut(); + unsafe { + check_bool(ConvertSidToStringSidW(self.as_psid(), &mut value))?; + if value.is_null() { + return Err(io::Error::last_os_error()); + } + let sddl = wide_c_string(value); + LocalFree(value.cast()); + Ok(sddl) + } + } + fn with_byte_capacity(bytes: usize) -> Self { Self { storage: vec![0usize; bytes.div_ceil(size_of::())], @@ -286,16 +274,32 @@ mod windows { struct SecurityDescriptor(PSECURITY_DESCRIPTOR); impl SecurityDescriptor { + fn from_sddl(sddl: &str) -> io::Result { + let sddl: Vec = sddl.encode_utf16().chain(Some(0)).collect(); + let mut descriptor = null_mut(); + unsafe { + check_bool(ConvertStringSecurityDescriptorToSecurityDescriptorW( + sddl.as_ptr(), + SDDL_REVISION_1, + &mut descriptor, + null_mut(), + ))?; + } + if descriptor.is_null() { + return Err(io::Error::last_os_error()); + } + Ok(Self(descriptor)) + } + fn get(handle: HANDLE) -> io::Result { let mut descriptor = null_mut(); unsafe { check(GetSecurityInfo( handle, SE_FILE_OBJECT, - (OWNER_SECURITY_INFORMATION + OWNER_SECURITY_INFORMATION | GROUP_SECURITY_INFORMATION - | DACL_SECURITY_INFORMATION) - as OBJECT_SECURITY_INFORMATION, + | DACL_SECURITY_INFORMATION, null_mut(), null_mut(), null_mut(), @@ -303,6 +307,9 @@ mod windows { &mut descriptor, ))?; } + if descriptor.is_null() { + return Err(io::Error::last_os_error()); + } Ok(Self(descriptor)) } @@ -316,12 +323,34 @@ mod windows { &mut owner_defaulted, ))? }; - if owner.is_null() { - return Err(invalid_input("path security descriptor has no owner")); + if owner.is_null() || unsafe { IsValidSid(owner) } == 0 { + return Err(invalid_input( + "path security descriptor has an invalid owner", + )); } Ok(owner) } + fn dacl(&self) -> io::Result<&windows_sys::Win32::Security::ACL> { + let mut present = 0; + let mut dacl = null_mut(); + let mut defaulted = 0; + unsafe { + check_bool(GetSecurityDescriptorDacl( + self.0, + &mut present, + &mut dacl, + &mut defaulted, + ))? + }; + if present == 0 || dacl.is_null() { + return Err(invalid_input( + "parsed protected SDDL must contain a non-null DACL", + )); + } + Ok(unsafe { &*dacl }) + } + fn to_sddl(&self) -> io::Result { let mut value = null_mut(); let mut length = 0; @@ -329,15 +358,14 @@ mod windows { check_bool(ConvertSecurityDescriptorToStringSecurityDescriptorW( self.0, SDDL_REVISION_1, - DACL_SECURITY_INFORMATION as OBJECT_SECURITY_INFORMATION, + DACL_SECURITY_INFORMATION, &mut value, &mut length, ))?; - let mut units = std::slice::from_raw_parts(value, length as usize).to_vec(); - while units.last() == Some(&0) { - units.pop(); + if value.is_null() { + return Err(io::Error::last_os_error()); } - let sddl = OsString::from_wide(&units).to_string_lossy().into_owned(); + let sddl = wide_c_string(value); LocalFree(value.cast()); Ok(sddl) } @@ -350,13 +378,23 @@ mod windows { } } - fn validate_owner(owner: PSID, current_user: &SidBuffer) -> io::Result<()> { - let system = SidBuffer::well_known(WinLocalSystemSid)?; - let administrators = SidBuffer::well_known(WinBuiltinAdministratorsSid)?; + fn wide_c_string(value: *const u16) -> String { + let mut length = 0; + unsafe { + while *value.add(length) != 0 { + length += 1; + } + OsString::from_wide(std::slice::from_raw_parts(value, length)) + .to_string_lossy() + .into_owned() + } + } + + fn validate_owner(owner: PSID, trusted_sids: [&SidBuffer; 3]) -> io::Result<()> { let trusted = unsafe { - EqualSid(owner, current_user.as_psid()) != 0 - || EqualSid(owner, system.as_psid()) != 0 - || EqualSid(owner, administrators.as_psid()) != 0 + trusted_sids + .into_iter() + .any(|trusted_sid| EqualSid(owner, trusted_sid.as_psid()) != 0) }; if trusted { Ok(()) @@ -368,40 +406,13 @@ mod windows { } } - struct ProtectedAcl { - // usize elements keep the ACL allocation aligned for all Win32 structures. - storage: Vec, - } - - impl ProtectedAcl { - fn new(sids: [&SidBuffer; 3], inheritance: u8) -> io::Result { - let bytes = size_of::() - + sids - .iter() - .map(|sid| { - // ACE_HEADER + ACCESS_MASK, followed by the SID. - 8 + unsafe { GetLengthSid(sid.as_psid()) as usize } - }) - .sum::(); - let mut storage = vec![0usize; bytes.div_ceil(size_of::())]; - let acl = storage.as_mut_ptr().cast::(); - unsafe { - check_bool(InitializeAcl(acl, bytes as u32, ACL_REVISION))?; - for sid in sids { - check_bool(AddAccessAllowedAceEx( - acl, - ACL_REVISION, - inheritance as u32, - PROTECTED_FILE_ACCESS, - sid.as_psid(), - ))?; - } - } - Ok(Self { storage }) - } - - fn as_ptr(&self) -> *const ACL { - self.storage.as_ptr().cast() + fn reject_reparse_point(attributes: u32) -> io::Result<()> { + if attributes & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + Err(invalid_input( + "final path component must not be a reparse point", + )) + } else { + Ok(()) } } @@ -434,13 +445,40 @@ mod windows { ACCESS_ALLOWED_ACE, GetAce, GetSecurityDescriptorControl, GetSecurityDescriptorDacl, SE_DACL_PROTECTED, }; + use windows_sys::Win32::Storage::FileSystem::FILE_ALL_ACCESS; const ACCESS_ALLOWED_ACE_TYPE: u8 = 0; + const OBJECT_INHERIT_ACE: u8 = 0x01; + const CONTAINER_INHERIT_ACE: u8 = 0x02; + const INHERITED_ACE: u8 = 0x10; + + fn temporary_path(name: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!( + "acl-prototype-{name}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )) + } - fn assert_protected_file_acl(path: &Path, expected_sids: [&SidBuffer; 3]) { - let path = WidePath::new(path).unwrap(); - let handle = FileHandle::open_for_read(&path).unwrap(); - let descriptor = SecurityDescriptor::get(handle.0).unwrap(); + fn trusted_sids() -> (SidBuffer, SidBuffer, SidBuffer) { + ( + SidBuffer::current_user().unwrap(), + SidBuffer::well_known(WinLocalSystemSid).unwrap(), + SidBuffer::well_known(WinBuiltinAdministratorsSid).unwrap(), + ) + } + + fn assert_acl( + path: &Path, + expected_sids: [&SidBuffer; 3], + expected_flags: u8, + protected: bool, + ) { + let handle = open_path(path, READ_CONTROL).unwrap(); + let descriptor = SecurityDescriptor::get(handle.as_raw_handle().cast()).unwrap(); let mut control = 0; let mut revision = 0; unsafe { @@ -451,7 +489,12 @@ mod windows { )) .unwrap(); } - assert_ne!(control & SE_DACL_PROTECTED, 0, "DACL must be protected"); + assert_eq!( + control & SE_DACL_PROTECTED != 0, + protected, + "protected DACL state was unexpected for {}", + path.display() + ); let mut dacl_present = 0; let mut dacl = null_mut(); @@ -488,12 +531,12 @@ mod windows { ); assert_eq!( (*ace).Header.AceFlags, - 0, - "file ACE {index} must not inherit", + expected_flags, + "ACE {index} inheritance flags were unexpected", ); assert_eq!( (*ace).Mask, - PROTECTED_FILE_ACCESS, + FILE_ALL_ACCESS, "ACE {index} must grant full control", ); let sid = std::ptr::addr_of!((*ace).SidStart).cast_mut().cast(); @@ -506,33 +549,118 @@ mod windows { } } + fn assert_inherited_child_acl(path: &Path, expected_sids: [&SidBuffer; 3]) { + let handle = open_path(path, READ_CONTROL).unwrap(); + let descriptor = SecurityDescriptor::get(handle.as_raw_handle().cast()).unwrap(); + let dacl = descriptor.dacl().unwrap(); + assert_eq!(dacl.AceCount, 3, "child DACL must inherit three ACEs"); + + for (index, expected_sid) in expected_sids.into_iter().enumerate() { + let mut ace = null_mut(); + unsafe { + check_bool(GetAce(dacl as *const _ as *mut _, index as u32, &mut ace)).unwrap(); + let ace = ace.cast::(); + assert_ne!( + (*ace).Header.AceFlags & INHERITED_ACE, + 0, + "child ACE {index} must be inherited", + ); + assert_eq!( + (*ace).Mask, + FILE_ALL_ACCESS, + "child ACE {index} must grant full control", + ); + let sid = std::ptr::addr_of!((*ace).SidStart).cast_mut().cast(); + assert_ne!( + EqualSid(sid, expected_sid.as_psid()), + 0, + "child ACE {index} SID did not match", + ); + } + } + } + #[test] - fn secure_path_protects_a_real_file_with_the_current_user_ace() { - let current_user = SidBuffer::current_user().unwrap(); - let system = SidBuffer::well_known(WinLocalSystemSid).unwrap(); - let administrators = SidBuffer::well_known(WinBuiltinAdministratorsSid).unwrap(); - let path = std::env::temp_dir().join(format!( - "acl-prototype-{}-{}.txt", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) + fn validate_owner_accepts_trusted_sids_and_rejects_world() { + let (current_user, system, administrators) = trusted_sids(); + let trusted = [¤t_user, &system, &administrators]; + for owner in trusted { + validate_owner(owner.as_psid(), trusted).unwrap(); + } + + let world = SidBuffer::well_known(windows_sys::Win32::Security::WinWorldSid).unwrap(); + assert_eq!( + validate_owner(world.as_psid(), trusted).unwrap_err().kind(), + io::ErrorKind::PermissionDenied + ); + } + + #[test] + fn open_path_rejects_relative_and_nul_paths() { + assert_eq!( + open_path(Path::new("relative"), READ_CONTROL) + .err() .unwrap() - .as_nanos() - )); + .kind(), + io::ErrorKind::InvalidInput + ); + let nul_path = std::path::PathBuf::from(OsString::from_wide(&[ + b'C' as u16, + b':' as u16, + b'\\' as u16, + 0, + b'x' as u16, + ])); + assert_eq!( + open_path(&nul_path, READ_CONTROL).err().unwrap().kind(), + io::ErrorKind::InvalidInput + ); + } + + #[test] + fn secure_path_applies_an_exact_protected_file_dacl_idempotently() { + let (current_user, system, administrators) = trusted_sids(); + let path = temporary_path("file"); fs::write(&path, "test").unwrap(); secure_path(&path).unwrap(); + let first = inspect_path(&path).unwrap(); secure_path(&path).unwrap(); - let sddl = inspect_path(&path).unwrap(); - + assert_eq!( + inspect_path(&path).unwrap(), + first, + "file DACL must be idempotent" + ); assert!( - !sddl.ends_with('\0'), + !first.ends_with('\0'), "SDDL must not retain the API terminator" ); - assert_protected_file_acl(&path, [¤t_user, &system, &administrators]); + assert_acl(&path, [¤t_user, &system, &administrators], 0, true); fs::remove_file(path).unwrap(); } + + #[test] + fn secure_path_applies_inheritable_protected_directory_aces() { + let (current_user, system, administrators) = trusted_sids(); + let directory = temporary_path("directory"); + fs::create_dir(&directory).unwrap(); + + secure_path(&directory).unwrap(); + assert_acl( + &directory, + [¤t_user, &system, &administrators], + OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE, + true, + ); + + let child = directory.join("child.txt"); + fs::write(&child, "test").unwrap(); + assert_inherited_child_acl(&child, [¤t_user, &system, &administrators]); + + fs::remove_file(child).unwrap(); + fs::remove_dir(directory).unwrap(); + } } } diff --git a/prototypes/windows-acl/experiment-report.cjs b/prototypes/windows-acl/experiment-report.cjs new file mode 100644 index 0000000000..0358f151eb --- /dev/null +++ b/prototypes/windows-acl/experiment-report.cjs @@ -0,0 +1,142 @@ +#!/usr/bin/env node +"use strict"; +/* global __dirname, console, process */ + +const childProcess = require("node:child_process"); +const fs = require("node:fs"); +const path = require("node:path"); + +const { + BASELINE_REVISION, + CELLS, + reportFilename, +} = require("./experiment.cjs"); +const { + packageManifest, + packagePrototype, +} = require("./package-prototype.cjs"); + +const ROOT = __dirname; +const ARTIFACT_ROOT = path.join(ROOT, "artifacts"); +const ARCHITECTURES = ["x64", "arm64"]; +const VARIANTS = ["helper", "addon"]; + +function fileSize(filename) { + const stat = fs.statSync(filename, { throwIfNoEntry: false }); + if (!stat?.isFile()) + throw new Error(`Missing experiment artifact: ${filename}`); + return stat.size; +} + +function readJson(filename) { + try { + return JSON.parse(fs.readFileSync(filename, "utf8")); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Invalid experiment report ${filename}: ${message}`, { + cause: error, + }); + } +} + +function verifyPackage(filename, variant) { + const entries = childProcess + .execFileSync("unzip", ["-Z1", filename], { encoding: "utf8" }) + .split(/\r?\n/) + .filter(Boolean); + const manifest = JSON.parse( + childProcess.execFileSync( + "unzip", + ["-p", filename, "extension/package.json"], + { + encoding: "utf8", + }, + ), + ); + for (const arch of ARCHITECTURES) { + const name = variant === "helper" ? "acl-helper.exe" : "acl.node"; + const entry = `extension/artifacts/win32-${arch}/${name}`; + if (!entries.includes(entry)) + throw new Error(`Package ${filename} is missing ${entry}`); + } + if (entries.some((entry) => entry.includes("linux-"))) { + throw new Error(`Package ${filename} includes a non-Windows artifact`); + } + if (JSON.stringify(manifest) === "{}") { + throw new Error(`Package ${filename} has an empty manifest`); + } + const expected = packageManifest(variant); + if (manifest.name !== expected.name || manifest.main !== expected.main) { + throw new Error(`Package ${filename} has an unexpected manifest`); + } +} + +function collectCell(cell, { root = ROOT, artifactRoot = ARTIFACT_ROOT } = {}) { + const cellRoot = path.join(artifactRoot, "experiments", cell.name); + const architectures = {}; + for (const arch of ARCHITECTURES) { + const artifactDirectory = path.join(cellRoot, `win32-${arch}`); + const report = readJson(path.join(cellRoot, reportFilename(arch))); + if (report.arch !== arch || report.cell?.name !== cell.name) { + throw new Error(`Experiment report does not match ${cell.name}/${arch}`); + } + if ( + report.build?.status !== "passed" || + report.runtimeTest?.status !== "passed" + ) { + throw new Error( + `Experiment ${cell.name}/${arch} failed: ${report.failure ?? "unknown failure"}`, + ); + } + architectures[arch] = { + report, + helperBytes: fileSize(path.join(artifactDirectory, "acl-helper.exe")), + addonBytes: fileSize(path.join(artifactDirectory, "acl.node")), + }; + } + const packages = {}; + for (const variant of VARIANTS) { + const output = packagePrototype(variant, { root, artifactRoot: cellRoot }); + verifyPackage(output, variant); + packages[variant] = { bytes: fileSize(output) }; + } + return { architectures, packages }; +} + +function createExperimentReport(options = {}) { + const root = options.root ?? ROOT; + const artifactRoot = options.artifactRoot ?? ARTIFACT_ROOT; + const cells = Object.fromEntries( + CELLS.map((cell) => [cell.name, collectCell(cell, { root, artifactRoot })]), + ); + const report = { + generatedAt: new Date().toISOString(), + baselineRevision: BASELINE_REVISION, + cells, + }; + const output = path.join(artifactRoot, "experiments", "report.json"); + fs.mkdirSync(path.dirname(output), { recursive: true }); + fs.writeFileSync(output, `${JSON.stringify(report, null, "\t")}\n`); + return { output, report }; +} + +function main() { + console.log(createExperimentReport().output); +} + +if (require.main === module) { + try { + main(); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} + +module.exports = { + ARCHITECTURES, + VARIANTS, + collectCell, + createExperimentReport, + verifyPackage, +}; diff --git a/prototypes/windows-acl/experiment-report.d.cts b/prototypes/windows-acl/experiment-report.d.cts new file mode 100644 index 0000000000..19f95465c0 --- /dev/null +++ b/prototypes/windows-acl/experiment-report.d.cts @@ -0,0 +1,17 @@ +import type { ExperimentCell } from "./experiment.cjs"; + +export const ARCHITECTURES: string[]; +export const VARIANTS: string[]; + +export function verifyPackage( + filename: string, + variant: "helper" | "addon", +): void; +export function collectCell( + cell: ExperimentCell, + options?: { root?: string; artifactRoot?: string }, +): unknown; +export function createExperimentReport(options?: { + root?: string; + artifactRoot?: string; +}): { output: string; report: unknown }; diff --git a/prototypes/windows-acl/experiment.cjs b/prototypes/windows-acl/experiment.cjs new file mode 100644 index 0000000000..dbf033aaa1 --- /dev/null +++ b/prototypes/windows-acl/experiment.cjs @@ -0,0 +1,437 @@ +#!/usr/bin/env node +"use strict"; +/* global __dirname, console, process */ + +const childProcess = require("node:child_process"); +const fs = require("node:fs"); +const path = require("node:path"); + +const ROOT = __dirname; +const ARTIFACT_ROOT = path.join(ROOT, "artifacts"); +const EXPERIMENT_ROOT = path.join(ARTIFACT_ROOT, "experiments"); +const BASELINE_REVISION = "22a751efc2fc859641e387921e115d01829db1ac"; +const WINDOWS_TARGETS = new Map([ + ["x86_64-pc-windows-msvc", "x64"], + ["aarch64-pc-windows-msvc", "arm64"], +]); +const CELLS = [ + { name: "original", source: "baseline" }, + { + name: "simplified-3", + source: "current", + optLevel: "3", + crtStatic: false, + }, + { + name: "simplified-s", + source: "current", + optLevel: "s", + crtStatic: false, + }, + { + name: "projection-s", + source: "current", + optLevel: "s", + crtStatic: false, + features: [ + "acl-prototype-helper/projection", + "acl-prototype-addon/projection", + ], + }, + { + name: "simplified-z", + source: "current", + optLevel: "z", + crtStatic: false, + }, + { + name: "simplified-s-static", + source: "current", + optLevel: "s", + crtStatic: true, + }, +]; + +function parseArguments(args) { + let target; + for (let index = 0; index < args.length; index += 1) { + if (args[index] !== "--target") { + throw new Error(`Unknown argument: ${args[index]}`); + } + target = args[index + 1]; + if (!target) throw new Error("--target requires a Rust target triple"); + index += 1; + } + if (!target) throw new Error("--target is required"); + if (!WINDOWS_TARGETS.has(target)) { + throw new Error(`Unsupported Rust target: ${target}`); + } + return target; +} + +function targetEnvironmentName(target) { + return `CARGO_TARGET_${target.toUpperCase().replaceAll("-", "_")}_RUSTFLAGS`; +} + +function buildEnvironment(target, cell, targetDirectory) { + const environment = { ...process.env, CARGO_TARGET_DIR: targetDirectory }; + delete environment.RUSTFLAGS; + delete environment.CARGO_ENCODED_RUSTFLAGS; + delete environment[targetEnvironmentName(target)]; + if (cell.source === "current") { + environment.CARGO_PROFILE_RELEASE_OPT_LEVEL = cell.optLevel; + environment.CARGO_PROFILE_RELEASE_PANIC = "unwind"; + environment.CARGO_PROFILE_RELEASE_LTO = "true"; + environment.CARGO_PROFILE_RELEASE_CODEGEN_UNITS = "1"; + environment.CARGO_PROFILE_RELEASE_STRIP = "true"; + if (cell.crtStatic) { + environment[targetEnvironmentName(target)] = + "-C target-feature=+crt-static"; + } + } else { + delete environment.CARGO_PROFILE_RELEASE_OPT_LEVEL; + delete environment.CARGO_PROFILE_RELEASE_PANIC; + delete environment.CARGO_PROFILE_RELEASE_LTO; + delete environment.CARGO_PROFILE_RELEASE_CODEGEN_UNITS; + delete environment.CARGO_PROFILE_RELEASE_STRIP; + } + return environment; +} + +function baselineRoot(value = process.env.ACL_BASELINE_ROOT) { + if (!value || !path.isAbsolute(value)) { + throw new Error( + "ACL_BASELINE_ROOT must be an absolute path to the baseline source", + ); + } + if ( + !fs + .statSync(path.join(value, "Cargo.toml"), { throwIfNoEntry: false }) + ?.isFile() + ) { + throw new Error( + `ACL_BASELINE_ROOT is not a Windows ACL source root: ${value}`, + ); + } + return value; +} + +function sourceRootFor(cell, root, configuredBaselineRoot) { + const sourceRoot = + cell.source === "baseline" ? baselineRoot(configuredBaselineRoot) : root; + if ( + cell.source === "baseline" && + sourceRevision(sourceRoot) !== BASELINE_REVISION + ) { + throw new Error(`ACL_BASELINE_ROOT must be at ${BASELINE_REVISION}`); + } + return sourceRoot; +} + +function readAscii(buffer, offset) { + const end = buffer.indexOf(0, offset); + if (end === -1) throw new Error("PE string is not NUL terminated"); + return buffer.toString("ascii", offset, end); +} + +function parsePortableExecutable(buffer) { + if (buffer.length < 0x40 || buffer.toString("ascii", 0, 2) !== "MZ") { + throw new Error("Not a PE file"); + } + const peOffset = buffer.readUInt32LE(0x3c); + if ( + peOffset + 24 > buffer.length || + buffer.toString("ascii", peOffset, peOffset + 4) !== "PE\0\0" + ) { + throw new Error("Invalid PE signature"); + } + const machine = buffer.readUInt16LE(peOffset + 4); + const architecture = new Map([ + [0x8664, "x64"], + [0xaa64, "arm64"], + ]).get(machine); + if (!architecture) + throw new Error(`Unsupported PE machine: 0x${machine.toString(16)}`); + + const sectionCount = buffer.readUInt16LE(peOffset + 6); + const optionalHeaderSize = buffer.readUInt16LE(peOffset + 20); + const optionalHeaderOffset = peOffset + 24; + if (optionalHeaderOffset + optionalHeaderSize > buffer.length) { + throw new Error("Truncated PE optional header"); + } + const magic = buffer.readUInt16LE(optionalHeaderOffset); + const directoryOffset = + magic === 0x20b ? 112 : magic === 0x10b ? 96 : undefined; + if (directoryOffset === undefined) + throw new Error("Unsupported PE optional header"); + if (directoryOffset + 16 > optionalHeaderSize) + throw new Error("Missing PE import directory"); + const importRva = buffer.readUInt32LE( + optionalHeaderOffset + directoryOffset + 8, + ); + const sectionTableOffset = optionalHeaderOffset + optionalHeaderSize; + if (sectionTableOffset + sectionCount * 40 > buffer.length) { + throw new Error("Truncated PE section table"); + } + + function rvaToOffset(rva) { + for (let index = 0; index < sectionCount; index += 1) { + const sectionOffset = sectionTableOffset + index * 40; + const virtualSize = buffer.readUInt32LE(sectionOffset + 8); + const virtualAddress = buffer.readUInt32LE(sectionOffset + 12); + const rawSize = buffer.readUInt32LE(sectionOffset + 16); + const rawOffset = buffer.readUInt32LE(sectionOffset + 20); + const size = Math.max(virtualSize, rawSize); + if (rva >= virtualAddress && rva < virtualAddress + size) { + return rawOffset + rva - virtualAddress; + } + } + throw new Error(`PE RVA is outside sections: 0x${rva.toString(16)}`); + } + + const imports = []; + if (importRva !== 0) { + let descriptorOffset = rvaToOffset(importRva); + while (descriptorOffset + 20 <= buffer.length) { + const nameRva = buffer.readUInt32LE(descriptorOffset + 12); + if (nameRva === 0) break; + imports.push(readAscii(buffer, rvaToOffset(nameRva))); + descriptorOffset += 20; + } + } + return { architecture, imports: imports.sort() }; +} + +function copyBuildOutputs(targetDirectory, target, artifactDirectory) { + const releaseDirectory = path.join(targetDirectory, target, "release"); + const outputs = [ + ["acl-prototype-helper.exe", "acl-helper.exe"], + ["acl_prototype_addon.dll", "acl.node"], + ]; + fs.mkdirSync(artifactDirectory, { recursive: true }); + return outputs.map(([sourceName, destinationName]) => { + const source = path.join(releaseDirectory, sourceName); + if (!fs.statSync(source, { throwIfNoEntry: false })?.isFile()) { + throw new Error(`Missing Cargo release output: ${source}`); + } + const destination = path.join(artifactDirectory, destinationName); + fs.copyFileSync(source, destination); + const contents = fs.readFileSync(destination); + return { + name: destinationName, + bytes: contents.length, + pe: parsePortableExecutable(contents), + }; + }); +} + +function run(command, args, options) { + childProcess.execFileSync(command, args, { ...options, stdio: "inherit" }); +} + +function commandOutput(command, args, options) { + return childProcess + .execFileSync(command, args, { + ...options, + encoding: "utf8", + }) + .trim(); +} + +function sourceRevision(root) { + return commandOutput("git", ["rev-parse", "HEAD"], { cwd: root }); +} + +function vitestEntrypoint() { + return path.join( + path.dirname(require.resolve("vitest/package.json")), + "vitest.mjs", + ); +} + +function electron37Path(value = process.env.ACL_ELECTRON37_PATH) { + if (!value || !path.isAbsolute(value)) { + throw new Error( + "ACL_ELECTRON37_PATH must be an absolute Electron 37 executable path", + ); + } + if (!fs.statSync(value, { throwIfNoEntry: false })?.isFile()) { + throw new Error(`ACL_ELECTRON37_PATH is not an executable file: ${value}`); + } + return value; +} + +function runtimeTest(root) { + const vitest = vitestEntrypoint(); + const args = [ + vitest, + "run", + "--config", + path.join(root, "test", "vitest.config.mts"), + ]; + const environment = { ...process.env, ELECTRON_RUN_AS_NODE: "1" }; + for (const runtime of [ + { name: "node", command: process.execPath, args }, + { name: "electron-current", command: require("electron"), args }, + { + name: "electron-37", + command: electron37Path(), + args, + }, + ]) { + run(runtime.command, runtime.args, { cwd: root, env: environment }); + } + return ["node", "electron-current", "electron-37"]; +} + +function reportFilename(arch) { + return `report-${arch}.json`; +} + +function writeJson(filename, value) { + fs.mkdirSync(path.dirname(filename), { recursive: true }); + fs.writeFileSync(filename, `${JSON.stringify(value, null, "\t")}\n`); +} + +function runExperiment(target, options = {}) { + const root = options.root ?? ROOT; + const artifactRoot = options.artifactRoot ?? ARTIFACT_ROOT; + const configuredBaselineRoot = + options.baselineRoot ?? process.env.ACL_BASELINE_ROOT; + const arch = WINDOWS_TARGETS.get(target); + if (!arch) throw new Error(`Unsupported Rust target: ${target}`); + if (process.platform !== "win32" || process.arch !== arch) { + throw new Error( + `Experiment target ${target} requires win32/${arch}; received ${process.platform}/${process.arch}`, + ); + } + const results = []; + const failures = []; + const toolchain = { + cargo: commandOutput("cargo", ["+1.98.1", "--version"], { cwd: root }), + rustc: commandOutput("rustc", ["+1.98.1", "--version"], { cwd: root }), + }; + + for (const cell of CELLS) { + const cellRoot = path.join(artifactRoot, "experiments", cell.name); + const artifactDirectory = path.join(cellRoot, `win32-${arch}`); + let sourceRoot; + let targetDirectory; + try { + sourceRoot = sourceRootFor(cell, root, configuredBaselineRoot); + targetDirectory = path.join( + sourceRoot, + "target", + "experiments", + cell.name, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const report = { + cell, + target, + arch, + build: { targetDirectory, status: "failed" }, + runtimeTest: { status: "failed" }, + failure: message, + }; + writeJson(path.join(cellRoot, reportFilename(arch)), report); + results.push(report); + failures.push(`${cell.name}: ${message}`); + continue; + } + const report = { + cell, + target, + arch, + sourceRevision: sourceRevision(sourceRoot), + toolchain, + runtime: { + execPath: process.execPath, + platform: process.platform, + arch: process.arch, + versions: process.versions, + }, + build: { targetDirectory, status: "pending" }, + runtimeTest: { status: "pending", runners: [] }, + }; + try { + fs.rmSync(targetDirectory, { recursive: true, force: true }); + run( + "cargo", + [ + "+1.98.1", + "build", + "--release", + "--workspace", + "--target", + target, + "--manifest-path", + path.join(sourceRoot, "Cargo.toml"), + ...(cell.features ? ["--features", cell.features.join(",")] : []), + "--locked", + ], + { + cwd: sourceRoot, + env: buildEnvironment(target, cell, targetDirectory), + }, + ); + report.build = { + status: "passed", + targetDirectory, + binaries: copyBuildOutputs(targetDirectory, target, artifactDirectory), + }; + copyBuildOutputs( + targetDirectory, + target, + path.join(artifactRoot, `win32-${arch}`), + ); + report.runtimeTest = { status: "passed", runners: runtimeTest(root) }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + report.failure = message; + if (report.build.status === "pending") report.build.status = "failed"; + if (report.runtimeTest.status === "pending") + report.runtimeTest.status = "failed"; + failures.push(`${cell.name}: ${message}`); + } finally { + writeJson(path.join(cellRoot, reportFilename(arch)), report); + results.push(report); + } + } + if (failures.length > 0) throw new Error(failures.join("\n")); + return results; +} + +function main() { + runExperiment(parseArguments(process.argv.slice(2))); +} + +if (require.main === module) { + try { + main(); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} + +module.exports = { + ARTIFACT_ROOT, + BASELINE_REVISION, + CELLS, + EXPERIMENT_ROOT, + WINDOWS_TARGETS, + baselineRoot, + buildEnvironment, + copyBuildOutputs, + parseArguments, + parsePortableExecutable, + electron37Path, + reportFilename, + runExperiment, + runtimeTest, + sourceRootFor, + targetEnvironmentName, + vitestEntrypoint, +}; diff --git a/prototypes/windows-acl/experiment.d.cts b/prototypes/windows-acl/experiment.d.cts new file mode 100644 index 0000000000..644777ce6b --- /dev/null +++ b/prototypes/windows-acl/experiment.d.cts @@ -0,0 +1,38 @@ +export interface ExperimentCell { + name: string; + source: "baseline" | "current"; + optLevel?: "3" | "s" | "z"; + crtStatic?: boolean; + features?: string[]; +} + +export interface PortableExecutable { + architecture: "x64" | "arm64"; + imports: string[]; +} + +export const ARTIFACT_ROOT: string; +export const BASELINE_REVISION: string; +export const EXPERIMENT_ROOT: string; +export const CELLS: ExperimentCell[]; +export const WINDOWS_TARGETS: Map; + +export function parseArguments(args: string[]): string; +export function targetEnvironmentName(target: string): string; +export function baselineRoot(value?: string): string; +export function sourceRootFor( + cell: ExperimentCell, + root: string, + configuredBaselineRoot?: string, +): string; +export function buildEnvironment( + target: string, + cell: ExperimentCell, + targetDirectory: string, +): NodeJS.ProcessEnv; +export function parsePortableExecutable(buffer: Buffer): PortableExecutable; +export function reportFilename(arch: "x64" | "arm64"): string; +export function electron37Path(value?: string): string; +export function vitestEntrypoint(): string; +export function runtimeTest(root: string): string[]; +export function runExperiment(target: string): unknown[]; diff --git a/prototypes/windows-acl/helper/Cargo.toml b/prototypes/windows-acl/helper/Cargo.toml index 5957ddbfa9..8d2e7c88e6 100644 --- a/prototypes/windows-acl/helper/Cargo.toml +++ b/prototypes/windows-acl/helper/Cargo.toml @@ -4,6 +4,10 @@ version = "0.1.0" edition = "2021" publish = false +[features] +projection = ["dep:acl-prototype-core-windows"] + [dependencies] acl-prototype-core = { path = "../core" } +acl-prototype-core-windows = { path = "../core-windows", optional = true } serde_json = "1" diff --git a/prototypes/windows-acl/helper/src/main.rs b/prototypes/windows-acl/helper/src/main.rs index 1d19f988c4..e4a4b44439 100644 --- a/prototypes/windows-acl/helper/src/main.rs +++ b/prototypes/windows-acl/helper/src/main.rs @@ -1,3 +1,8 @@ +#[cfg(not(feature = "projection"))] +use acl_prototype_core as core; +#[cfg(feature = "projection")] +use acl_prototype_core_windows as core; + use std::{env, path::Path, process::ExitCode}; use serde_json::json; @@ -5,10 +10,7 @@ use serde_json::json; fn main() -> ExitCode { let args: Vec<_> = env::args_os().skip(1).collect(); if args.len() == 1 && args[0] == "probe" { - println!( - "{}", - json!({ "version": 1, "backend": acl_prototype_core::backend() }) - ); + println!("{}", json!({ "version": 1, "backend": core::backend() })); return ExitCode::SUCCESS; } if args.len() != 2 || (args[0] != "secure" && args[0] != "inspect") { @@ -20,9 +22,9 @@ fn main() -> ExitCode { } let path = Path::new(&args[1]); let result = if args[0] == "secure" { - acl_prototype_core::secure_path(path).map(|()| None) + core::secure_path(path).map(|()| None) } else { - acl_prototype_core::inspect_path(path).map(Some) + core::inspect_path(path).map(Some) }; match result { Ok(sddl) => { diff --git a/prototypes/windows-acl/test/experiment-report.test.ts b/prototypes/windows-acl/test/experiment-report.test.ts new file mode 100644 index 0000000000..8a977982fb --- /dev/null +++ b/prototypes/windows-acl/test/experiment-report.test.ts @@ -0,0 +1,97 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import reportModule from "../experiment-report.cjs"; + +const { collectCell } = reportModule; +const temporaryDirectories: string[] = []; + +function temporaryDirectory(): string { + const directory = fs.mkdtempSync( + path.join(os.tmpdir(), "windows-acl-experiment-report-"), + ); + temporaryDirectories.push(directory); + return directory; +} + +function writeCellReport( + root: string, + cell: string, + arch: "x64" | "arm64", +): void { + const directory = path.join(root, "experiments", cell, `win32-${arch}`); + fs.mkdirSync(directory, { recursive: true }); + fs.writeFileSync( + path.join(root, "experiments", cell, `report-${arch}.json`), + JSON.stringify({ + cell: { name: cell }, + arch, + build: { status: "passed" }, + runtimeTest: { status: "passed" }, + }), + ); + fs.writeFileSync(path.join(directory, "acl-helper.exe"), "helper"); + fs.writeFileSync(path.join(directory, "acl.node"), "addon"); +} + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +describe("experiment-report", () => { + it("fails when an architecture payload is missing", () => { + const artifactRoot = temporaryDirectory(); + writeCellReport(artifactRoot, "simplified-s", "x64"); + + expect(() => + collectCell( + { + name: "simplified-s", + source: "current", + optLevel: "s", + crtStatic: false, + }, + { artifactRoot }, + ), + ).toThrow("Invalid experiment report"); + }); + + it("fails explicitly when a recorded cell failed", () => { + const artifactRoot = temporaryDirectory(); + for (const arch of ["x64", "arm64"] as const) { + writeCellReport(artifactRoot, "simplified-s", arch); + } + const failed = path.join( + artifactRoot, + "experiments", + "simplified-s", + "report-arm64.json", + ); + fs.writeFileSync( + failed, + JSON.stringify({ + cell: { name: "simplified-s" }, + arch: "arm64", + build: { status: "failed" }, + runtimeTest: { status: "failed" }, + failure: "native build failed", + }), + ); + + expect(() => + collectCell( + { + name: "simplified-s", + source: "current", + optLevel: "s", + crtStatic: false, + }, + { artifactRoot }, + ), + ).toThrow("Experiment simplified-s/arm64 failed: native build failed"); + }); +}); diff --git a/prototypes/windows-acl/test/experiment.test.ts b/prototypes/windows-acl/test/experiment.test.ts new file mode 100644 index 0000000000..5d520c1e28 --- /dev/null +++ b/prototypes/windows-acl/test/experiment.test.ts @@ -0,0 +1,169 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import experimentModule from "../experiment.cjs"; + +const { + buildEnvironment, + CELLS, + electron37Path, + parseArguments, + parsePortableExecutable, + targetEnvironmentName, + vitestEntrypoint, +} = experimentModule; +const temporaryDirectories: string[] = []; + +function temporaryDirectory(): string { + const directory = fs.mkdtempSync( + path.join(os.tmpdir(), "windows-acl-experiment-"), + ); + temporaryDirectories.push(directory); + return directory; +} + +function syntheticPortableExecutable( + machine: number, + importName: string, +): Buffer { + const buffer = Buffer.alloc(0x400); + buffer.write("MZ", 0, "ascii"); + buffer.writeUInt32LE(0x80, 0x3c); + buffer.write("PE\0\0", 0x80, "ascii"); + buffer.writeUInt16LE(machine, 0x84); + buffer.writeUInt16LE(1, 0x86); + buffer.writeUInt16LE(0xf0, 0x94); + buffer.writeUInt16LE(0x20b, 0x98); + buffer.writeUInt32LE(0x1000, 0x98 + 112 + 8); + const section = 0x188; + buffer.write(".rdata", section, "ascii"); + buffer.writeUInt32LE(0x200, section + 8); + buffer.writeUInt32LE(0x1000, section + 12); + buffer.writeUInt32LE(0x200, section + 16); + buffer.writeUInt32LE(0x200, section + 20); + buffer.writeUInt32LE(0x1080, 0x200 + 12); + buffer.writeUInt32LE(0, 0x200 + 20); + buffer.write(importName, 0x280, "ascii"); + return buffer; +} + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +describe("experiment", () => { + it("accepts only supported Windows Rust targets", () => { + expect(parseArguments(["--target", "x86_64-pc-windows-msvc"])).toBe( + "x86_64-pc-windows-msvc", + ); + expect(() => parseArguments([])).toThrow("--target is required"); + expect(() => parseArguments(["--target", "unsupported"])).toThrow( + "Unsupported Rust target", + ); + expect(() => parseArguments(["--unknown"])).toThrow("Unknown argument"); + }); + + it("includes the projection dependency comparison cell", () => { + const projection = CELLS.find((cell) => cell.name === "projection-s"); + expect(projection).toMatchObject({ + source: "current", + optLevel: "s", + crtStatic: false, + features: [ + "acl-prototype-helper/projection", + "acl-prototype-addon/projection", + ], + }); + }); + + it("uses distinct target directories and Cargo profile overrides", () => { + const directory = temporaryDirectory(); + const previousRustFlags = process.env.RUSTFLAGS; + const previousEncodedRustFlags = process.env.CARGO_ENCODED_RUSTFLAGS; + process.env.RUSTFLAGS = "-C debuginfo=2"; + process.env.CARGO_ENCODED_RUSTFLAGS = "-C\x1fdebuginfo=2"; + try { + const dynamic = buildEnvironment( + "x86_64-pc-windows-msvc", + { + name: "simplified-s", + source: "current", + optLevel: "s", + crtStatic: false, + }, + directory, + ); + const staticEnvironment = buildEnvironment( + "aarch64-pc-windows-msvc", + { + name: "simplified-s-static", + source: "current", + optLevel: "s", + crtStatic: true, + }, + path.join(directory, "static"), + ); + + expect(dynamic.CARGO_TARGET_DIR).toBe(directory); + expect(dynamic.CARGO_PROFILE_RELEASE_OPT_LEVEL).toBe("s"); + expect(dynamic.CARGO_PROFILE_RELEASE_PANIC).toBe("unwind"); + expect(dynamic.CARGO_PROFILE_RELEASE_LTO).toBe("true"); + expect(dynamic.CARGO_PROFILE_RELEASE_STRIP).toBe("true"); + expect(dynamic.RUSTFLAGS).toBeUndefined(); + expect(dynamic.CARGO_ENCODED_RUSTFLAGS).toBeUndefined(); + expect( + dynamic[targetEnvironmentName("x86_64-pc-windows-msvc")], + ).toBeUndefined(); + expect( + staticEnvironment[targetEnvironmentName("aarch64-pc-windows-msvc")], + ).toBe("-C target-feature=+crt-static"); + expect( + buildEnvironment( + "x86_64-pc-windows-msvc", + { name: "original", source: "baseline" }, + directory, + ).CARGO_PROFILE_RELEASE_OPT_LEVEL, + ).toBeUndefined(); + } finally { + if (previousRustFlags === undefined) delete process.env.RUSTFLAGS; + else process.env.RUSTFLAGS = previousRustFlags; + if (previousEncodedRustFlags === undefined) { + delete process.env.CARGO_ENCODED_RUSTFLAGS; + } else { + process.env.CARGO_ENCODED_RUSTFLAGS = previousEncodedRustFlags; + } + } + }); + + it("requires an absolute Electron 37 executable path", () => { + expect(() => electron37Path()).toThrow("ACL_ELECTRON37_PATH"); + expect(() => electron37Path("relative/electron.exe")).toThrow( + "ACL_ELECTRON37_PATH", + ); + }); + + it("resolves Vitest through its exported package manifest", () => { + expect(path.basename(vitestEntrypoint())).toBe("vitest.mjs"); + expect(fs.statSync(vitestEntrypoint()).isFile()).toBe(true); + }); + + it("reads architectures and imports from PE fixtures", () => { + expect( + parsePortableExecutable( + syntheticPortableExecutable(0x8664, "KERNEL32.dll"), + ), + ).toEqual({ architecture: "x64", imports: ["KERNEL32.dll"] }); + expect( + parsePortableExecutable( + syntheticPortableExecutable(0xaa64, "ADVAPI32.dll"), + ), + ).toEqual({ architecture: "arm64", imports: ["ADVAPI32.dll"] }); + expect(() => parsePortableExecutable(Buffer.from("not pe"))).toThrow( + "Not a PE file", + ); + }); +}); diff --git a/prototypes/windows-acl/test/windows-native.test.ts b/prototypes/windows-acl/test/windows-native.test.ts index 667aeb6f44..b5d850c835 100644 --- a/prototypes/windows-acl/test/windows-native.test.ts +++ b/prototypes/windows-acl/test/windows-native.test.ts @@ -25,6 +25,23 @@ afterEach(async () => { describe.runIf(process.platform === "win32")( "staged Windows ACL implementations", () => { + it("rejects a final directory junction without changing its target", async () => { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), "windows-acl-junction-"), + ); + temporaryDirectories.push(root); + const target = path.join(root, "target"); + const junction = path.join(root, "junction"); + await fs.mkdir(target); + await fs.symlink(target, junction, "junction"); + for (const variant of ["helper", "addon"] as const) { + const bridge = createBridge({ variant, artifactRoot }); + const before = await bridge.inspect(target); + await expect(bridge.secure(junction)).rejects.toThrow(); + expect(await bridge.inspect(target)).toBe(before); + } + }); + it("repair an included file and preserve the policy through an atomic rewrite", async () => { const root = await fs.mkdtemp( path.join(os.tmpdir(), "windows-acl-native-"), diff --git a/prototypes/windows-acl/tsconfig.json b/prototypes/windows-acl/tsconfig.json index 6eed45a843..43df1df63a 100644 --- a/prototypes/windows-acl/tsconfig.json +++ b/prototypes/windows-acl/tsconfig.json @@ -5,5 +5,11 @@ "rootDir": ".", "types": ["node"] }, - "files": ["bridge.d.cts", "stage.d.cts", "package-prototype.d.cts"] + "files": [ + "bridge.d.cts", + "stage.d.cts", + "package-prototype.d.cts", + "experiment.d.cts", + "experiment-report.d.cts" + ] } From 29de7d9689f507496da32f76d6586e4159388590 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Mon, 14 Sep 2026 11:54:16 +0000 Subject: [PATCH 6/8] test(prototypes): isolate missing runtime fixture from runner environment --- prototypes/windows-acl/test/experiment.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/prototypes/windows-acl/test/experiment.test.ts b/prototypes/windows-acl/test/experiment.test.ts index 5d520c1e28..877f5503c5 100644 --- a/prototypes/windows-acl/test/experiment.test.ts +++ b/prototypes/windows-acl/test/experiment.test.ts @@ -140,7 +140,7 @@ describe("experiment", () => { }); it("requires an absolute Electron 37 executable path", () => { - expect(() => electron37Path()).toThrow("ACL_ELECTRON37_PATH"); + expect(() => electron37Path("")).toThrow("ACL_ELECTRON37_PATH"); expect(() => electron37Path("relative/electron.exe")).toThrow( "ACL_ELECTRON37_PATH", ); From 1964289713733f46e5157324a2411c37cec796a2 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Mon, 14 Sep 2026 12:08:41 +0000 Subject: [PATCH 7/8] docs(prototypes): record Windows ACL experiment results --- prototypes/windows-acl/README.md | 146 +++++++++++++++++++++---------- 1 file changed, 99 insertions(+), 47 deletions(-) diff --git a/prototypes/windows-acl/README.md b/prototypes/windows-acl/README.md index d561c277ac..a63a640934 100644 --- a/prototypes/windows-acl/README.md +++ b/prototypes/windows-acl/README.md @@ -5,13 +5,16 @@ SSH writer. Do not apply these prototypes to real user config directories. ## Scope -Compare a Rust executable against a Rust Node-API addon using the same Win32 core. +Compare a Rust executable against a Rust Node-API addon using interchangeable Win32 cores. The intended distribution remains one universal VSIX containing Windows x64 and ARM64 assets. Linux/macOS return before resolving, loading, or executing native code. Native Windows builds are separate from VSIX packaging; platform-specific Marketplace releases are not required. - `core`: direct `windows-sys` ACL operations on an existing file or directory. +- `core-windows`: Microsoft `windows` typed bindings with the same ACL policy. +- `experiment.cjs` / `experiment-report.cjs`: isolated builds, runtime checks, PE + import inspection, and universal package comparisons. - `helper`: process interface with a versioned JSON response. - `addon`: Node-API 8 interface using `napi-rs`, with work off the JS thread. - `bridge.cjs`: lazy, Windows-only selection and error propagation. @@ -26,31 +29,88 @@ variant, with both Windows architectures in the same universal extension. ## Results on September 14, 2026 -Performed using Rust 1.98.1. Native Windows results are from Actions run -`34835778683`; package inspection was repeated locally with its real payloads: - -| Check | Result | -| ----------------------------------------------- | -------------------------------------------------- | -| Linux release build: helper + addon | Passed | -| Linux Rust unit tests | Passed (unsupported-platform behavior only) | -| Clippy, all targets, warnings denied | Passed on Linux and Windows source checks | -| Windows MSVC source/test check, x64 | Passed; not linked or executed | -| Windows MSVC source/test check, ARM64 | Passed; not linked or executed | -| Bridge/staging/assembly tests | 27 passed, 1 Windows-only skip with real payloads | -| Windows OpenSSH integration | Passed on Windows x64 and ARM64 | -| Actual Windows universal VSIX archives | Passed locally with both real Windows payloads | -| macOS/Linux native bypass | Passed using injected platform/architecture values | -| Actual macOS runtime | Bridge bypass tests passed on macos-15 | -| Node 24.15.0 transport probe | Passed | -| Electron 37.10.3 / Node 22.21.1 transport probe | Passed | -| Electron 42.5.1 / Node 24.17.0 transport probe | Passed | -| Production VSIX listing excludes prototypes | Passed with `vsce ls --no-dependencies` | - -The same Linux `.node` binary was loaded in all three runtimes, without rebuild. -This validates interface loading/error handling, NOT Windows ACL correctness. -The explicit transport probe is development-only: it deliberately loads a Linux -build that always returns Unsupported for ACL operations. No Linux native asset -is intended for distribution. +Measured at commit `29de7d9` using Rust 1.98.1. Prototype Actions run +`34840561914` and standard repository CI run `34840562010` both passed. + +- All six configurations built helper/addon payloads on native Windows x64 and + ARM64 and passed the shared OpenSSH rejection/repair/rewrite, idempotence, and + final-junction rejection tests under Node 22 and Electron 37/42. +- Both cores' Rust tests passed. The typed core still needs the low-level core's + independent exact-ACE and directory-inheritance assertions before adoption. +- Linux/macOS bridge-bypass jobs and all twelve real universal VSIX archive + inspections passed. Inert package activation is not a full editor integration test. +- PE import inspection covered both interfaces and architectures in every cell. + +### Universal package size + +Each experimental package contains x64 and ARM64 assets for one interface. +These are compressed prototype sizes, not production extension sizes. + +| Configuration | Helper (KiB) | Addon (KiB) | +| --------------------------------- | -----------: | ----------: | +| Original manual ACL, opt 3 | 190.7 | 246.6 | +| Low-level SDDL, opt 3 | 194.2 | 250.1 | +| Low-level SDDL, opt s | 160.5 | 231.1 | +| Microsoft `windows` + SDDL, opt s | 160.9 | 231.7 | +| Low-level SDDL, opt z | 159.5 | 223.0 | +| Low-level SDDL, opt s, static CRT | 254.7 | 320.4 | + +All cells use LTO, one codegen unit, stripping, and unwind panic strategy. The +original source at `22a751efc2fc859641e387921e115d01829db1ac` was rebuilt on the +same runners. At equal opt s, typed bindings add only 380 helper package bytes +and 693 addon package bytes. Size reductions versus original include compiler +optimization changes; they are not solely a dependency benefit. No latency +distributions were measured. + +### Handwritten Rust and dependency maintenance + +Counts include production-core comments/blanks and exclude the line containing +its first `#[cfg(test)]` and everything after it. Unsafe-token counts are rough +indicators, not safety scores. + +| Core | Physical lines | Nonblank lines | `unsafe` tokens | +| -------------------------- | -------------: | -------------: | --------------: | +| Original manual ACL | 427 | 381 | 17 | +| Low-level SDDL | 438 | 395 | 19 | +| Microsoft `windows` + SDDL | 352 | 317 | 20 | + +The typed implementation has 75 fewer lines than original (~18%), but more +unsafe sites. Some reduction comes from implementation choices such as +`IsWellKnownSid`, not the dependency alone. SDDL with `windows-sys` did not +reduce total code or package size at equal opt 3; it replaces manual ACL layout +arithmetic with parser/conversion lifetime handling. + +`windows = 0.62.2` supplies typed API signatures and some error plumbing, not a +complete safe ACL abstraction. We still own descriptor allocation lifetimes, +aligned token/SID storage, handle sequencing, and owner/reparse policy. It adds +11 registry packages to this experimental lockfile, not separately shipped DLLs. +A selected implementation would retain only one core. + +The published-source dependency investigation found no complete maintained safe +wrapper matching this boundary. `winsafe 0.0.29` lacks the central security-info +and SDDL APIs; `windows-acl` and `windows-permissions` use older `winapi` bindings +and do not cover the whole policy; `qiongli-windows-security` has a specialized +owner-only policy rather than the required existing-handle protected-DACL write. +SID-only helpers and descriptor parsers cannot replace the central operation. +The Microsoft typed binding was therefore implemented and measured rather than +rejected on dependency count alone. + +### Runtime linkage + +All dynamic cells import `VCRUNTIME140.dll` and UCRT API-set DLLs on both +architectures. Static CRT removes these explicit imports for both interfaces, +with approximately 94 KiB helper / 89 KiB addon universal-package growth versus +low-level opt s. Windows OS DLL dependencies remain. + +The typed-binding plus static-CRT combination was not tested. Neither hosted +runner success nor import inspection proves clean-machine portability. Signing, +application control, DLL search/integrity, and addon CRT ownership boundaries +remain deployment review work. No production linkage choice has been made. + +The consolidated measurements are in `report.json` of the +`acl-universal-prototypes` artifact from run `34840561914`. The workflow rebuilds +all cells and records source revisions, compiler versions, PE imports, native +bytes, runtime outcomes, and package bytes. ## Reproduce checks @@ -104,9 +164,9 @@ The experimental extensions have inert activation. Installing them alone does not exercise ACL behavior; use the bridge/native test harness explicitly. The archive tests currently use `unzip`; run them on the Linux assembly host. -Before selecting a production implementation, also inspect PE DLL dependencies, -validate signing/application-control behavior, exercise real macOS activation -with no native assets, and test the minimum supported Windows editor runtime. +Before selecting a production implementation, validate clean-machine and +signing/application-control behavior, exercise real editor activation, and +equalize the cores' independent ACL-shape and inheritance assertions. ## Interpretation @@ -118,26 +178,18 @@ the host process. Both passed the same Windows x64/ARM64 tests under Node 22 and 37/42. Neither has been proven operationally superior on end-user machines. The macOS keyring history argues for strict platform gating and package-level regression tests, not a claim that shipping binaries is risk-free. -## Measured payloads +## Decision gate -The experimental universal packages built from run `34835778683` contain: +For discussion, the typed-binding helper is the strongest measured candidate for +less handwritten Rust and avoiding native code inside the extension host. This +is not a safety proof or an adoption decision. The helper's same-user process +boundary provides memory/crash containment, not a sandbox. -| Payload | Helper | Addon | -| --------------------------- | --------------------- | --------------------- | -| Windows x64 | 186 KiB | 291.5 KiB | -| Windows ARM64 | 178.5 KiB | 268.5 KiB | -| Universal VSIX (compressed) | approximately 191 KiB | approximately 247 KiB | - -These are standalone prototype package sizes, not the production extension size. -`objdump -p` showed that both x64 variants import `VCRUNTIME140.dll` and Universal -CRT API DLLs. Hosted-runner success therefore does not establish that either -payload is self-contained on a clean user machine. ARM64 DLL inspection remains -outstanding. No runtime-linking or redistribution choice has been made. - -The first native run exposed a test-only SDDL spelling assumption (SID aliases and -auto-inheritance descriptor flags); the test now inspects actual protection and -ACE semantics. The first package job exposed an incorrect artifact lookup path; -the package job now requires both architectures instead of silently skipping. +A possible next experiment is typed bindings plus static CRT, with independent +ACL/inheritance tests brought to parity. The user must approve narrowing to that +candidate, further experiments, and any later production integration. Both +interfaces remain experimental; no dependency, profile, or CRT option is selected +for production. ## Prototype limitations From 1134c39366b997178bba03a2b2870be6d557224d Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Mon, 14 Sep 2026 12:59:47 +0000 Subject: [PATCH 8/8] test(prototypes): compare typed ACL bindings with static CRT --- .github/workflows/windows-acl-prototypes.yaml | 6 + prototypes/windows-acl/README.md | 15 ++ .../windows-acl/core-windows/src/lib.rs | 181 +++++++++++++++++- prototypes/windows-acl/experiment.cjs | 25 +++ prototypes/windows-acl/experiment.d.cts | 3 + .../windows-acl/test/experiment.test.ts | 50 +++++ 6 files changed, 271 insertions(+), 9 deletions(-) diff --git a/.github/workflows/windows-acl-prototypes.yaml b/.github/workflows/windows-acl-prototypes.yaml index d88ba6a70a..83429fc901 100644 --- a/.github/workflows/windows-acl-prototypes.yaml +++ b/.github/workflows/windows-acl-prototypes.yaml @@ -55,6 +55,12 @@ jobs: cargo +1.98.1 clippy --workspace --all-features --all-targets --target ${{ matrix.target }} --manifest-path prototypes/windows-acl/Cargo.toml --locked -- -D warnings - name: Test native ACL core run: cargo +1.98.1 test -p acl-prototype-core -p acl-prototype-core-windows --target ${{ matrix.target }} --manifest-path prototypes/windows-acl/Cargo.toml --locked + - name: Test typed ACL core with static CRT + run: cargo +1.98.1 test -p acl-prototype-core-windows --release --target ${{ matrix.target }} --manifest-path prototypes/windows-acl/Cargo.toml --locked + env: + RUSTFLAGS: "-C target-feature=+crt-static" + CARGO_PROFILE_RELEASE_OPT_LEVEL: "s" + CARGO_TARGET_DIR: "prototypes/windows-acl/target/static-core-tests" - name: Resolve Electron 37 executable run: pnpm dlx electron@37.10.3 -e 'require("node:fs").appendFileSync(process.env.GITHUB_ENV, "ACL_ELECTRON37_PATH=" + process.execPath + "\n")' env: diff --git a/prototypes/windows-acl/README.md b/prototypes/windows-acl/README.md index a63a640934..8122acdded 100644 --- a/prototypes/windows-acl/README.md +++ b/prototypes/windows-acl/README.md @@ -178,6 +178,21 @@ the host process. Both passed the same Windows x64/ARM64 tests under Node 22 and 37/42. Neither has been proven operationally superior on end-user machines. The macOS keyring history argues for strict platform gating and package-level regression tests, not a claim that shipping binaries is risk-free. +## Static CRT follow-up + +The user approved accepting a larger package to reduce deployment dependencies. +`projection-s-static` compares typed bindings with the existing dynamic +`projection-s` cell at identical optimization settings. The existing addon +comparison remains intact; the deployment assessment prioritizes the helper. +Static cells fail if the inspected PE import table contains VC/UCRT runtime DLLs. +This checks direct imports, not transitive or dynamically loaded dependencies, +and is not a substitute for running on a clean Windows installation. + +Independent typed-core tests check exact file/directory ACEs, real child +inheritance, path rejection, and the owner allow-list. These follow-up changes +require native runner validation; the results above describe the earlier six-cell +experiment, not this seven-cell run. + ## Decision gate For discussion, the typed-binding helper is the strongest measured candidate for diff --git a/prototypes/windows-acl/core-windows/src/lib.rs b/prototypes/windows-acl/core-windows/src/lib.rs index f529dc40ec..1448a48b48 100644 --- a/prototypes/windows-acl/core-windows/src/lib.rs +++ b/prototypes/windows-acl/core-windows/src/lib.rs @@ -353,28 +353,191 @@ mod windows { #[cfg(test)] mod tests { use super::*; + use std::ffi::OsString; use std::fs; + use std::mem::size_of; + use std::os::windows::ffi::OsStringExt; + use std::path::{Path, PathBuf}; + use windows::Win32::Security::{ + ACCESS_ALLOWED_ACE, CreateWellKnownSid, GetAce, GetSecurityDescriptorControl, + SE_DACL_PROTECTED, WELL_KNOWN_SID_TYPE, WinWorldSid, + }; + use windows::Win32::Storage::FileSystem::FILE_ALL_ACCESS; - #[test] - fn secure_path_applies_an_idempotent_protected_dacl() { - let path = std::env::temp_dir().join(format!( - "acl-prototype-windows-{}-{}.conf", + const ACCESS_ALLOWED_ACE_TYPE: u8 = 0; + const OBJECT_INHERIT_ACE: u8 = 0x01; + const CONTAINER_INHERIT_ACE: u8 = 0x02; + const INHERITED_ACE: u8 = 0x10; + + fn temporary_path(name: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "acl-prototype-windows-{name}-{}-{}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos(), - )); - fs::write(&path, "Host acl-prototype\n").unwrap(); + )) + } + + fn well_known_sid(kind: WELL_KNOWN_SID_TYPE) -> Sid { + let mut length = 0; + let _ = unsafe { CreateWellKnownSid(kind, None, None, &mut length) }; + assert_ne!(length, 0, "Windows did not report the well-known SID size"); + + let mut storage = vec![0usize; (length as usize).div_ceil(size_of::())]; + unsafe { + CreateWellKnownSid( + kind, + None, + Some(PSID(storage.as_mut_ptr().cast())), + &mut length, + ) + .unwrap(); + } + Sid(storage) + } + + fn trusted_sids() -> (Sid, Sid, Sid) { + ( + current_user_sid().unwrap(), + well_known_sid(WinLocalSystemSid), + well_known_sid(WinBuiltinAdministratorsSid), + ) + } + + fn assert_acl(path: &Path, expected_sids: [&Sid; 3], expected_flags: u8, protected: bool) { + let file = open(path, READ_CONTROL.0).unwrap(); + let descriptor = SecurityDescriptor::get(&file).unwrap(); + let mut control = 0; + let mut revision = 0; + unsafe { + GetSecurityDescriptorControl(descriptor.0, &mut control, &mut revision).unwrap(); + } + assert_eq!( + control & SE_DACL_PROTECTED.0 != 0, + protected, + "protected DACL state was unexpected for {}", + path.display() + ); + + let dacl = descriptor.dacl().unwrap(); + assert_eq!(dacl.AceCount, 3, "DACL must contain three ACEs"); + + for (index, expected_sid) in expected_sids.into_iter().enumerate() { + let mut ace = std::ptr::null_mut(); + unsafe { + GetAce(dacl, index as u32, &mut ace).unwrap(); + let ace = ace.cast::(); + assert_eq!( + (*ace).Header.AceType, + ACCESS_ALLOWED_ACE_TYPE, + "ACE {index} must allow access", + ); + assert_eq!( + (*ace).Header.AceFlags, + expected_flags, + "ACE {index} inheritance flags were unexpected", + ); + assert_eq!( + (*ace).Mask, + FILE_ALL_ACCESS.0, + "ACE {index} must grant full control", + ); + let sid = PSID(std::ptr::addr_of!((*ace).SidStart).cast_mut().cast()); + assert!( + EqualSid(sid, expected_sid.as_psid()).is_ok(), + "ACE {index} SID did not match", + ); + } + } + } + + #[test] + fn validate_owner_accepts_trusted_sids_and_rejects_world() { + let (current_user, system, administrators) = trusted_sids(); + for owner in [¤t_user, &system, &administrators] { + validate_owner(owner.as_psid(), current_user.as_psid()).unwrap(); + } + + let world = well_known_sid(WinWorldSid); + assert_eq!( + validate_owner(world.as_psid(), current_user.as_psid()) + .unwrap_err() + .kind(), + io::ErrorKind::PermissionDenied + ); + } + + #[test] + fn open_rejects_relative_and_nul_paths() { + assert_eq!( + open(Path::new("relative"), READ_CONTROL.0) + .unwrap_err() + .kind(), + io::ErrorKind::InvalidInput + ); + let nul_path = PathBuf::from(OsString::from_wide(&[ + b'C' as u16, + b':' as u16, + b'\\' as u16, + 0, + b'x' as u16, + ])); + assert_eq!( + open(&nul_path, READ_CONTROL.0).unwrap_err().kind(), + io::ErrorKind::InvalidInput + ); + } + + #[test] + fn secure_path_applies_an_exact_protected_file_dacl_idempotently() { + let (current_user, system, administrators) = trusted_sids(); + let path = temporary_path("file"); + fs::write(&path, "test").unwrap(); secure_path(&path).unwrap(); let first = inspect_path(&path).unwrap(); secure_path(&path).unwrap(); - let second = inspect_path(&path).unwrap(); + assert_eq!( + inspect_path(&path).unwrap(), + first, + "file DACL must be idempotent" + ); + assert!( + !first.ends_with('\0'), + "SDDL must not retain the API terminator" + ); + assert_acl(&path, [¤t_user, &system, &administrators], 0, true); - assert!(first.contains("D:P"), "DACL must be protected: {first}"); - assert_eq!(second, first, "secure_path must be idempotent"); fs::remove_file(path).unwrap(); } + + #[test] + fn secure_path_applies_inheritable_protected_directory_aces() { + let (current_user, system, administrators) = trusted_sids(); + let directory = temporary_path("directory"); + fs::create_dir(&directory).unwrap(); + + secure_path(&directory).unwrap(); + assert_acl( + &directory, + [¤t_user, &system, &administrators], + OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE, + true, + ); + + let child = directory.join("child.txt"); + fs::write(&child, "test").unwrap(); + assert_acl( + &child, + [¤t_user, &system, &administrators], + INHERITED_ACE, + false, + ); + + fs::remove_file(child).unwrap(); + fs::remove_dir(directory).unwrap(); + } } } diff --git a/prototypes/windows-acl/experiment.cjs b/prototypes/windows-acl/experiment.cjs index dbf033aaa1..36464935f7 100644 --- a/prototypes/windows-acl/experiment.cjs +++ b/prototypes/windows-acl/experiment.cjs @@ -38,6 +38,16 @@ const CELLS = [ "acl-prototype-addon/projection", ], }, + { + name: "projection-s-static", + source: "current", + optLevel: "s", + crtStatic: true, + features: [ + "acl-prototype-helper/projection", + "acl-prototype-addon/projection", + ], + }, { name: "simplified-z", source: "current", @@ -202,6 +212,19 @@ function parsePortableExecutable(buffer) { return { architecture, imports: imports.sort() }; } +function verifyStaticRuntime(binaries) { + for (const binary of binaries) { + const runtimeImports = binary.pe.imports.filter((name) => + /^(?:vcruntime|msvcp|msvcr|ucrtbase|api-ms-win-crt-)/i.test(name), + ); + if (runtimeImports.length > 0) { + throw new Error( + `Static CRT payload ${binary.name} imports runtime DLLs: ${runtimeImports.join(", ")}`, + ); + } + } +} + function copyBuildOutputs(targetDirectory, target, artifactDirectory) { const releaseDirectory = path.join(targetDirectory, target, "release"); const outputs = [ @@ -381,6 +404,7 @@ function runExperiment(target, options = {}) { targetDirectory, binaries: copyBuildOutputs(targetDirectory, target, artifactDirectory), }; + if (cell.crtStatic) verifyStaticRuntime(report.build.binaries); copyBuildOutputs( targetDirectory, target, @@ -434,4 +458,5 @@ module.exports = { sourceRootFor, targetEnvironmentName, vitestEntrypoint, + verifyStaticRuntime, }; diff --git a/prototypes/windows-acl/experiment.d.cts b/prototypes/windows-acl/experiment.d.cts index 644777ce6b..8f702cd790 100644 --- a/prototypes/windows-acl/experiment.d.cts +++ b/prototypes/windows-acl/experiment.d.cts @@ -31,6 +31,9 @@ export function buildEnvironment( targetDirectory: string, ): NodeJS.ProcessEnv; export function parsePortableExecutable(buffer: Buffer): PortableExecutable; +export function verifyStaticRuntime( + binaries: Array<{ name: string; pe: PortableExecutable }>, +): void; export function reportFilename(arch: "x64" | "arm64"): string; export function electron37Path(value?: string): string; export function vitestEntrypoint(): string; diff --git a/prototypes/windows-acl/test/experiment.test.ts b/prototypes/windows-acl/test/experiment.test.ts index 877f5503c5..6f82cdf209 100644 --- a/prototypes/windows-acl/test/experiment.test.ts +++ b/prototypes/windows-acl/test/experiment.test.ts @@ -13,6 +13,7 @@ const { parsePortableExecutable, targetEnvironmentName, vitestEntrypoint, + verifyStaticRuntime, } = experimentModule; const temporaryDirectories: string[] = []; @@ -80,6 +81,55 @@ describe("experiment", () => { }); }); + it("compares typed bindings with identical settings except CRT linkage", () => { + const dynamic = CELLS.find((cell) => cell.name === "projection-s"); + const staticCell = CELLS.find( + (cell) => cell.name === "projection-s-static", + ); + expect(dynamic).toBeDefined(); + expect(staticCell).toEqual({ + ...dynamic, + name: "projection-s-static", + crtStatic: true, + }); + }); + + it.each([ + "VCRUNTIME140.dll", + "vcruntime140_1.dll", + "MSVCP140.dll", + "msvcrt.dll", + "ucrtbase.dll", + "api-ms-win-crt-runtime-l1-1-0.dll", + ])("rejects a static payload importing %s", (name) => { + expect(() => + verifyStaticRuntime([ + { + name: "acl-helper.exe", + pe: { architecture: "x64", imports: ["KERNEL32.dll", name] }, + }, + ]), + ).toThrow(`imports runtime DLLs: ${name}`); + }); + + it("allows Windows OS imports in both static payloads", () => { + expect(() => + verifyStaticRuntime([ + { + name: "acl-helper.exe", + pe: { + architecture: "x64", + imports: ["KERNEL32.dll", "advapi32.dll"], + }, + }, + { + name: "acl.node", + pe: { architecture: "arm64", imports: ["ntdll.dll"] }, + }, + ]), + ).not.toThrow(); + }); + it("uses distinct target directories and Cargo profile overrides", () => { const directory = temporaryDirectory(); const previousRustFlags = process.env.RUSTFLAGS;