diff --git a/src/console/console-config-catalog.ts b/src/console/console-config-catalog.ts index 7a30641..b1b80dc 100644 --- a/src/console/console-config-catalog.ts +++ b/src/console/console-config-catalog.ts @@ -3,7 +3,11 @@ import { constants, type BigIntStats, type Stats } from "node:fs"; import { lstat, open, readdir, realpath, stat } from "node:fs/promises"; import { isAbsolute, join, relative, resolve } from "node:path"; import { createConfigMigrationSource } from "../cli/migrate-config.js"; -import { verifyWindowsConfigPathSecurity } from "../cli/windows-config-acl.js"; +import { + verifyWindowsConfigPathSecurity, + verifyWindowsConfigPathsSecurity, + type WindowsPrivatePath +} from "../cli/windows-config-acl.js"; import { loadConfigFromText } from "../config/load-config.js"; import { consoleInitializedConfigMetadata, @@ -26,6 +30,8 @@ export interface ConsoleConfigCatalogDiscoveryOptions { readonly ownerUid?: number; /** Test seam for Windows DACL verification. */ readonly windowsAclVerifier?: WindowsConfigAclVerifier; + /** Test seam for batched Windows DACL verification of one stable boundary. */ + readonly windowsAclPathsVerifier?: WindowsConfigAclPathsVerifier; /** Test-only opaque diagnostic observer for one catalog invocation. */ readonly candidateStageObserver?: ConsoleConfigCatalogCandidateStageObserver; /** Test-only comparison of number and BigInt file-handle identities. */ @@ -34,6 +40,8 @@ export interface ConsoleConfigCatalogDiscoveryOptions { export type WindowsConfigAclVerifier = (path: string, kind: "file" | "directory") => Promise; +export type WindowsConfigAclPathsVerifier = (paths: readonly WindowsPrivatePath[]) => Promise; + /** Opaque test-only stages for diagnosing a rejected catalog candidate. */ export type ConsoleConfigCatalogCandidateStage = | "acl" @@ -81,6 +89,10 @@ export type ConsoleConfigCatalogCandidateIdentityDiagnosticObserver = ( type TrustedConfigurationFileHandle = Awaited>; +interface WindowsCatalogAclBoundary { + verified: boolean; +} + function observeCandidateStage( observer: ConsoleConfigCatalogCandidateStageObserver | undefined, candidateIndex: number, @@ -168,14 +180,19 @@ function defaultOwnerUid(platform: NodeJS.Platform): number | undefined { } async function hasTrustedWindowsAcl( - path: string, - kind: "file" | "directory", + paths: readonly WindowsPrivatePath[], platform: NodeJS.Platform, - verifier: WindowsConfigAclVerifier + verifier: WindowsConfigAclVerifier, + pathsVerifier: WindowsConfigAclPathsVerifier, + useBatchedVerifier: boolean ): Promise { if (platform !== "win32") return true; try { - return await verifier(path, kind); + if (useBatchedVerifier) return await pathsVerifier(paths); + for (const path of paths) { + if (!(await verifier(path.path, path.kind))) return false; + } + return true; } catch { return false; } @@ -185,7 +202,9 @@ async function trustedDirectory( directory: string, ownerUid: number | undefined, platform: NodeJS.Platform, - windowsAclVerifier: WindowsConfigAclVerifier + windowsAclVerifier: WindowsConfigAclVerifier, + windowsAclPathsVerifier: WindowsConfigAclPathsVerifier, + useBatchedWindowsAclVerifier: boolean ): Promise { let observed: BigIntStats; try { @@ -200,7 +219,13 @@ async function trustedDirectory( if (!isTrustedDirectory(resolved, ownerUid, platform) || !sameBigIntFileIdentity(observed, resolved)) { throw new Error("unsafe configuration directory"); } - if (!(await hasTrustedWindowsAcl(canonical, "directory", platform, windowsAclVerifier))) { + if (!useBatchedWindowsAclVerifier && !(await hasTrustedWindowsAcl( + [{ path: canonical, kind: "directory" }], + platform, + windowsAclVerifier, + windowsAclPathsVerifier, + false + ))) { throw new Error("unsafe configuration directory"); } return canonical; @@ -227,6 +252,9 @@ async function readTrustedConfiguration( ownerUid: number | undefined, platform: NodeJS.Platform, windowsAclVerifier: WindowsConfigAclVerifier, + windowsAclPathsVerifier: WindowsConfigAclPathsVerifier, + useBatchedWindowsAclVerifier: boolean, + windowsAclBoundary: WindowsCatalogAclBoundary, candidateIndex: number, candidateStageObserver: ConsoleConfigCatalogCandidateStageObserver | undefined, candidateIdentityObserver: ConsoleConfigCatalogCandidateIdentityDiagnosticObserver | undefined @@ -244,10 +272,20 @@ async function readTrustedConfiguration( if (!isWithin(directory, canonical)) return undefined; const resolved = await stat(canonical, { bigint: true }); if (!isTrustedFile(resolved, ownerUid, platform) || !sameBigIntFileIdentity(observed, resolved)) return undefined; - if (!(await hasTrustedWindowsAcl(canonical, "file", platform, windowsAclVerifier))) { + const aclPaths: readonly WindowsPrivatePath[] = useBatchedWindowsAclVerifier + ? [{ path: directory, kind: "directory" }, { path: canonical, kind: "file" }] + : [{ path: canonical, kind: "file" }]; + if (!(await hasTrustedWindowsAcl( + aclPaths, + platform, + windowsAclVerifier, + windowsAclPathsVerifier, + useBatchedWindowsAclVerifier + ))) { observeCandidateStage(candidateStageObserver, candidateIndex, "acl", "rejected"); return undefined; } + if (useBatchedWindowsAclVerifier) windowsAclBoundary.verified = true; observeCandidateStage(candidateStageObserver, candidateIndex, "acl", "success"); let handle: TrustedConfigurationFileHandle; @@ -353,11 +391,22 @@ export async function discoverConsoleConfigCatalog( const platform = options.platform ?? process.platform; const ownerUid = options.ownerUid ?? defaultOwnerUid(platform); const windowsAclVerifier = options.windowsAclVerifier ?? verifyWindowsConfigPathSecurity; + const windowsAclPathsVerifier = options.windowsAclPathsVerifier ?? verifyWindowsConfigPathsSecurity; + const useBatchedWindowsAclVerifier = platform === "win32" && ( + options.windowsAclPathsVerifier !== undefined || options.windowsAclVerifier === undefined + ); const candidateStageObserver = options.candidateStageObserver; const candidateIdentityObserver = options.candidateIdentityObserver; let directory: string | undefined; try { - directory = await trustedDirectory(resolve(options.configDirectory), ownerUid, platform, windowsAclVerifier); + directory = await trustedDirectory( + resolve(options.configDirectory), + ownerUid, + platform, + windowsAclVerifier, + windowsAclPathsVerifier, + useBatchedWindowsAclVerifier + ); } catch { return { catalog: { source: "standard-config-directory", discoveryState: "unavailable", configurations: [] }, @@ -371,6 +420,10 @@ export async function discoverConsoleConfigCatalog( }; } + // The default Windows path verifies the canonical directory and each + // candidate together below. Names are not surfaced until that bounded + // trusted boundary succeeds; an empty or rejected catalog still verifies + // the directory alone before it can report readiness. let names: readonly string[]; try { names = (await readdir(directory)).filter((name) => configurationFileName.test(name)).sort((left, right) => left.localeCompare(right)); @@ -384,6 +437,7 @@ export async function discoverConsoleConfigCatalog( const identities = new Set(); const numberIdentities = candidateIdentityObserver === undefined ? undefined : new Set(); const configurations: DiscoveredConsoleConfiguration[] = []; + const windowsAclBoundary: WindowsCatalogAclBoundary = { verified: !useBatchedWindowsAclVerifier }; for (const [candidateIndex, name] of names.entries()) { try { const discovered = await readTrustedConfiguration( @@ -392,6 +446,9 @@ export async function discoverConsoleConfigCatalog( ownerUid, platform, windowsAclVerifier, + windowsAclPathsVerifier, + useBatchedWindowsAclVerifier, + windowsAclBoundary, candidateIndex, candidateStageObserver, candidateIdentityObserver @@ -441,6 +498,18 @@ export async function discoverConsoleConfigCatalog( // A malformed, raced, or untrusted candidate is never a Console entry. } } + if (useBatchedWindowsAclVerifier && !windowsAclBoundary.verified && !(await hasTrustedWindowsAcl( + [{ path: directory, kind: "directory" }], + platform, + windowsAclVerifier, + windowsAclPathsVerifier, + true + ))) { + return { + catalog: { source: "standard-config-directory", discoveryState: "unavailable", configurations: [] }, + configurations: [] + }; + } configurations.sort((left, right) => left.metadata.name.localeCompare(right.metadata.name) || left.metadata.id.localeCompare(right.metadata.id) ); diff --git a/tests/console-dashboard-application-service.test.ts b/tests/console-dashboard-application-service.test.ts index 3da18cf..b91a687 100644 --- a/tests/console-dashboard-application-service.test.ts +++ b/tests/console-dashboard-application-service.test.ts @@ -1,4 +1,4 @@ -import { chmod, link, lstat, mkdir, mkdtemp, open, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { chmod, link, lstat, mkdir, mkdtemp, open, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { basename, join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -6,7 +6,8 @@ import { discoverConsoleConfigCatalog, sameBigIntFileIdentity, type ConsoleConfigCatalogCandidateIdentityDiagnosticEvent, - type ConsoleConfigCatalogCandidateStageEvent + type ConsoleConfigCatalogCandidateStageEvent, + type ConsoleConfigCatalogDiscoveryOptions } from "../src/console/console-config-catalog.js"; import { ConsoleDashboardApplicationService } from "../src/console/console-dashboard-application-service.js"; import { buildPresetConfig } from "../src/config/presets.js"; @@ -1087,6 +1088,45 @@ describe("Console dashboard application service", () => { }); }); + it("batches Windows ACL verification for a trusted directory and candidate", async () => { + const root = await mkdtemp(join(tmpdir(), "miftah-console-dashboard-windows-acl-batch-")); + temporaryDirectories.push(root); + const directory = await createPrivateConsoleDirectory(root); + const configPath = join(directory, "gsc.json"); + await writeConfig(configPath, { + version: "3", + name: "gsc", + defaultProfile: "default", + upstream: { transport: "stdio", command: "node", args: [] }, + profiles: { default: {} } + }); + + const legacyCalls: Array<{ readonly path: string; readonly kind: "file" | "directory" }> = []; + const batches: Array = []; + const options: ConsoleConfigCatalogDiscoveryOptions = { + configDirectory: directory, + platform: "win32" as const, + windowsAclVerifier: async (path: string, kind: "file" | "directory") => { + legacyCalls.push({ path, kind }); + return false; + }, + windowsAclPathsVerifier: async (paths) => { + batches.push(paths); + return true; + } + }; + + await expect(discoverConsoleConfigCatalog(options)).resolves.toMatchObject({ + catalog: { discoveryState: "ready", configurations: [{ name: "gsc" }] } + }); + const [canonicalDirectory, canonicalConfigPath] = await Promise.all([realpath(directory), realpath(configPath)]); + expect(legacyCalls).toEqual([]); + expect(batches).toEqual([[ + { path: canonicalDirectory, kind: "directory" }, + { path: canonicalConfigPath, kind: "file" } + ]]); + }); + it.skipIf(process.platform === "win32")("revalidates a selected configuration before any later Console operation", async () => { const directory = await mkdtemp(join(tmpdir(), "miftah-console-dashboard-revalidate-")); temporaryDirectories.push(directory); diff --git a/tests/console-windows-first-run.test.ts b/tests/console-windows-first-run.test.ts index 8a7df05..08477a2 100644 --- a/tests/console-windows-first-run.test.ts +++ b/tests/console-windows-first-run.test.ts @@ -105,11 +105,13 @@ describe("Console Windows first-run boundary", () => { expect(aclMocks.createPrivateDirectoryInParent).toHaveBeenCalledWith(parent, configDirectory); // Catalog discovery independently verifies the created configuration after - // publication. The creator must not add a second directory probe before it. - expect(aclMocks.verifyPath).toHaveBeenCalledTimes(2); - expect(aclMocks.verifyPath).toHaveBeenNthCalledWith(1, expect.stringMatching(/[/\\]miftah$/u), "directory"); - expect(aclMocks.verifyPath).toHaveBeenNthCalledWith(2, expect.stringMatching(/[/\\]miftah\.json$/u), "file"); - expect(aclMocks.verifyPaths).not.toHaveBeenCalled(); + // publication, but it keeps the directory and file in one trusted boundary. + expect(aclMocks.verifyPath).not.toHaveBeenCalled(); + expect(aclMocks.verifyPaths).toHaveBeenCalledTimes(1); + expect(aclMocks.verifyPaths).toHaveBeenCalledWith([ + { path: expect.stringMatching(/[/\\]miftah$/u), kind: "directory" }, + { path: expect.stringMatching(/[/\\]miftah\.json$/u), kind: "file" } + ]); expect(aclMocks.writePrivateFile).toHaveBeenCalledWith(configPath, expect.any(String)); }); @@ -193,13 +195,15 @@ describe("Console Windows first-run boundary", () => { scopes: ["openid"] })).resolves.toMatchObject({ changed: true, write: true }); - expect(aclMocks.verifyPaths).toHaveBeenCalledWith([ + expect(aclMocks.verifyPaths).toHaveBeenNthCalledWith(1, [ { path: parent, kind: "directory" }, { path: configDirectory, kind: "directory" } ]); - expect(aclMocks.verifyPath).toHaveBeenCalledTimes(2); - expect(aclMocks.verifyPath).toHaveBeenNthCalledWith(1, expect.stringMatching(/[/\\]miftah$/u), "directory"); - expect(aclMocks.verifyPath).toHaveBeenNthCalledWith(2, expect.stringMatching(/[/\\]miftah\.json$/u), "file"); + expect(aclMocks.verifyPaths).toHaveBeenNthCalledWith(2, [ + { path: expect.stringMatching(/[/\\]miftah$/u), kind: "directory" }, + { path: expect.stringMatching(/[/\\]miftah\.json$/u), kind: "file" } + ]); + expect(aclMocks.verifyPath).not.toHaveBeenCalled(); }); it("fails closed before audit or config creation when the standard directory cannot be verified", async () => { @@ -287,8 +291,8 @@ describe("Console Windows first-run boundary", () => { expect(aclMocks.createPrivateDirectoryInParent).toHaveBeenCalledTimes(2); expect(aclMocks.secureFile).toHaveBeenCalledOnce(); expect(aclMocks.writePrivateFile).toHaveBeenCalledOnce(); - expect(aclMocks.verifyPath).toHaveBeenCalledTimes(5); - expect(aclMocks.verifyPaths).toHaveBeenCalledTimes(3); + expect(aclMocks.verifyPath).toHaveBeenCalledOnce(); + expect(aclMocks.verifyPaths).toHaveBeenCalledTimes(5); }); it("attributes Windows ACL helper launches across the complete first-run draft lifecycle", async () => { @@ -343,9 +347,9 @@ describe("Console Windows first-run boundary", () => { expect({ afterSave, afterLoad, afterPublication, afterDraftRead, afterConfiguredRead }).toEqual({ afterSave: { create: 0, createInParent: 1, secure: 1, write: 0, verify: 0, verifyBatch: 0 }, afterLoad: { create: 0, createInParent: 1, secure: 1, write: 0, verify: 0, verifyBatch: 1 }, - afterPublication: { create: 0, createInParent: 2, secure: 1, write: 1, verify: 3, verifyBatch: 2 }, - afterDraftRead: { create: 0, createInParent: 2, secure: 1, write: 1, verify: 3, verifyBatch: 3 }, - afterConfiguredRead: { create: 0, createInParent: 2, secure: 1, write: 1, verify: 5, verifyBatch: 3 } + afterPublication: { create: 0, createInParent: 2, secure: 1, write: 1, verify: 1, verifyBatch: 3 }, + afterDraftRead: { create: 0, createInParent: 2, secure: 1, write: 1, verify: 1, verifyBatch: 4 }, + afterConfiguredRead: { create: 0, createInParent: 2, secure: 1, write: 1, verify: 1, verifyBatch: 5 } }); });