rea-agents 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/README.md +139 -21
  2. package/bridge/hopper_bridge.py +156 -14
  3. package/dist/application/AnalysisProvider.js +12 -1
  4. package/dist/application/ArtifactExtraction.js +166 -0
  5. package/dist/application/ArtifactGraphConstruction.js +257 -0
  6. package/dist/application/ArtifactInventory.js +253 -0
  7. package/dist/application/BinarySession.js +174 -14
  8. package/dist/application/CompositeProvider.js +73 -0
  9. package/dist/application/DirectAnalysis.js +44 -3
  10. package/dist/application/Doctor.js +35 -6
  11. package/dist/application/EnhancedTools.js +10 -7
  12. package/dist/application/EvidenceBundleCommands.js +51 -0
  13. package/dist/application/EvidenceBundleFiles.js +127 -0
  14. package/dist/application/EvidenceLedger.js +225 -18
  15. package/dist/application/FilesystemSnapshot.js +124 -0
  16. package/dist/application/LinuxHopper.js +186 -0
  17. package/dist/application/LoopbackReplay.js +195 -37
  18. package/dist/application/ProcessHarness.js +200 -191
  19. package/dist/application/ProcessNormalization.js +44 -0
  20. package/dist/application/ProcessOwnership.js +105 -0
  21. package/dist/application/ProcessSampling.js +284 -0
  22. package/dist/application/RealHopperAssertions.js +116 -0
  23. package/dist/application/ReferenceSourceImport.js +182 -0
  24. package/dist/application/ReferenceSourceImportEntries.js +122 -0
  25. package/dist/application/ReferenceSourceImportPolicy.js +73 -0
  26. package/dist/application/ReferenceSourceImportTypes.js +18 -0
  27. package/dist/application/ReferenceSourceVcsAdapter.js +34 -0
  28. package/dist/application/Setup.js +192 -41
  29. package/dist/application/Uninstall.js +130 -0
  30. package/dist/application/runtime.js +8 -1
  31. package/dist/artifacts/ArtifactPaths.js +51 -0
  32. package/dist/artifacts/ArtifactProvider.js +130 -0
  33. package/dist/artifacts/ArtifactReader.js +9 -0
  34. package/dist/artifacts/AsarArtifactReader.js +62 -0
  35. package/dist/artifacts/DirectoryArtifactReader.js +107 -0
  36. package/dist/artifacts/MachOSliceArtifactReader.js +66 -0
  37. package/dist/artifacts/SafeOutputTree.js +199 -0
  38. package/dist/artifacts/StreamBytes.js +10 -0
  39. package/dist/artifacts/ZipArtifactReader.js +109 -0
  40. package/dist/cli.js +229 -21
  41. package/dist/cliEvidenceCommands.js +68 -0
  42. package/dist/cliLogging.js +21 -0
  43. package/dist/config.js +35 -1
  44. package/dist/contracts/artifactComparisonExample.js +95 -0
  45. package/dist/contracts/artifactToolContracts.js +84 -0
  46. package/dist/contracts/enhancedInputs.js +4 -0
  47. package/dist/contracts/functionComparisonExample.js +57 -0
  48. package/dist/contracts/investigationExamples.js +118 -0
  49. package/dist/contracts/nativeToolContracts.js +52 -0
  50. package/dist/contracts/processCaptureExample.js +25 -0
  51. package/dist/contracts/toolContractExamples.js +69 -0
  52. package/dist/contracts/toolContracts.js +99 -36
  53. package/dist/contracts/toolOutputSchemas.js +156 -82
  54. package/dist/contracts/unknownContractExamples.js +33 -0
  55. package/dist/domain/artifactComparison.js +273 -0
  56. package/dist/domain/artifactGraph.js +194 -0
  57. package/dist/domain/artifactInventoryEvidence.js +150 -0
  58. package/dist/domain/binaryTarget.js +55 -0
  59. package/dist/domain/bundleComparison.js +266 -0
  60. package/dist/domain/callPath.js +346 -0
  61. package/dist/domain/changedBehavior.js +294 -0
  62. package/dist/domain/errors.js +199 -5
  63. package/dist/domain/evidence.js +41 -10
  64. package/dist/domain/evidenceBundle.js +187 -6
  65. package/dist/domain/functionComparison.js +201 -0
  66. package/dist/domain/functionComparisonNormalization.js +112 -0
  67. package/dist/domain/functionComparisonResults.js +54 -0
  68. package/dist/domain/functionComparisonSchemas.js +82 -0
  69. package/dist/domain/functionDossierEvidence.js +171 -0
  70. package/dist/domain/hopperValues.js +35 -8
  71. package/dist/domain/nativeInspection.js +142 -0
  72. package/dist/domain/processCapture.js +152 -54
  73. package/dist/domain/processComparison.js +106 -0
  74. package/dist/domain/reconstructionUnknowns.js +90 -0
  75. package/dist/domain/reconstructionVerification.js +285 -0
  76. package/dist/domain/reconstructionVerificationSchemas.js +126 -0
  77. package/dist/domain/referenceSourceClassification.js +496 -0
  78. package/dist/domain/referenceSourceGraph.js +376 -0
  79. package/dist/domain/referenceSourceImportParsing.js +235 -0
  80. package/dist/domain/referenceSourcePolicy.js +1 -0
  81. package/dist/domain/residualUnknown.js +239 -0
  82. package/dist/domain/staticRuntimeCorrelation.js +375 -0
  83. package/dist/hopper/BridgeLauncher.js +39 -3
  84. package/dist/hopper/HopperClient.js +14 -5
  85. package/dist/hopper/HopperProvider.js +57 -22
  86. package/dist/hopper/protocol.js +13 -2
  87. package/dist/identity.js +1 -0
  88. package/dist/main.js +5 -1
  89. package/dist/native/CommandRunner.js +156 -0
  90. package/dist/native/NativeMacOSProvider.js +306 -0
  91. package/dist/native/NativeMachoInspection.js +135 -0
  92. package/dist/native/parsers/codesign.js +55 -0
  93. package/dist/native/parsers/demangle.js +26 -0
  94. package/dist/native/parsers/dyldInfo.js +25 -0
  95. package/dist/native/parsers/lipo.js +67 -0
  96. package/dist/native/parsers/otool.js +193 -0
  97. package/dist/native/parsers/plist.js +23 -0
  98. package/dist/reference/ReferenceSourceReader.js +73 -0
  99. package/dist/reference/ReferenceSourceReaderEntries.js +206 -0
  100. package/dist/reference/ReferenceSourceReaderErrors.js +19 -0
  101. package/dist/reference/ReferenceSourceReaderFile.js +119 -0
  102. package/dist/reference/ReferenceSourceReaderPaths.js +23 -0
  103. package/dist/reference/ReferenceSourceReaderTypes.js +2 -0
  104. package/dist/reference/ReferenceSourceReaderValidate.js +71 -0
  105. package/dist/server/createServer.js +31 -5
  106. package/dist/server/recordDerivedEvidence.js +10 -0
  107. package/dist/server/registerArtifactComparisonTool.js +62 -0
  108. package/dist/server/registerArtifactTools.js +6 -0
  109. package/dist/server/registerBundleComparisonTool.js +47 -0
  110. package/dist/server/registerEnhancedTools.js +64 -12
  111. package/dist/server/registerEvidenceTools.js +36 -0
  112. package/dist/server/registerFunctionComparisonTool.js +68 -0
  113. package/dist/server/registerInvestigationTools.js +224 -0
  114. package/dist/server/registerNativeTools.js +6 -0
  115. package/dist/server/registerOfficialTools.js +47 -14
  116. package/dist/server/registerProcessComparisonTool.js +106 -0
  117. package/dist/server/registerSessionTools.js +179 -70
  118. package/dist/server/sessionEvidence.js +28 -0
  119. package/dist/server/sessionToolPolicies.js +64 -0
  120. package/dist/server/toolRegistrationOptions.js +7 -0
  121. package/dist/server/toolResult.js +8 -5
  122. package/install.sh +198 -0
  123. package/package.json +18 -1
  124. package/scripts/rea.mjs +5 -1
  125. package/skills/rea-analysis/SKILL.md +77 -2
