mirror of
https://github.com/morluto/rea
synced 2026-10-26 14:25:49 -04:00
25c22222ab
Keep the observed target path and failed directory in the unreadable-link diagnostic without promoting either to proven graph identity. Verify the retained path through the importer boundary. Refs: #4013, #4053
201 lines
6.6 KiB
TypeScript
201 lines
6.6 KiB
TypeScript
import { posix } from "node:path";
|
|
|
|
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
|
import { createReferenceSourcePathLookup } from "../domain/referenceSourcePathIdentity.js";
|
|
import { err, ok } from "../domain/result.js";
|
|
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
|
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
|
import { traverseDirectory } from "./ReferenceSourceReaderEntries.js";
|
|
import type {
|
|
ReferenceSourceReaderOptions,
|
|
ReferenceSourceEntry,
|
|
ReferenceSourceRead,
|
|
ReferenceSourceResult,
|
|
TraversalState,
|
|
} from "./ReferenceSourceReaderTypes.js";
|
|
import {
|
|
isAborted,
|
|
noFollowOpenSupported,
|
|
prepareRoot,
|
|
} from "./ReferenceSourceReaderValidate.js";
|
|
|
|
const PATH_RACE_LIMITATION =
|
|
"Path identity is revalidated around operations; Node lacks portable descriptor-relative openat traversal, so a syscall-boundary pathname race remains.";
|
|
|
|
/** Read a source tree without intentionally following symbolic links. */
|
|
export const readReferenceSource = async (
|
|
root: string,
|
|
resources: ArtifactResourceScope,
|
|
options: ReferenceSourceReaderOptions = {},
|
|
): Promise<ReferenceSourceResult<ReferenceSourceRead>> => {
|
|
try {
|
|
return await resources.run(() => readSource(root, resources, options));
|
|
} catch (cause: unknown) {
|
|
if (!(cause instanceof ArtifactReaderFailure)) throw cause;
|
|
return err({
|
|
tag: "reference-source-reader",
|
|
code: cause.reason === "cancelled" ? "cancelled" : "io",
|
|
message: cause.message,
|
|
...(cause.cleanup === undefined ? {} : { cleanup: cause.cleanup }),
|
|
cause,
|
|
});
|
|
}
|
|
};
|
|
|
|
const readSource = async (
|
|
root: string,
|
|
resources: ArtifactResourceScope,
|
|
options: ReferenceSourceReaderOptions,
|
|
): Promise<ReferenceSourceResult<ReferenceSourceRead>> => {
|
|
if (!noFollowOpenSupported())
|
|
return err({
|
|
tag: "reference-source-reader",
|
|
code: "unsupported",
|
|
message: "Safe no-follow file opens are unavailable",
|
|
});
|
|
const prepared = await prepareRoot(root, options.signal);
|
|
if (!prepared.ok) return prepared;
|
|
const { canonicalRoot, rootIdentity } = prepared.value;
|
|
const state: TraversalState = {
|
|
resources,
|
|
root: canonicalRoot,
|
|
rootIdentity,
|
|
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
...(options.shouldExclude === undefined
|
|
? {}
|
|
: { shouldExclude: options.shouldExclude }),
|
|
entries: [],
|
|
exclusions: [],
|
|
pending: [{ path: canonicalRoot }],
|
|
bytesRead: 0,
|
|
};
|
|
const traversal = await traverse(state);
|
|
if (
|
|
!isAborted(state.signal) &&
|
|
(traversal.ok || traversal.error.code !== "cancelled")
|
|
)
|
|
reconcileUnobservedSymlinkTargets(
|
|
state.entries,
|
|
state.exclusions,
|
|
state.signal,
|
|
);
|
|
state.entries.sort((left, right) =>
|
|
compareUnicodeCodePoints(left.path, right.path),
|
|
);
|
|
const read: ReferenceSourceRead = {
|
|
root: canonicalRoot,
|
|
entries: state.entries,
|
|
bytesRead: state.bytesRead,
|
|
limitations: [PATH_RACE_LIMITATION],
|
|
};
|
|
return traversal.ok ? ok(read) : err({ ...traversal.error, partial: read });
|
|
};
|
|
|
|
/**
|
|
* Resolve internal link observations against the completed reader inventory.
|
|
* If the closest containing directory failed before enumeration completed,
|
|
* the target remains unknown; a broader failed ancestor cannot override a
|
|
* successfully enumerated directory or a recorded exclusion.
|
|
*/
|
|
export const reconcileUnobservedSymlinkTargets = (
|
|
entries: ReferenceSourceEntry[],
|
|
exclusions: TraversalState["exclusions"] = [],
|
|
signal?: AbortSignal,
|
|
): void => {
|
|
const failedDirectories = new Map(
|
|
entries.flatMap((entry) =>
|
|
entry.status === "failed" &&
|
|
entry.kind === "directory" &&
|
|
entry.code !== "cancelled"
|
|
? [[entry.path, entry] as const]
|
|
: [],
|
|
),
|
|
);
|
|
if (failedDirectories.size === 0 || isAborted(signal)) return;
|
|
const directoryEntries = entries.filter(
|
|
(entry) => entry.kind === "directory",
|
|
);
|
|
const directoryPaths = createReferenceSourcePathLookup(
|
|
directoryEntries.map(({ path }) => path),
|
|
);
|
|
const observedPaths = createReferenceSourcePathLookup(
|
|
entries.map(({ path }) => path),
|
|
);
|
|
const excludedPaths = createReferenceSourcePathLookup(
|
|
exclusions.map(({ path }) => path),
|
|
);
|
|
for (const [index, entry] of entries.entries()) {
|
|
if (isAborted(signal)) return;
|
|
if (
|
|
entry.status !== "read" ||
|
|
entry.kind !== "symlink" ||
|
|
entry.targetState !== "internal" ||
|
|
observedPaths.resolve(entry.target) !== undefined ||
|
|
excludedPaths.resolve(entry.target) !== undefined
|
|
)
|
|
continue;
|
|
const ancestor = nearestFailedAncestor(
|
|
entry.target,
|
|
directoryPaths,
|
|
failedDirectories,
|
|
excludedPaths,
|
|
signal,
|
|
);
|
|
if (ancestor === undefined) continue;
|
|
entries[index] = {
|
|
status: "failed",
|
|
kind: "symlink",
|
|
path: entry.path,
|
|
code: ancestor.code,
|
|
message: `Symbolic link target ${JSON.stringify(entry.target)} was not inventoried because directory ${JSON.stringify(ancestor.path)} could not be enumerated: ${ancestor.message}`,
|
|
};
|
|
}
|
|
};
|
|
|
|
const nearestFailedAncestor = (
|
|
target: string,
|
|
directoryPaths: ReturnType<typeof createReferenceSourcePathLookup>,
|
|
failedDirectories: ReadonlyMap<
|
|
string,
|
|
Extract<ReferenceSourceEntry, { status: "failed" }>
|
|
>,
|
|
excludedPaths: ReturnType<typeof createReferenceSourcePathLookup>,
|
|
signal: AbortSignal | undefined,
|
|
): Extract<ReferenceSourceEntry, { status: "failed" }> | undefined => {
|
|
for (
|
|
let candidate = posix.dirname(target);
|
|
candidate !== "." && candidate !== "/";
|
|
candidate = posix.dirname(candidate)
|
|
) {
|
|
if (isAborted(signal)) return undefined;
|
|
if (excludedPaths.resolve(candidate) !== undefined) return undefined;
|
|
const observedDirectory = directoryPaths.resolve(candidate);
|
|
if (observedDirectory !== undefined)
|
|
return failedDirectories.get(observedDirectory);
|
|
}
|
|
if (isAborted(signal)) return undefined;
|
|
if (excludedPaths.resolve(".") !== undefined) return undefined;
|
|
const observedRoot = directoryPaths.resolve(".");
|
|
return observedRoot === undefined
|
|
? undefined
|
|
: failedDirectories.get(observedRoot);
|
|
};
|
|
|
|
const traverse = async (
|
|
state: TraversalState,
|
|
): Promise<ReferenceSourceResult<undefined>> => {
|
|
while (state.pending.length > 0) {
|
|
if (isAborted(state.signal))
|
|
return err({
|
|
tag: "reference-source-reader",
|
|
code: "cancelled",
|
|
message: "Reference source traversal cancelled",
|
|
});
|
|
const current = state.pending.pop();
|
|
if (current === undefined) break;
|
|
const result = await traverseDirectory(state, current);
|
|
if (!result.ok) return result;
|
|
}
|
|
return ok(undefined);
|
|
};
|