Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 45 additions & 35 deletions src/console/console-config-catalog.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createHash } from "node:crypto";
import { constants, type Stats } from "node:fs";
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";
Expand Down Expand Up @@ -118,35 +118,44 @@ function errorCode(error: unknown): string | undefined {
: undefined;
}

function sameEntry(left: Pick<Stats, "dev" | "ino">, right: Pick<Stats, "dev" | "ino">): boolean {
/** Uses exact-width IDs because Node Number file IDs can be lossy on Windows. */
export function sameBigIntFileIdentity(
left: Pick<BigIntStats, "dev" | "ino">,
right: Pick<BigIntStats, "dev" | "ino">
): boolean {
return left.dev === right.dev && left.ino === right.ino;
}

function bigintFileIdentity(entry: Pick<BigIntStats, "dev" | "ino">): string {
return `${entry.dev}:${entry.ino}`;
}

function isWithin(parent: string, child: string): boolean {
const path = relative(parent, child);
return path.length > 0 && !path.startsWith("..") && !isAbsolute(path);
}

function hasExpectedOwner(entry: Pick<Stats, "uid">, ownerUid: number | undefined): boolean {
return ownerUid === undefined || entry.uid === ownerUid;
function hasExpectedOwner(entry: Pick<Stats | BigIntStats, "uid">, ownerUid: number | undefined): boolean {
if (ownerUid === undefined) return true;
return typeof entry.uid === "bigint" ? entry.uid === BigInt(ownerUid) : entry.uid === ownerUid;
}

function hasSafeDirectoryMode(entry: Pick<Stats, "mode">, platform: NodeJS.Platform): boolean {
function hasSafeDirectoryMode(entry: Pick<Stats | BigIntStats, "mode">, platform: NodeJS.Platform): boolean {
// Node does not expose Windows DACLs through Stats. The established Miftah
// Windows permission diagnostic is similarly skipped; non-link/canonical
// validation remains enforced on every platform.
return platform === "win32" || (Number(entry.mode) & 0o022) === 0;
}

function hasSafeFileMode(entry: Pick<Stats, "mode">, platform: NodeJS.Platform): boolean {
function hasSafeFileMode(entry: Pick<Stats | BigIntStats, "mode">, platform: NodeJS.Platform): boolean {
return platform === "win32" || (Number(entry.mode) & 0o066) === 0;
}

function isTrustedDirectory(entry: Stats, ownerUid: number | undefined, platform: NodeJS.Platform): boolean {
function isTrustedDirectory(entry: Stats | BigIntStats, ownerUid: number | undefined, platform: NodeJS.Platform): boolean {
return entry.isDirectory() && !entry.isSymbolicLink() && hasExpectedOwner(entry, ownerUid) && hasSafeDirectoryMode(entry, platform);
}

function isTrustedFile(entry: Stats, ownerUid: number | undefined, platform: NodeJS.Platform): boolean {
function isTrustedFile(entry: Stats | BigIntStats, ownerUid: number | undefined, platform: NodeJS.Platform): boolean {
return entry.isFile() && !entry.isSymbolicLink() && hasExpectedOwner(entry, ownerUid) && hasSafeFileMode(entry, platform);
}

Expand Down Expand Up @@ -178,17 +187,17 @@ async function trustedDirectory(
platform: NodeJS.Platform,
windowsAclVerifier: WindowsConfigAclVerifier
): Promise<string | undefined> {
let observed: Stats;
let observed: BigIntStats;
try {
observed = await lstat(directory);
observed = await lstat(directory, { bigint: true });
} catch (error) {
if (errorCode(error) === "ENOENT") return undefined;
throw error;
}
if (!isTrustedDirectory(observed, ownerUid, platform)) throw new Error("unsafe configuration directory");
const canonical = await realpath(directory);
const resolved = await lstat(canonical);
if (!isTrustedDirectory(resolved, ownerUid, platform) || !sameEntry(observed, resolved)) {
const resolved = await lstat(canonical, { bigint: true });
if (!isTrustedDirectory(resolved, ownerUid, platform) || !sameBigIntFileIdentity(observed, resolved)) {
throw new Error("unsafe configuration directory");
}
if (!(await hasTrustedWindowsAcl(canonical, "directory", platform, windowsAclVerifier))) {
Expand Down Expand Up @@ -223,16 +232,18 @@ async function readTrustedConfiguration(
candidateIdentityObserver: ConsoleConfigCatalogCandidateIdentityDiagnosticObserver | undefined
): Promise<{
readonly path: string;
/** Exact-width identity used for security comparisons and catalog dedupe. */
readonly identity: string;
readonly bigintIdentity?: string;
/** Test-only Number projection retained to diagnose platform precision loss. */
readonly numberIdentity?: string;
readonly trustedConfiguration: ConsoleTrustedConfiguration;
} | undefined> {
const observed = await lstat(path);
const observed = await lstat(path, { bigint: true });
if (!isTrustedFile(observed, ownerUid, platform)) return undefined;
const canonical = await realpath(path);
if (!isWithin(directory, canonical)) return undefined;
const resolved = await stat(canonical);
if (!isTrustedFile(resolved, ownerUid, platform) || !sameEntry(observed, resolved)) 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))) {
observeCandidateStage(candidateStageObserver, candidateIndex, "acl", "rejected");
return undefined;
Expand All @@ -249,26 +260,24 @@ async function readTrustedConfiguration(
observeCandidateStage(candidateStageObserver, candidateIndex, "open", "success");
try {
let opened: Stats;
let openedIdentity: BigIntStats;
try {
opened = await handle.stat();
[opened, openedIdentity] = await Promise.all([handle.stat(), handle.stat({ bigint: true })]);
} catch (error) {
observeCandidateStage(candidateStageObserver, candidateIndex, "opened-validation", "error");
throw error;
}
if (
!isTrustedFile(opened, ownerUid, platform) ||
!sameEntry(observed, opened) ||
!isTrustedFile(openedIdentity, ownerUid, platform) ||
!sameBigIntFileIdentity(observed, openedIdentity) ||
opened.size > maximumConfigurationBytes
) {
observeCandidateStage(candidateStageObserver, candidateIndex, "opened-validation", "rejected");
return undefined;
}
observeCandidateStage(candidateStageObserver, candidateIndex, "opened-validation", "success");
let bigintIdentity: string | undefined;
if (candidateIdentityObserver !== undefined) {
const openedBigInt = await handle.stat({ bigint: true });
bigintIdentity = `${openedBigInt.dev}:${openedBigInt.ino}`;
}
const identity = bigintFileIdentity(openedIdentity);
const numberIdentity = candidateIdentityObserver === undefined ? undefined : `${opened.dev}:${opened.ino}`;
let content: Buffer;
try {
content = await handle.readFile();
Expand All @@ -278,16 +287,17 @@ async function readTrustedConfiguration(
}
observeCandidateStage(candidateStageObserver, candidateIndex, "read", "success");
let afterRead: Stats;
let afterReadIdentity: BigIntStats;
try {
afterRead = await handle.stat();
[afterRead, afterReadIdentity] = await Promise.all([handle.stat(), handle.stat({ bigint: true })]);
} catch (error) {
observeCandidateStage(candidateStageObserver, candidateIndex, "after-read-validation", "error");
throw error;
}
if (
!isTrustedFile(afterRead, ownerUid, platform) ||
!sameEntry(observed, afterRead) ||
afterRead.size !== opened.size ||
!isTrustedFile(afterReadIdentity, ownerUid, platform) ||
!sameBigIntFileIdentity(observed, afterReadIdentity) ||
afterReadIdentity.size !== openedIdentity.size ||
content.byteLength > maximumConfigurationBytes
) {
observeCandidateStage(candidateStageObserver, candidateIndex, "after-read-validation", "rejected");
Expand Down Expand Up @@ -320,8 +330,8 @@ async function readTrustedConfiguration(
observeCandidateStage(candidateStageObserver, candidateIndex, "migration-source", "success");
return {
path: canonical,
identity: `${opened.dev}:${opened.ino}`,
...(bigintIdentity === undefined ? {} : { bigintIdentity }),
identity,
...(numberIdentity === undefined ? {} : { numberIdentity }),
trustedConfiguration: {
config,
contentDigest: createHash("sha256").update(content).digest("base64url"),
Expand Down Expand Up @@ -372,7 +382,7 @@ export async function discoverConsoleConfigCatalog(
}

const identities = new Set<string>();
const bigintIdentities = candidateIdentityObserver === undefined ? undefined : new Set<string>();
const numberIdentities = candidateIdentityObserver === undefined ? undefined : new Set<string>();
const configurations: DiscoveredConsoleConfiguration[] = [];
for (const [candidateIndex, name] of names.entries()) {
try {
Expand All @@ -387,15 +397,15 @@ export async function discoverConsoleConfigCatalog(
candidateIdentityObserver
);
if (discovered === undefined) continue;
const numberDuplicate = identities.has(discovered.identity);
const bigintDuplicate = discovered.bigintIdentity !== undefined && bigintIdentities?.has(discovered.bigintIdentity) === true;
const bigintDuplicate = identities.has(discovered.identity);
const numberDuplicate = discovered.numberIdentity !== undefined && numberIdentities?.has(discovered.numberIdentity) === true;
candidateIdentityObserver?.({ candidateIndex, numberDuplicate, bigintDuplicate });
if (numberDuplicate) {
if (bigintDuplicate) {
observeCandidateStage(candidateStageObserver, candidateIndex, "dedupe", "duplicate");
continue;
}
identities.add(discovered.identity);
if (discovered.bigintIdentity !== undefined) bigintIdentities?.add(discovered.bigintIdentity);
if (discovered.numberIdentity !== undefined) numberIdentities?.add(discovered.numberIdentity);
observeCandidateStage(candidateStageObserver, candidateIndex, "dedupe", "success");
let summary: ReturnType<typeof consoleInitializedConfigMetadata>;
try {
Expand Down
34 changes: 20 additions & 14 deletions tests/console-dashboard-application-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { basename, join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
discoverConsoleConfigCatalog,
sameBigIntFileIdentity,
type ConsoleConfigCatalogCandidateIdentityDiagnosticEvent,
type ConsoleConfigCatalogCandidateStageEvent
} from "../src/console/console-config-catalog.js";
Expand Down Expand Up @@ -155,6 +156,14 @@ function catalogStageSummary(
}

describe("Console dashboard application service", () => {
it("keeps distinct lossless file identities separate when Number coercion collides", () => {
const first = { dev: 1n, ino: 9_007_199_254_740_992n };
const second = { dev: 1n, ino: 9_007_199_254_740_993n };

expect(Number(first.ino)).toBe(Number(second.ino));
expect(sameBigIntFileIdentity(first, second)).toBe(false);
});

it.runIf(process.platform === "win32")("creates fixture files that pass the production Windows ACL verifier", async () => {
const root = await mkdtemp(join(tmpdir(), "miftah-console-dashboard-acl-"));
temporaryDirectories.push(root);
Expand Down Expand Up @@ -247,9 +256,7 @@ describe("Console dashboard application service", () => {
catalogStageDiagnostic.observer = undefined;
});
expect({
stageSummary: catalogStageSummary(candidateStages, initial.catalog?.configurations.map((configuration) => configuration.name)),
identitySummary: candidateIdentities,
...(process.platform === "win32" ? { fixtureIdentity, openedFixtureIdentity } : {})
stageSummary: catalogStageSummary(candidateStages, initial.catalog?.configurations.map((configuration) => configuration.name))
}).toEqual({
stageSummary: [
{
Expand Down Expand Up @@ -288,19 +295,18 @@ describe("Console dashboard application service", () => {
],
accepted: true
}
],
identitySummary: process.platform === "win32"
? [
{ candidateIndex: 0, bigintDuplicate: false, numberDuplicate: false },
{ candidateIndex: 1, bigintDuplicate: false, numberDuplicate: false }
]
: [],
...(process.platform === "win32" ? {
fixtureIdentity: { bigintIdentity: "different", numberIdentity: "different" },
openedFixtureIdentity: { bigintIdentity: "different", numberIdentity: "different" }
} : {})
]
});
if (process.platform === "win32") {
// Number identities are diagnostic only: Windows may legitimately collapse
// these distinct files. The catalog must retain both through BigInt IDs.
expect(fixtureIdentity?.bigintIdentity).toBe("different");
expect(openedFixtureIdentity?.bigintIdentity).toBe("different");
expect(candidateIdentities).toHaveLength(2);
expect(candidateIdentities[0]).toEqual({ candidateIndex: 0, bigintDuplicate: false, numberDuplicate: false });
expect(candidateIdentities[1]?.candidateIndex).toBe(1);
expect(candidateIdentities[1]?.bigintDuplicate).toBe(false);
expect(typeof candidateIdentities[1]?.numberDuplicate).toBe("boolean");
expect({
aclProbeSummary: [...aclProbes].sort((left, right) => left.candidate.localeCompare(right.candidate)),
serviceConfigurationNames: initial.catalog?.configurations.map((configuration) => configuration.name)
Expand Down