@@ -0,0 +1,193 @@
1
+ /** Parse stable fields from `otool -l`, preserving unknown command fields. */
2
+ export const parseOtoolLoadCommands = (output) => {
3
+ const headerTokens = parseHeaderTokens(output);
4
+ const state = createLoadCommandState(headerTokens);
5
+ for (const block of output.split(/(?=Load command \d+)/u))
6
+ parseLoadCommand(block, state);
7
+ return {
8
+ fileType: headerTokens?.[4] ?? null,
9
+ flags: [...state.flags].sort(),
10
+ uuid: state.uuid,
11
+ commands: state.commands,
12
+ segments: state.segments,
13
+ dependencies: state.dependencies,
14
+ entrypoints: state.entrypoints,
15
+ builds: state.builds,
16
+ };
17
+ };
18
+ const parseHeaderTokens = (output) => output
19
+ .split(/\r?\n/u)
20
+ .map((line) => line.trim())
21
+ .find((line) => /^0x[a-fA-F0-9]+\s/u.test(line))
22
+ ?.split(/\s+/u);
23
+ const createLoadCommandState = (headerTokens) => ({
24
+ commands: [],
25
+ segments: [],
26
+ dependencies: [],
27
+ entrypoints: [],
28
+ builds: [],
29
+ uuid: null,
30
+ flags: new Set(headerTokens?.slice(7) ?? []),
31
+ });
32
+ const parseLoadCommand = (block, state) => {
33
+ const indexText = /^Load command (\d+)/u.exec(block)?.[1];
34
+ if (indexText === undefined)
35
+ return;
36
+ const header = block.split(/\n\s*Section\n/u)[0] ?? block;
37
+ const fields = parseFields(header);
38
+ const kind = stringField(fields, "cmd") ?? "unknown";
39
+ state.commands.push({
40
+ index: Number.parseInt(indexText, 10),
41
+ kind,
42
+ file_offset: null,
43
+ fields,
44
+ });
45
+ collectUuid(kind, fields, state);
46
+ collectEntrypoint(kind, fields, state);
47
+ collectBuild(kind, block, fields, state);
48
+ collectDependency(kind, fields, state);
49
+ collectSegment(kind, block, fields, state);
50
+ collectFlags(fields, state.flags);
51
+ };
52
+ const collectUuid = (kind, fields, state) => {
53
+ if (kind === "LC_UUID")
54
+ state.uuid = stringField(fields, "uuid") ?? state.uuid;
55
+ };
56
+ const collectEntrypoint = (kind, fields, state) => {
57
+ if (kind !== "LC_MAIN")
58
+ return;
59
+ const entry = numberField(fields, "entryoff");
60
+ if (entry !== null)
61
+ state.entrypoints.push({ file_offset: entry });
62
+ };
63
+ const collectBuild = (kind, block, fields, state) => {
64
+ if (kind !== "LC_BUILD_VERSION")
65
+ return;
66
+ state.builds.push({
67
+ platform: stringField(fields, "platform"),
68
+ minimum_os: stringField(fields, "minos"),
69
+ sdk: stringField(fields, "sdk"),
70
+ tools: parseBuildTools(block),
71
+ });
72
+ };
73
+ const collectDependency = (kind, fields, state) => {
74
+ if (!kind.startsWith("LC_LOAD_") && kind !== "LC_ID_DYLIB")
75
+ return;
76
+ state.dependencies.push({
77
+ path: stripOffsetSuffix(stringField(fields, "name")),
78
+ kind,
79
+ current_version: stringField(fields, "current version"),
80
+ compatibility_version: stringField(fields, "compatibility version"),
81
+ });
82
+ };
83
+ const collectSegment = (kind, block, fields, state) => {
84
+ if (kind === "LC_SEGMENT" || kind === "LC_SEGMENT_64")
85
+ state.segments.push(parseSegment(block, fields));
86
+ };
87
+ const collectFlags = (fields, flags) => {
88
+ const rawFlags = stringField(fields, "flags");
89
+ if (rawFlags === null)
90
+ return;
91
+ for (const flag of rawFlags.split(/\s+/u))
92
+ if (flag.length > 0)
93
+ flags.add(flag);
94
+ };
95
+ const parseFields = (block) => {
96
+ const fields = {};
97
+ for (const rawLine of block.split(/\r?\n/u).slice(1)) {
98
+ const line = rawLine.trim();
99
+ const match = /^(\S+(?:\s+version)?)\s+(.+)$/u.exec(line);
100
+ if (match?.[1] === undefined || match[2] === undefined)
101
+ continue;
102
+ const value = match[2].trim();
103
+ fields[match[1]] = numeric(value) ?? value;
104
+ }
105
+ return fields;
106
+ };
107
+ const parseSegment = (block, fields) => ({
108
+ name: stringField(fields, "segname") ?? "unknown",
109
+ vm_address: hexField(fields, "vmaddr"),
110
+ vm_size: numberField(fields, "vmsize"),
111
+ file_offset: numberField(fields, "fileoff"),
112
+ file_size: numberField(fields, "filesize"),
113
+ maximum_permissions: permissions(stringField(fields, "maxprot")),
114
+ initial_permissions: permissions(stringField(fields, "initprot")),
115
+ sections: parseSections(block),
116
+ });
117
+ const parseSections = (block) => block
118
+ .split(/(?=\n\s*Section\n)/u)
119
+ .slice(1)
120
+ .map((sectionBlock) => {
121
+ const fields = parseFields(`Section${sectionBlock}`);
122
+ return {
123
+ segment: stringField(fields, "segname") ?? "unknown",
124
+ name: stringField(fields, "sectname") ?? "unknown",
125
+ address: hexField(fields, "addr"),
126
+ size: numberField(fields, "size"),
127
+ file_offset: numberField(fields, "offset"),
128
+ alignment: sectionAlignment(numberField(fields, "align")),
129
+ flags: (stringField(fields, "flags") ?? "")
130
+ .split(/\s+/u)
131
+ .filter((flag) => flag.length > 0),
132
+ };
133
+ });
134
+ const parseBuildTools = (block) => {
135
+ const tools = [];
136
+ const lines = block.split(/\r?\n/u).map((line) => line.trim());
137
+ for (let index = 0; index < lines.length - 1; index += 1) {
138
+ const tool = /^tool\s+(.+)$/u.exec(lines[index] ?? "")?.[1];
139
+ const version = /^version\s+(.+)$/u.exec(lines[index + 1] ?? "")?.[1];
140
+ if (tool !== undefined && version !== undefined)
141
+ tools.push({ name: tool, version });
142
+ }
143
+ return tools;
144
+ };
145
+ const permissions = (raw) => {
146
+ if (raw === null)
147
+ return { read: null, write: null, execute: null, raw: null };
148
+ const numericValue = numeric(raw);
149
+ if (numericValue !== null)
150
+ return {
151
+ read: (numericValue & 4) !== 0,
152
+ write: (numericValue & 2) !== 0,
153
+ execute: (numericValue & 1) !== 0,
154
+ raw,
155
+ };
156
+ if (/^[r-][w-][x-]$/u.test(raw))
157
+ return {
158
+ read: raw[0] === "r",
159
+ write: raw[1] === "w",
160
+ execute: raw[2] === "x",
161
+ raw,
162
+ };
163
+ return { read: null, write: null, execute: null, raw };
164
+ };
165
+ const numeric = (value) => {
166
+ const token = value.split(/\s+/u)[0];
167
+ if (token === undefined || !/^(?:0x[a-fA-F0-9]+|\d+)$/u.test(token))
168
+ return null;
169
+ const parsed = Number.parseInt(token, token.startsWith("0x") ? 16 : 10);
170
+ return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null;
171
+ };
172
+ const stringField = (fields, name) => {
173
+ const value = fields[name];
174
+ return typeof value === "string"
175
+ ? value
176
+ : value === undefined || value === null
177
+ ? null
178
+ : String(value);
179
+ };
180
+ const numberField = (fields, name) => {
181
+ const value = fields[name];
182
+ return typeof value === "number"
183
+ ? value
184
+ : typeof value === "string"
185
+ ? numeric(value)
186
+ : null;
187
+ };
188
+ const hexField = (fields, name) => {
189
+ const value = numberField(fields, name);
190
+ return value === null ? null : `0x${value.toString(16)}`;
191
+ };
192
+ const stripOffsetSuffix = (value) => value?.replace(/\s+\(offset\s+\d+\)$/u, "") ?? "unknown";
193
+ const sectionAlignment = (exponent) => exponent === null || exponent > 52 ? null : 2 ** exponent;
@@ -0,0 +1,23 @@
1
+ import { z } from "zod";
2
+ const plistObject = z.record(z.string(), z.unknown());
3
+ /** Parse plutil JSON output and project stable bundle metadata. */
4
+ export const parsePlistJson = (output) => {
5
+ const value = JSON.parse(output);
6
+ const object = plistObject.safeParse(value);
7
+ const field = (name) => {
8
+ if (!object.success)
9
+ return null;
10
+ const candidate = object.data[name];
11
+ return typeof candidate === "string" ? candidate : null;
12
+ };
13
+ return {
14
+ value,
15
+ bundle: {
16
+ identifier: field("CFBundleIdentifier"),
17
+ executable: field("CFBundleExecutable"),
18
+ name: field("CFBundleName"),
19
+ version: field("CFBundleVersion"),
20
+ short_version: field("CFBundleShortVersionString"),
21
+ },
22
+ };
23
+ };
@@ -0,0 +1,73 @@
1
+ import { err, ok } from "../domain/result.js";
2
+ import { traverseDirectory } from "./ReferenceSourceReaderEntries.js";
3
+ import { compareNames } from "./ReferenceSourceReaderPaths.js";
4
+ import {} from "./ReferenceSourceReaderTypes.js";
5
+ import { isAborted, noFollowOpenSupported, prepareRoot, } from "./ReferenceSourceReaderValidate.js";
6
+ const DEFAULT_LIMITS = {
7
+ maxBytes: 16 * 1024 * 1024,
8
+ maxEntries: 10_000,
9
+ maxDepth: 32,
10
+ maxPathBytes: 4_096,
11
+ };
12
+ const PATH_RACE_LIMITATION = "Path identity is revalidated around operations; Node lacks portable descriptor-relative openat traversal, so a syscall-boundary pathname race remains.";
13
+ /** Read a bounded source tree without intentionally following symbolic links. */
14
+ export const readReferenceSource = async (root, limits = DEFAULT_LIMITS, options = {}) => {
15
+ if (!noFollowOpenSupported())
16
+ return err({
17
+ tag: "reference-source-reader",
18
+ code: "unsupported",
19
+ message: "Safe no-follow file opens are unavailable",
20
+ });
21
+ const prepared = await prepareRoot(root, limits, options.signal);
22
+ if (!prepared.ok)
23
+ return prepared;
24
+ const { canonicalRoot, rootIdentity } = prepared.value;
25
+ const state = {
26
+ root: canonicalRoot,
27
+ rootIdentity,
28
+ limits,
29
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
30
+ ...(options.shouldExclude === undefined
31
+ ? {}
32
+ : { shouldExclude: options.shouldExclude }),
33
+ entries: [],
34
+ pending: [{ path: canonicalRoot, depth: 0 }],
35
+ bytesRead: 0,
36
+ filesSeen: 0,
37
+ truncated: false,
38
+ stopped: false,
39
+ };
40
+ const traversal = await traverse(state);
41
+ if (!traversal.ok)
42
+ return traversal;
43
+ state.entries.sort((left, right) => compareNames(left.path, right.path));
44
+ return ok({
45
+ root: canonicalRoot,
46
+ entries: state.entries,
47
+ bytesRead: state.bytesRead,
48
+ truncated: state.truncated,
49
+ limitations: [
50
+ PATH_RACE_LIMITATION,
51
+ ...(state.stopped
52
+ ? ["Traversal stopped because the entry limit was reached."]
53
+ : []),
54
+ ],
55
+ });
56
+ };
57
+ const traverse = async (state) => {
58
+ while (state.pending.length > 0 && !state.stopped) {
59
+ if (isAborted(state.signal))
60
+ return err({
61
+ tag: "reference-source-reader",
62
+ code: "cancelled",
63
+ message: "Reference source traversal cancelled",
64
+ });
65
+ const current = state.pending.pop();
66
+ if (current === undefined)
67
+ break;
68
+ const result = await traverseDirectory(state, current);
69
+ if (!result.ok)
70
+ return result;
71
+ }
72
+ return ok(undefined);
73
+ };
@@ -0,0 +1,206 @@
1
+ import { readdir, readlink, realpath } from "node:fs/promises";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { entryFailure, safeSize } from "./ReferenceSourceReaderErrors.js";
4
+ import { compareNames, pathFromRoot, withinRoot, } from "./ReferenceSourceReaderPaths.js";
5
+ import { readStableFile } from "./ReferenceSourceReaderFile.js";
6
+ import { isAborted, sameFile, validateDirectory, bigLstat, } from "./ReferenceSourceReaderValidate.js";
7
+ import {} from "./ReferenceSourceReaderTypes.js";
8
+ export const traverseDirectory = async (state, current) => {
9
+ const before = await validateDirectory(state.root, state.rootIdentity, current.path);
10
+ if (!before.ok) {
11
+ state.entries.push(entryFailure(pathFromRoot(state.root, current.path), "directory", before.code, before.message));
12
+ return { ok: true, value: undefined };
13
+ }
14
+ const names = await readDirectoryNames(current.path);
15
+ if (!names.ok) {
16
+ state.entries.push(entryFailure(pathFromRoot(state.root, current.path), "directory", "io", names.message));
17
+ return { ok: true, value: undefined };
18
+ }
19
+ if (isAborted(state.signal))
20
+ return {
21
+ ok: false,
22
+ error: {
23
+ tag: "reference-source-reader",
24
+ code: "cancelled",
25
+ message: "Reference source traversal cancelled",
26
+ },
27
+ };
28
+ const after = await validateDirectory(state.root, state.rootIdentity, current.path);
29
+ if (!after.ok || !sameFile(before.stats, after.stats)) {
30
+ state.entries.push(entryFailure(pathFromRoot(state.root, current.path), "directory", "changed", "Directory changed while it was read"));
31
+ return { ok: true, value: undefined };
32
+ }
33
+ const directories = [];
34
+ for (const name of names.value) {
35
+ const result = await processEntry(state, current, name, directories);
36
+ if (!result.ok)
37
+ return result;
38
+ if (state.stopped)
39
+ break;
40
+ }
41
+ directories.reverse();
42
+ state.pending.push(...directories);
43
+ return { ok: true, value: undefined };
44
+ };
45
+ const readDirectoryNames = async (path) => {
46
+ try {
47
+ return { ok: true, value: (await readdir(path)).sort(compareNames) };
48
+ }
49
+ catch {
50
+ return { ok: false, message: "Directory could not be read" };
51
+ }
52
+ };
53
+ const readMetadata = async (path) => {
54
+ try {
55
+ return { ok: true, value: await bigLstat(path) };
56
+ }
57
+ catch {
58
+ return { ok: false, message: "Entry metadata could not be read" };
59
+ }
60
+ };
61
+ const processEntry = async (state, current, name, directories) => {
62
+ if (isAborted(state.signal))
63
+ return {
64
+ ok: false,
65
+ error: {
66
+ tag: "reference-source-reader",
67
+ code: "cancelled",
68
+ message: "Reference source traversal cancelled",
69
+ },
70
+ };
71
+ const absolute = join(current.path, name);
72
+ const path = pathFromRoot(state.root, absolute);
73
+ const excluded = applyExclusion(state.shouldExclude, path);
74
+ if (!excluded.ok)
75
+ return {
76
+ ok: false,
77
+ error: {
78
+ tag: "reference-source-reader",
79
+ code: "io",
80
+ message: "Reference source exclusion check failed",
81
+ },
82
+ };
83
+ if (excluded.value)
84
+ return { ok: true, value: undefined };
85
+ if (!reserveEntry(state))
86
+ return { ok: true, value: undefined };
87
+ if (Buffer.byteLength(path) > state.limits.maxPathBytes) {
88
+ state.entries.push(entryFailure(path, "unknown", "limit", "Path exceeds maxPathBytes"));
89
+ state.truncated = true;
90
+ return { ok: true, value: undefined };
91
+ }
92
+ const metadata = await readMetadata(absolute);
93
+ if (!metadata.ok) {
94
+ state.entries.push(entryFailure(path, "unknown", "io", metadata.message));
95
+ return { ok: true, value: undefined };
96
+ }
97
+ if (isAborted(state.signal))
98
+ return {
99
+ ok: false,
100
+ error: {
101
+ tag: "reference-source-reader",
102
+ code: "cancelled",
103
+ message: "Reference source traversal cancelled",
104
+ },
105
+ };
106
+ if (metadata.value.isSymbolicLink())
107
+ state.entries.push(await describeSymlink(state.root, absolute, path));
108
+ else if (metadata.value.isDirectory()) {
109
+ if (current.depth < state.limits.maxDepth) {
110
+ state.entries.push({ status: "read", kind: "directory", path });
111
+ directories.push({ path: absolute, depth: current.depth + 1 });
112
+ }
113
+ else {
114
+ state.entries.push(entryFailure(path, "directory", "limit", "Directory exceeds maxDepth"));
115
+ state.truncated = true;
116
+ }
117
+ }
118
+ else if (!metadata.value.isFile())
119
+ state.entries.push(entryFailure(path, "other", "unsupported", "Entry is not a regular file", safeSize(metadata.value.size)));
120
+ else
121
+ await processFileEntry(state, absolute, path, metadata.value);
122
+ return { ok: true, value: undefined };
123
+ };
124
+ const processFileEntry = async (state, absolute, path, metadata) => {
125
+ const remaining = state.limits.maxBytes - state.bytesRead;
126
+ if (metadata.size > BigInt(remaining)) {
127
+ state.entries.push(entryFailure(path, "file", "limit", "File exceeds remaining maxBytes", safeSize(metadata.size)));
128
+ state.truncated = true;
129
+ return;
130
+ }
131
+ const result = await readStableFile({
132
+ root: state.root,
133
+ rootIdentity: state.rootIdentity,
134
+ absolute,
135
+ path,
136
+ expected: metadata,
137
+ remaining,
138
+ ...(state.signal === undefined ? {} : { signal: state.signal }),
139
+ });
140
+ if (result.status === "read" && result.kind === "file")
141
+ state.bytesRead += result.bytes.byteLength;
142
+ else if (result.status === "failed" && result.code === "limit")
143
+ state.truncated = true;
144
+ state.entries.push(result);
145
+ };
146
+ const describeSymlink = async (root, absolute, path) => {
147
+ try {
148
+ const rawTarget = await readlink(absolute);
149
+ const lexicalTarget = resolve(dirname(absolute), rawTarget);
150
+ if (!withinRoot(root, lexicalTarget))
151
+ return {
152
+ status: "read",
153
+ kind: "symlink",
154
+ path,
155
+ target: "<outside-root>",
156
+ targetState: "external",
157
+ };
158
+ try {
159
+ const canonicalTarget = await realpath(lexicalTarget);
160
+ return withinRoot(root, canonicalTarget)
161
+ ? {
162
+ status: "read",
163
+ kind: "symlink",
164
+ path,
165
+ target: pathFromRoot(root, canonicalTarget),
166
+ targetState: "internal",
167
+ }
168
+ : {
169
+ status: "read",
170
+ kind: "symlink",
171
+ path,
172
+ target: "<outside-root>",
173
+ targetState: "external",
174
+ };
175
+ }
176
+ catch {
177
+ return {
178
+ status: "read",
179
+ kind: "symlink",
180
+ path,
181
+ target: pathFromRoot(root, lexicalTarget),
182
+ targetState: "missing",
183
+ };
184
+ }
185
+ }
186
+ catch {
187
+ return entryFailure(path, "symlink", "io", "Symbolic link target could not be read");
188
+ }
189
+ };
190
+ const applyExclusion = (shouldExclude, path) => {
191
+ try {
192
+ return { ok: true, value: shouldExclude?.(path) === true };
193
+ }
194
+ catch {
195
+ return { ok: false };
196
+ }
197
+ };
198
+ const reserveEntry = (state) => {
199
+ if (state.filesSeen < state.limits.maxEntries) {
200
+ state.filesSeen += 1;
201
+ return true;
202
+ }
203
+ state.truncated = true;
204
+ state.stopped = true;
205
+ return false;
206
+ };
@@ -0,0 +1,19 @@
1
+ import {} from "./ReferenceSourceReaderTypes.js";
2
+ export const failure = (code, message) => ({
3
+ tag: "reference-source-reader",
4
+ code,
5
+ message,
6
+ });
7
+ export const cancelled = () => failure("cancelled", "Reference source traversal cancelled");
8
+ export const safeSize = (size) => {
9
+ const value = Number(size);
10
+ return Number.isSafeInteger(value) && value >= 0 ? value : undefined;
11
+ };
12
+ export const entryFailure = (...[path, kind, code, message, size]) => ({
13
+ status: "failed",
14
+ kind,
15
+ path,
16
+ code,
17
+ message,
18
+ ...(size === undefined ? {} : { size }),
19
+ });
@@ -0,0 +1,119 @@
1
+ import { constants } from "node:fs";
2
+ import { open } from "node:fs/promises";
3
+ import { dirname } from "node:path";
4
+ import { entryFailure, safeSize } from "./ReferenceSourceReaderErrors.js";
5
+ import { isAborted, sameFile, validateDirectory, } from "./ReferenceSourceReaderValidate.js";
6
+ import {} from "./ReferenceSourceReaderTypes.js";
7
+ const READ_CHUNK_BYTES = 64 * 1024;
8
+ const prepareFileRead = async (request) => {
9
+ const { root, rootIdentity, absolute, path, expected, signal } = request;
10
+ const parentBefore = await validateDirectory(root, rootIdentity, dirname(absolute));
11
+ if (!parentBefore.ok)
12
+ return {
13
+ status: "failed",
14
+ entry: entryFailure(path, "file", parentBefore.code, parentBefore.message, safeSize(expected.size)),
15
+ };
16
+ const handle = await open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW);
17
+ if (isAborted(signal)) {
18
+ await handle.close().catch(() => undefined);
19
+ return {
20
+ status: "failed",
21
+ entry: entryFailure(path, "file", "cancelled", "File read cancelled", safeSize(expected.size)),
22
+ };
23
+ }
24
+ const before = await handle.stat({ bigint: true });
25
+ if (!sameFile(expected, before)) {
26
+ await handle.close().catch(() => undefined);
27
+ return {
28
+ status: "failed",
29
+ entry: entryFailure(path, "file", "changed", "File changed before it was read", safeSize(before.size)),
30
+ };
31
+ }
32
+ return { status: "ready", handle, before, parentBefore };
33
+ };
34
+ const readFileContents = async (request) => {
35
+ const { handle, path, remaining, signal } = request;
36
+ const chunks = [];
37
+ let total = 0;
38
+ for (;;) {
39
+ if (isAborted(signal))
40
+ return {
41
+ status: "failed",
42
+ entry: entryFailure(path, "file", "cancelled", "File read cancelled", total),
43
+ };
44
+ const capacity = Math.min(READ_CHUNK_BYTES, remaining + 1 - total);
45
+ if (capacity <= 0)
46
+ return {
47
+ status: "failed",
48
+ entry: entryFailure(path, "file", "limit", "File exceeds remaining maxBytes", total),
49
+ };
50
+ const chunk = Buffer.allocUnsafe(capacity);
51
+ const read = await handle.read(chunk, 0, capacity, null);
52
+ if (isAborted(signal))
53
+ return {
54
+ status: "failed",
55
+ entry: entryFailure(path, "file", "cancelled", "File read cancelled", total),
56
+ };
57
+ if (read.bytesRead === 0)
58
+ break;
59
+ total += read.bytesRead;
60
+ if (total > remaining)
61
+ return {
62
+ status: "failed",
63
+ entry: entryFailure(path, "file", "limit", "File exceeds remaining maxBytes", total),
64
+ };
65
+ chunks.push(chunk.subarray(0, read.bytesRead));
66
+ }
67
+ return { status: "ok", chunks, total };
68
+ };
69
+ const finalizeFileRead = async (request) => {
70
+ const { root, rootIdentity, absolute, path, handle, before, parentBefore, chunks, total, } = request;
71
+ const after = await handle.stat({ bigint: true });
72
+ const parentAfter = await validateDirectory(root, rootIdentity, dirname(absolute));
73
+ if (!parentAfter.ok || !sameFile(parentBefore.stats, parentAfter.stats))
74
+ return entryFailure(path, "file", "changed", "Parent directory changed while file was read", total);
75
+ if (!sameFile(before, after) || BigInt(total) !== after.size)
76
+ return entryFailure(path, "file", "changed", "File changed while it was read", total);
77
+ return {
78
+ status: "read",
79
+ kind: "file",
80
+ path,
81
+ bytes: Buffer.concat(chunks, total),
82
+ size: total,
83
+ };
84
+ };
85
+ export const readStableFile = async (request) => {
86
+ const { root, rootIdentity, absolute, path, expected, remaining, signal } = request;
87
+ let handle;
88
+ try {
89
+ const prepared = await prepareFileRead(request);
90
+ if (prepared.status === "failed")
91
+ return prepared.entry;
92
+ handle = prepared.handle;
93
+ const contents = await readFileContents({
94
+ handle,
95
+ path,
96
+ remaining,
97
+ ...(signal === undefined ? {} : { signal }),
98
+ });
99
+ if (contents.status === "failed")
100
+ return contents.entry;
101
+ return await finalizeFileRead({
102
+ root,
103
+ rootIdentity,
104
+ absolute,
105
+ path,
106
+ handle,
107
+ before: prepared.before,
108
+ parentBefore: prepared.parentBefore,
109
+ chunks: contents.chunks,
110
+ total: contents.total,
111
+ });
112
+ }
113
+ catch {
114
+ return entryFailure(path, "file", isAborted(signal) ? "cancelled" : "io", "File could not be read safely", safeSize(expected.size));
115
+ }
116
+ finally {
117
+ await handle?.close().catch(() => undefined);
118
+ }
119
+ };
@@ -0,0 +1,23 @@
1
+ import { isAbsolute, relative, sep } from "node:path";
2
+ export const withinRoot = (root, path) => {
3
+ const value = relative(root, path);
4
+ return (value === "" ||
5
+ (value !== ".." && !value.startsWith(`..${sep}`) && !isAbsolute(value)));
6
+ };
7
+ export const pathFromRoot = (root, path) => {
8
+ const value = relative(root, path);
9
+ return withinRoot(root, path)
10
+ ? value.split(sep).join("/") || "."
11
+ : "<outside-root>";
12
+ };
13
+ export const compareNames = (left, right) => {
14
+ const leftPoints = [...left].map((value) => value.codePointAt(0) ?? 0);
15
+ const rightPoints = [...right].map((value) => value.codePointAt(0) ?? 0);
16
+ const length = Math.min(leftPoints.length, rightPoints.length);
17
+ for (let index = 0; index < length; index += 1) {
18
+ const difference = (leftPoints[index] ?? 0) - (rightPoints[index] ?? 0);
19
+ if (difference !== 0)
20
+ return difference;
21
+ }
22
+ return leftPoints.length - rightPoints.length;
23
+ };
@@ -0,0 +1,2 @@
1
+ import {} from "node:fs";
2
+ import {} from "../domain/result.js";