agentic-workflow-manager 9.18.0 → 9.20.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.
- package/dist/src/commands/change/index.js +492 -0
- package/dist/src/commands/changes/index.js +84 -0
- package/dist/src/commands/plan/index.js +1 -1
- package/dist/src/commands/sensors/index.js +9 -0
- package/dist/src/commands/sensors/read-only-observation.js +305 -0
- package/dist/src/commands/sensors/run.js +24 -60
- package/dist/src/core/admission/index.js +11 -1
- package/dist/src/core/changes/command.js +38 -0
- package/dist/src/core/changes/derive.js +120 -0
- package/dist/src/core/changes/fs-safe.js +47 -0
- package/dist/src/core/changes/git-env.js +68 -0
- package/dist/src/core/changes/git.js +216 -0
- package/dist/src/core/changes/list-parse.js +67 -0
- package/dist/src/core/changes/list.js +408 -0
- package/dist/src/core/changes/parse.js +72 -0
- package/dist/src/core/changes/range.js +53 -0
- package/dist/src/core/changes/receipt.js +274 -0
- package/dist/src/core/changes/record-io.js +149 -0
- package/dist/src/core/changes/render-list.js +109 -0
- package/dist/src/core/changes/render.js +178 -0
- package/dist/src/core/changes/text-safety.js +116 -0
- package/dist/src/core/changes/trailers.js +76 -0
- package/dist/src/core/changes/types.js +26 -0
- package/dist/src/core/changes/validate-shape.js +219 -0
- package/dist/src/core/changes/worktree.js +53 -0
- package/dist/src/core/currentness/check.js +5 -2
- package/dist/src/index.js +4 -0
- package/dist/src/release/orchestrator.js +3 -1
- package/dist/tests/commands/change/new.test.js +1126 -0
- package/dist/tests/commands/change/validate.test.js +1145 -0
- package/dist/tests/commands/changes/list.test.js +423 -0
- package/dist/tests/commands/sensors/index.test.js +1 -1
- package/dist/tests/commands/sensors/read-only-observation.test.js +537 -0
- package/dist/tests/commands/sensors/run-read-only.test.js +151 -0
- package/dist/tests/core/admission/index.test.js +60 -0
- package/dist/tests/core/changes/derive.test.js +455 -0
- package/dist/tests/core/changes/fs-safe.test.js +82 -0
- package/dist/tests/core/changes/git-env.test.js +143 -0
- package/dist/tests/core/changes/git.test.js +442 -0
- package/dist/tests/core/changes/helpers.js +269 -0
- package/dist/tests/core/changes/list-fixtures.js +122 -0
- package/dist/tests/core/changes/list-long-history.test.js +52 -0
- package/dist/tests/core/changes/list-paging.test.js +511 -0
- package/dist/tests/core/changes/list-parse.test.js +120 -0
- package/dist/tests/core/changes/list-tree.test.js +44 -0
- package/dist/tests/core/changes/list.test.js +821 -0
- package/dist/tests/core/changes/parse.test.js +142 -0
- package/dist/tests/core/changes/predicates.test.js +83 -0
- package/dist/tests/core/changes/range.test.js +92 -0
- package/dist/tests/core/changes/receipt.test.js +947 -0
- package/dist/tests/core/changes/record-io.test.js +307 -0
- package/dist/tests/core/changes/record.test.js +549 -0
- package/dist/tests/core/changes/render-list.test.js +212 -0
- package/dist/tests/core/changes/render.test.js +202 -0
- package/dist/tests/core/changes/text-safety.test.js +226 -0
- package/dist/tests/core/changes/trailers.test.js +181 -0
- package/dist/tests/core/changes/worktree.test.js +259 -0
- package/dist/tests/core/currentness/check-insteadof.test.js +120 -0
- package/dist/tests/core/currentness/check.test.js +75 -14
- package/dist/tests/release/orchestrator-trailer.test.js +72 -0
- package/dist/tests/release/orchestrator.test.js +1 -0
- package/dist/tests/structural/change-commands-are-registered.test.js +29 -0
- package/dist/tests/structural/change-record-git-and-platform-conventions.test.js +77 -0
- package/dist/tests/structural/cloud-admission-docs-contract.test.js +54 -0
- package/package.json +1 -1
- package/prebuilds/darwin-arm64/secure_fs.node +0 -0
- package/prebuilds/darwin-x64/secure_fs.node +0 -0
- package/prebuilds/win32-arm64/secure_fs.node +0 -0
- package/prebuilds/win32-x64/secure_fs.node +0 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.DEFAULT_EXCLUSION_NAMES = exports.READ_ONLY_MAX_BYTES = exports.READ_ONLY_MAX_ENTRIES = void 0;
|
|
7
|
+
exports.loadAdmissionConfig = loadAdmissionConfig;
|
|
8
|
+
exports.createExclusionPolicy = createExclusionPolicy;
|
|
9
|
+
exports.gitVerifier = gitVerifier;
|
|
10
|
+
exports.filesystemSnapshot = filesystemSnapshot;
|
|
11
|
+
exports.snapshotUnavailableRemedy = snapshotUnavailableRemedy;
|
|
12
|
+
exports.admissionConfigRemedy = admissionConfigRemedy;
|
|
13
|
+
const fs_1 = __importDefault(require("fs"));
|
|
14
|
+
const path_1 = __importDefault(require("path"));
|
|
15
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
16
|
+
const child_process_1 = require("child_process");
|
|
17
|
+
/**
|
|
18
|
+
* Admission's read-only observation (issue #215). `runSensors({ readOnly })` hashes
|
|
19
|
+
* the project tree before and after the sensors run; any difference means the
|
|
20
|
+
* evidence is not read-only. Generated output (node_modules, .next, …) can push that
|
|
21
|
+
* walk past its limits, so a directory that git ignores and that holds no tracked
|
|
22
|
+
* file may be skipped. Source files, `.gitignore`, and `.awm/` stay observed.
|
|
23
|
+
*/
|
|
24
|
+
exports.READ_ONLY_MAX_ENTRIES = 250_000;
|
|
25
|
+
exports.READ_ONLY_MAX_BYTES = 1024 * 1024 * 1024;
|
|
26
|
+
exports.DEFAULT_EXCLUSION_NAMES = Object.freeze(['node_modules', '.next', '.turbo', 'dist', 'build', 'coverage', '.venv', 'target']);
|
|
27
|
+
const ADMISSION_CONFIG_LABEL = '.awm/admission.json';
|
|
28
|
+
const GIT_TIMEOUT_MS = 5_000;
|
|
29
|
+
const GIT_MAX_OUTPUT_BYTES = 64 * 1024;
|
|
30
|
+
const MAX_FIELD_LABEL = 64;
|
|
31
|
+
const MAX_CONFIG_BYTES = 65_536;
|
|
32
|
+
// Control characters (C0, DEL, C1), bidi controls (marks, embeddings, isolates) and line/paragraph separators.
|
|
33
|
+
const UNSAFE_LABEL_CHARS = /[\p{Cc}\p{Bidi_Control}\u2028\u2029]/gu;
|
|
34
|
+
// Variables that relocate the repository, index or object store: inherited ones would
|
|
35
|
+
// make git answer for a different repository than `projectRoot` (fail-open).
|
|
36
|
+
const GIT_LOCATION_VARIABLES = new Set([
|
|
37
|
+
'GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_OBJECT_DIRECTORY',
|
|
38
|
+
'GIT_ALTERNATE_OBJECT_DIRECTORIES', 'GIT_COMMON_DIR', 'GIT_NAMESPACE', 'GIT_PREFIX',
|
|
39
|
+
]);
|
|
40
|
+
const RESERVED_NAMES = new Set(['.git', '.awm']);
|
|
41
|
+
const DEFAULT_LIMITS = { maxEntries: exports.READ_ONLY_MAX_ENTRIES, maxBytes: exports.READ_ONLY_MAX_BYTES };
|
|
42
|
+
function requireRoot(value, label) {
|
|
43
|
+
if (typeof value !== 'string' || value.trim() === '')
|
|
44
|
+
throw new Error(`${label} must be a non-empty string`);
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
function isRecord(value) {
|
|
48
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
49
|
+
}
|
|
50
|
+
/** Unknown keys come from the project file: bound them and neutralize control characters. */
|
|
51
|
+
function fieldLabel(key) {
|
|
52
|
+
const clean = key.replace(UNSAFE_LABEL_CHARS, '?');
|
|
53
|
+
return clean.length <= MAX_FIELD_LABEL ? clean : `${clean.slice(0, MAX_FIELD_LABEL - 1)}…`;
|
|
54
|
+
}
|
|
55
|
+
function defaultRules() {
|
|
56
|
+
return exports.DEFAULT_EXCLUSION_NAMES.map(name => ({ kind: 'name', name }));
|
|
57
|
+
}
|
|
58
|
+
/** Exact, case-insensitive match: `my.awm` and `.gitignore-dir` are not reserved. */
|
|
59
|
+
function isReservedSegment(segment) {
|
|
60
|
+
return RESERVED_NAMES.has(segment.toLowerCase());
|
|
61
|
+
}
|
|
62
|
+
function entryProblem(entry) {
|
|
63
|
+
if (entry === '')
|
|
64
|
+
return 'must not be empty';
|
|
65
|
+
if (/[\0\r\n\\*?[]/.test(entry))
|
|
66
|
+
return 'must not contain a backslash, wildcard, NUL or line break';
|
|
67
|
+
if (entry.startsWith('/') || /^[A-Za-z]:/.test(entry))
|
|
68
|
+
return 'must be relative to the project root';
|
|
69
|
+
if (entry.endsWith('/'))
|
|
70
|
+
return 'must not end with "/"';
|
|
71
|
+
const segments = entry.split('/');
|
|
72
|
+
if (segments.some(segment => segment === '' || segment === '.' || segment === '..'))
|
|
73
|
+
return 'must not contain an empty, "." or ".." segment';
|
|
74
|
+
if (segments.some(isReservedSegment))
|
|
75
|
+
return 'must not exclude .git or .awm';
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
/** Strict parser for the optional `.awm/admission.json`; absent means AWM defaults. */
|
|
79
|
+
function loadAdmissionConfig(projectRoot) {
|
|
80
|
+
const root = requireRoot(projectRoot, 'admission config projectRoot');
|
|
81
|
+
const invalid = (field, message) => ({ ok: false, field, message });
|
|
82
|
+
const file = path_1.default.join(root, '.awm', 'admission.json');
|
|
83
|
+
// The file is repo-controlled: inspect it before reading so a link, device or huge
|
|
84
|
+
// file can never be followed or slurped into memory.
|
|
85
|
+
let stat;
|
|
86
|
+
try {
|
|
87
|
+
stat = fs_1.default.lstatSync(file);
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
if (error.code === 'ENOENT')
|
|
91
|
+
return { ok: true, config: { defaults: true, rules: defaultRules() } };
|
|
92
|
+
return invalid('(file)', 'could not be read');
|
|
93
|
+
}
|
|
94
|
+
if (!stat.isFile())
|
|
95
|
+
return invalid('(file)', 'must be a regular file, not a link or device');
|
|
96
|
+
if (stat.size > MAX_CONFIG_BYTES)
|
|
97
|
+
return invalid('(file)', `is larger than ${MAX_CONFIG_BYTES} bytes`);
|
|
98
|
+
let raw;
|
|
99
|
+
try {
|
|
100
|
+
raw = fs_1.default.readFileSync(file, 'utf8');
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return invalid('(file)', 'could not be read');
|
|
104
|
+
}
|
|
105
|
+
let value;
|
|
106
|
+
try {
|
|
107
|
+
value = JSON.parse(raw);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return invalid('(file)', 'is not valid JSON');
|
|
111
|
+
}
|
|
112
|
+
if (!isRecord(value))
|
|
113
|
+
return invalid('(root)', 'must be a JSON object');
|
|
114
|
+
const unknownRoot = Object.keys(value).find(key => key !== 'schemaVersion' && key !== 'readOnlyObservation');
|
|
115
|
+
if (unknownRoot !== undefined)
|
|
116
|
+
return invalid(fieldLabel(unknownRoot), 'is not a known field');
|
|
117
|
+
if (value.schemaVersion !== 1)
|
|
118
|
+
return invalid('schemaVersion', 'must be 1');
|
|
119
|
+
const observation = 'readOnlyObservation' in value ? value.readOnlyObservation : {};
|
|
120
|
+
if (!isRecord(observation))
|
|
121
|
+
return invalid('readOnlyObservation', 'must be an object');
|
|
122
|
+
const unknownObservation = Object.keys(observation).find(key => key !== 'defaults' && key !== 'exclude');
|
|
123
|
+
if (unknownObservation !== undefined)
|
|
124
|
+
return invalid(`readOnlyObservation.${fieldLabel(unknownObservation)}`, 'is not a known field');
|
|
125
|
+
const defaults = 'defaults' in observation ? observation.defaults : true;
|
|
126
|
+
if (typeof defaults !== 'boolean')
|
|
127
|
+
return invalid('readOnlyObservation.defaults', 'must be a boolean');
|
|
128
|
+
const exclude = 'exclude' in observation ? observation.exclude : [];
|
|
129
|
+
if (!Array.isArray(exclude))
|
|
130
|
+
return invalid('readOnlyObservation.exclude', 'must be an array of strings');
|
|
131
|
+
const rules = defaults ? defaultRules() : [];
|
|
132
|
+
for (const [index, entry] of exclude.entries()) {
|
|
133
|
+
const field = `readOnlyObservation.exclude[${index}]`;
|
|
134
|
+
if (typeof entry !== 'string')
|
|
135
|
+
return invalid(field, 'must be a string');
|
|
136
|
+
const problem = entryProblem(entry);
|
|
137
|
+
if (problem)
|
|
138
|
+
return invalid(field, problem);
|
|
139
|
+
rules.push(entry.includes('/') ? { kind: 'path', path: entry } : { kind: 'name', name: entry });
|
|
140
|
+
}
|
|
141
|
+
return { ok: true, config: { defaults, rules } };
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Rule matching plus cached verdicts. The same policy instance serves the before and
|
|
145
|
+
* after snapshots, so a candidate is verified once and both passes agree.
|
|
146
|
+
*/
|
|
147
|
+
function createExclusionPolicy(config, verifier) {
|
|
148
|
+
if (!config || !Array.isArray(config.rules) || typeof config.defaults !== 'boolean')
|
|
149
|
+
throw new Error('exclusion policy requires a validated admission config');
|
|
150
|
+
if (typeof verifier !== 'function')
|
|
151
|
+
throw new Error('exclusion policy requires a candidate verifier');
|
|
152
|
+
const names = new Set(config.rules.flatMap(rule => (rule.kind === 'name' ? [rule.name] : [])));
|
|
153
|
+
const paths = new Set(config.rules.flatMap(rule => (rule.kind === 'path' ? [rule.path] : [])));
|
|
154
|
+
const verdicts = new Map();
|
|
155
|
+
return {
|
|
156
|
+
isExcluded(relativePath, name) {
|
|
157
|
+
// .git and .awm are never excluded, at any depth, nor anything inside them (B-R5).
|
|
158
|
+
if (relativePath.split('/').some(isReservedSegment))
|
|
159
|
+
return false;
|
|
160
|
+
if (!names.has(name) && !paths.has(relativePath))
|
|
161
|
+
return false;
|
|
162
|
+
let verdict = verdicts.get(relativePath);
|
|
163
|
+
if (verdict === undefined) {
|
|
164
|
+
verdict = verifier(relativePath);
|
|
165
|
+
verdicts.set(relativePath, verdict);
|
|
166
|
+
}
|
|
167
|
+
return verdict === 'excluded';
|
|
168
|
+
},
|
|
169
|
+
evidence() {
|
|
170
|
+
const excluded = [];
|
|
171
|
+
const rejected = [];
|
|
172
|
+
const sorted = [...verdicts.entries()].sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0));
|
|
173
|
+
for (const [candidate, verdict] of sorted) {
|
|
174
|
+
if (verdict === 'excluded')
|
|
175
|
+
excluded.push(candidate);
|
|
176
|
+
else
|
|
177
|
+
rejected.push({ path: candidate, reason: verdict });
|
|
178
|
+
}
|
|
179
|
+
return { defaults: config.defaults, excluded, rejected };
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
/** A copy of `env` without repository-location variables (case-insensitive: Windows). */
|
|
184
|
+
function scrubGitLocation(env) {
|
|
185
|
+
const scrubbed = {};
|
|
186
|
+
for (const [key, value] of Object.entries(env)) {
|
|
187
|
+
if (!GIT_LOCATION_VARIABLES.has(key.toUpperCase()))
|
|
188
|
+
scrubbed[key] = value;
|
|
189
|
+
}
|
|
190
|
+
return scrubbed;
|
|
191
|
+
}
|
|
192
|
+
/** Git decides: no tracked file inside (checked first) and ignored. Any doubt observes. */
|
|
193
|
+
function gitVerifier(projectRoot, env = process.env, spawn = child_process_1.spawnSync) {
|
|
194
|
+
const cwd = requireRoot(projectRoot, 'git verifier projectRoot');
|
|
195
|
+
const childEnv = scrubGitLocation(env);
|
|
196
|
+
const git = (args) => spawn('git', args, {
|
|
197
|
+
cwd,
|
|
198
|
+
env: childEnv,
|
|
199
|
+
encoding: 'utf8',
|
|
200
|
+
timeout: GIT_TIMEOUT_MS,
|
|
201
|
+
maxBuffer: GIT_MAX_OUTPUT_BYTES,
|
|
202
|
+
windowsHide: true,
|
|
203
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
204
|
+
});
|
|
205
|
+
return relativePath => {
|
|
206
|
+
// Git reads magic prefixes (":(top)", ":/") even after "--"; a leading "./"
|
|
207
|
+
// makes the candidate an ordinary relative pathspec, so a directory that is
|
|
208
|
+
// literally named like magic is never mistaken for a different path.
|
|
209
|
+
const pathspec = `./${relativePath}`;
|
|
210
|
+
// Tracked first: git already reports a directory holding a force-added file
|
|
211
|
+
// as not ignored, which would hide the more precise reason.
|
|
212
|
+
const tracked = git(['ls-files', '-z', '--', pathspec]);
|
|
213
|
+
if (tracked.error?.code === 'ENOBUFS')
|
|
214
|
+
return 'contains-tracked';
|
|
215
|
+
if (tracked.error || tracked.status !== 0)
|
|
216
|
+
return 'git-unavailable';
|
|
217
|
+
if (tracked.stdout !== '')
|
|
218
|
+
return 'contains-tracked';
|
|
219
|
+
const ignored = git(['check-ignore', '-q', '--', pathspec]);
|
|
220
|
+
if (ignored.error)
|
|
221
|
+
return 'git-unavailable';
|
|
222
|
+
if (ignored.status === 0)
|
|
223
|
+
return 'excluded';
|
|
224
|
+
return ignored.status === 1 ? 'not-ignored' : 'git-unavailable';
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
class SnapshotLimitError extends Error {
|
|
228
|
+
failure;
|
|
229
|
+
constructor(failure) {
|
|
230
|
+
super(failure);
|
|
231
|
+
this.failure = failure;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* A git status only sees tracked and non-ignored paths, while a sensor can mutate
|
|
236
|
+
* ignored runtime files (notably `.awm/`) just as easily, so the actual tree is
|
|
237
|
+
* hashed. The walk is bounded: inability to observe the whole declared project means
|
|
238
|
+
* no admission certificate. Excluded directories contribute nothing; observed
|
|
239
|
+
* directories are hashed without their size, which changes as entries come and go
|
|
240
|
+
* and is already covered by their children.
|
|
241
|
+
*/
|
|
242
|
+
function filesystemSnapshot(root, policy, limits = DEFAULT_LIMITS) {
|
|
243
|
+
requireRoot(root, 'snapshot root');
|
|
244
|
+
if (!Number.isSafeInteger(limits.maxEntries) || limits.maxEntries < 1 || !Number.isSafeInteger(limits.maxBytes) || limits.maxBytes < 1) {
|
|
245
|
+
throw new Error('snapshot limits must be positive integers');
|
|
246
|
+
}
|
|
247
|
+
try {
|
|
248
|
+
const digest = crypto_1.default.createHash('sha256');
|
|
249
|
+
let entriesSeen = 0;
|
|
250
|
+
let bytesSeen = 0;
|
|
251
|
+
const visit = (directory, relative) => {
|
|
252
|
+
const entries = fs_1.default.readdirSync(directory, { withFileTypes: true })
|
|
253
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
254
|
+
for (const entry of entries) {
|
|
255
|
+
// Git's internal bookkeeping changes as a consequence of normal
|
|
256
|
+
// observation, not project sensor execution.
|
|
257
|
+
if (relative === '' && entry.name === '.git')
|
|
258
|
+
continue;
|
|
259
|
+
const child = path_1.default.join(directory, entry.name);
|
|
260
|
+
const childRelative = relative === '' ? entry.name : `${relative}/${entry.name}`;
|
|
261
|
+
const stat = fs_1.default.lstatSync(child);
|
|
262
|
+
if (stat.isDirectory() && policy?.isExcluded(childRelative, entry.name))
|
|
263
|
+
continue;
|
|
264
|
+
if (++entriesSeen > limits.maxEntries)
|
|
265
|
+
throw new SnapshotLimitError('limit-entries');
|
|
266
|
+
if (stat.isDirectory()) {
|
|
267
|
+
digest.update(`${childRelative}\0${stat.mode}\0directory\0`);
|
|
268
|
+
visit(child, childRelative);
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
digest.update(`${childRelative}\0${stat.mode}\0${stat.size}\0`);
|
|
272
|
+
if (stat.isFile()) {
|
|
273
|
+
if ((bytesSeen += stat.size) > limits.maxBytes)
|
|
274
|
+
throw new SnapshotLimitError('limit-bytes');
|
|
275
|
+
digest.update('file\0');
|
|
276
|
+
digest.update(fs_1.default.readFileSync(child));
|
|
277
|
+
}
|
|
278
|
+
else if (stat.isSymbolicLink()) {
|
|
279
|
+
digest.update(`symlink\0${fs_1.default.readlinkSync(child)}\0`);
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
digest.update(`special\0${stat.rdev}\0`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
visit(root, '');
|
|
287
|
+
return { ok: true, digest: digest.digest('hex') };
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
// Every unexpected error (unreadable entry, vanished file, a throwing injected
|
|
291
|
+
// policy) is deliberately reported as 'unreadable': a tree that cannot be fully
|
|
292
|
+
// observed must fail closed instead of yielding a digest.
|
|
293
|
+
return { ok: false, failure: error instanceof SnapshotLimitError ? error.failure : 'unreadable' };
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
function snapshotUnavailableRemedy(failure) {
|
|
297
|
+
if (failure === 'unreadable')
|
|
298
|
+
return 'run admission from a readable git worktree';
|
|
299
|
+
const limit = failure === 'limit-bytes' ? `${exports.READ_ONLY_MAX_BYTES / 1024 ** 3} GiB of observed file content` : `${exports.READ_ONLY_MAX_ENTRIES} observed entries`;
|
|
300
|
+
return `the project tree exceeds the read-only observation limit of ${limit}; git-ignore heavy generated directories `
|
|
301
|
+
+ `(ignored ${exports.DEFAULT_EXCLUSION_NAMES.join(', ')} are skipped by default) or declare them in ${ADMISSION_CONFIG_LABEL} readOnlyObservation.exclude`;
|
|
302
|
+
}
|
|
303
|
+
function admissionConfigRemedy(problem) {
|
|
304
|
+
return `repair ${ADMISSION_CONFIG_LABEL}: ${problem.field} ${problem.message}`;
|
|
305
|
+
}
|
|
@@ -6,12 +6,11 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
exports.applyBaseline = void 0;
|
|
7
7
|
exports.findManifestDir = findManifestDir;
|
|
8
8
|
exports.detectPackDrift = detectPackDrift;
|
|
9
|
+
exports.shouldRun = shouldRun;
|
|
9
10
|
exports.resolveConcurrency = resolveConcurrency;
|
|
10
11
|
exports.runSensors = runSensors;
|
|
11
|
-
const fs_1 = __importDefault(require("fs"));
|
|
12
12
|
const os_1 = __importDefault(require("os"));
|
|
13
13
|
const path_1 = __importDefault(require("path"));
|
|
14
|
-
const crypto_1 = __importDefault(require("crypto"));
|
|
15
14
|
const baseline_1 = require("./baseline");
|
|
16
15
|
const result_1 = require("./result");
|
|
17
16
|
Object.defineProperty(exports, "applyBaseline", { enumerable: true, get: function () { return result_1.applyBaseline; } });
|
|
@@ -28,64 +27,12 @@ const registries_1 = require("../../core/registries");
|
|
|
28
27
|
const prepare_1 = require("./prepare");
|
|
29
28
|
const verdict_1 = require("./verdict");
|
|
30
29
|
const project_1 = require("./project");
|
|
30
|
+
const read_only_observation_1 = require("./read-only-observation");
|
|
31
31
|
// Sensor JSON output can be several MB on large repos (e.g. `eslint --format json`
|
|
32
32
|
// with thousands of findings). A 1MB cap killed the child with SIGTERM when
|
|
33
33
|
// exceeded — which previously surfaced as a false "timeout".
|
|
34
34
|
/** Hard ceiling on parallel sensors: past this, they only contend for the same cores. */
|
|
35
35
|
const MAX_CONCURRENCY = 4;
|
|
36
|
-
const READ_ONLY_MAX_ENTRIES = 250_000;
|
|
37
|
-
const READ_ONLY_MAX_BYTES = 1024 * 1024 * 1024;
|
|
38
|
-
/**
|
|
39
|
-
* A git status only sees tracked and non-ignored paths, while a sensor can
|
|
40
|
-
* mutate ignored runtime files (notably `.awm/`) just as easily. Hash the
|
|
41
|
-
* actual project tree instead. The scan is deliberately bounded: inability to
|
|
42
|
-
* observe the whole declared project means no admission certificate.
|
|
43
|
-
*/
|
|
44
|
-
function filesystemSnapshot(root) {
|
|
45
|
-
try {
|
|
46
|
-
const digest = crypto_1.default.createHash('sha256');
|
|
47
|
-
let entriesSeen = 0;
|
|
48
|
-
let bytesSeen = 0;
|
|
49
|
-
const visit = (directory, relative) => {
|
|
50
|
-
const entries = fs_1.default.readdirSync(directory, { withFileTypes: true })
|
|
51
|
-
.sort((left, right) => left.name.localeCompare(right.name));
|
|
52
|
-
for (const entry of entries) {
|
|
53
|
-
// Git's internal bookkeeping changes as a consequence of normal
|
|
54
|
-
// observation, not project sensor execution. Everything else,
|
|
55
|
-
// including ignored/untracked `.awm`, is evidence-relevant.
|
|
56
|
-
if (relative === '' && entry.name === '.git')
|
|
57
|
-
continue;
|
|
58
|
-
if (++entriesSeen > READ_ONLY_MAX_ENTRIES)
|
|
59
|
-
throw new Error('snapshot-entry-limit');
|
|
60
|
-
const child = path_1.default.join(directory, entry.name);
|
|
61
|
-
const childRelative = relative === '' ? entry.name : `${relative}/${entry.name}`;
|
|
62
|
-
const stat = fs_1.default.lstatSync(child);
|
|
63
|
-
digest.update(`${childRelative}\0${stat.mode}\0${stat.size}\0`);
|
|
64
|
-
if (stat.isDirectory()) {
|
|
65
|
-
digest.update('directory\0');
|
|
66
|
-
visit(child, childRelative);
|
|
67
|
-
}
|
|
68
|
-
else if (stat.isFile()) {
|
|
69
|
-
if ((bytesSeen += stat.size) > READ_ONLY_MAX_BYTES)
|
|
70
|
-
throw new Error('snapshot-byte-limit');
|
|
71
|
-
digest.update('file\0');
|
|
72
|
-
digest.update(fs_1.default.readFileSync(child));
|
|
73
|
-
}
|
|
74
|
-
else if (stat.isSymbolicLink()) {
|
|
75
|
-
digest.update(`symlink\0${fs_1.default.readlinkSync(child)}\0`);
|
|
76
|
-
}
|
|
77
|
-
else {
|
|
78
|
-
digest.update(`special\0${stat.rdev}\0`);
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
};
|
|
82
|
-
visit(root, '');
|
|
83
|
-
return digest.digest('hex');
|
|
84
|
-
}
|
|
85
|
-
catch {
|
|
86
|
-
return null;
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
36
|
async function resolveLiveFromSource(cwd, source, packSelection) {
|
|
90
37
|
try {
|
|
91
38
|
const parsed = (0, contract_1.parseSensorPack)(JSON.parse(source.content), source.path);
|
|
@@ -213,9 +160,20 @@ async function runSensors(opts = {}) {
|
|
|
213
160
|
configured = project;
|
|
214
161
|
}
|
|
215
162
|
const manifestDir = configured.projectRoot;
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
163
|
+
// Admission-only: `.awm/admission.json` is never read for ordinary runs.
|
|
164
|
+
let readOnlyPolicy;
|
|
165
|
+
let beforeReadOnly;
|
|
166
|
+
if (opts.readOnly) {
|
|
167
|
+
const admissionConfig = (0, read_only_observation_1.loadAdmissionConfig)(manifestDir);
|
|
168
|
+
if (!admissionConfig.ok) {
|
|
169
|
+
return { sensors: [], overall: 'not_certified', projectRoot: configured.projectRoot, manifestPath: configured.manifestPath, mode: 'invalid', reason: 'admission-config-invalid', remedy: (0, read_only_observation_1.admissionConfigRemedy)(admissionConfig) };
|
|
170
|
+
}
|
|
171
|
+
readOnlyPolicy = (0, read_only_observation_1.createExclusionPolicy)(admissionConfig.config, (0, read_only_observation_1.gitVerifier)(manifestDir));
|
|
172
|
+
const before = (0, read_only_observation_1.filesystemSnapshot)(manifestDir, readOnlyPolicy);
|
|
173
|
+
if (!before.ok) {
|
|
174
|
+
return { sensors: [], overall: 'not_certified', projectRoot: configured.projectRoot, manifestPath: configured.manifestPath, mode: 'invalid', reason: 'read-only-observation-unavailable', remedy: (0, read_only_observation_1.snapshotUnavailableRemedy)(before.failure), readOnlyObservation: readOnlyPolicy.evidence() };
|
|
175
|
+
}
|
|
176
|
+
beforeReadOnly = before.digest;
|
|
219
177
|
}
|
|
220
178
|
const parsed = configured.manifest;
|
|
221
179
|
const authority = { projectRoot: configured.projectRoot, manifestPath: configured.manifestPath };
|
|
@@ -382,8 +340,14 @@ async function runSensors(opts = {}) {
|
|
|
382
340
|
...(drift?.drift ? { packDrift: drift.drift } : {}),
|
|
383
341
|
...(changed ? { changedScope: { files: changed.files.length, ...(changed.error ? { error: changed.error } : {}) } } : {}),
|
|
384
342
|
};
|
|
385
|
-
if (
|
|
386
|
-
|
|
343
|
+
if (readOnlyPolicy) {
|
|
344
|
+
// Same policy instance: cached verdicts keep both passes consistent.
|
|
345
|
+
const after = (0, read_only_observation_1.filesystemSnapshot)(manifestDir, readOnlyPolicy);
|
|
346
|
+
const readOnlyObservation = readOnlyPolicy.evidence();
|
|
347
|
+
if (!after.ok || after.digest !== beforeReadOnly) {
|
|
348
|
+
return { ...output, overall: 'not_certified', reason: 'read-only-mutation-detected', remedy: 'sensor execution changed the worktree; repair it before using this evidence for admission', readOnlyObservation };
|
|
349
|
+
}
|
|
350
|
+
return { ...output, readOnlyObservation };
|
|
387
351
|
}
|
|
388
352
|
return output;
|
|
389
353
|
}
|
|
@@ -101,6 +101,16 @@ function currentness(report, consumedRegistryComponents) {
|
|
|
101
101
|
return { status, cli, diagnostics: [diagnostic('ADMISSION_CURRENTNESS_BLOCKED', `Consumed contract currentness is ${status}: ${names}.`)] };
|
|
102
102
|
}
|
|
103
103
|
function sensorVerdict(report) { return report.overall === 'not_certified' ? 'not-certified' : report.overall === 'pass' ? 'pass' : 'fail'; }
|
|
104
|
+
/** A not-certified verdict alone does not say what to fix; carry the run's own cause. */
|
|
105
|
+
function sensorBlockedMessage(report, verdict) {
|
|
106
|
+
// The diagnostic code must stay the message's only ADMISSION_ token (recovery classification counts them); echoed text may carry user-chosen ones.
|
|
107
|
+
const plain = (text) => text.trim().replace(/ADMISSION_/g, 'ADMISSION-');
|
|
108
|
+
const reason = verdict === 'not-certified' && typeof report.reason === 'string' ? plain(report.reason) : '';
|
|
109
|
+
if (!reason)
|
|
110
|
+
return `Sensor verdict is ${verdict}.`;
|
|
111
|
+
const remedy = typeof report.remedy === 'string' ? plain(report.remedy).replace(/\.+$/, '') : '';
|
|
112
|
+
return remedy ? `Sensor verdict is ${verdict} (${reason}): ${remedy}.` : `Sensor verdict is ${verdict} (${reason}).`;
|
|
113
|
+
}
|
|
104
114
|
function journalStatus(input, plan) {
|
|
105
115
|
if (input.journalCorrupt)
|
|
106
116
|
return 'corrupt';
|
|
@@ -237,7 +247,7 @@ async function admitPlan(input) {
|
|
|
237
247
|
const sensors = sensorVerdict(input.sensors);
|
|
238
248
|
const registryOptOut = sensors === 'not-certified' && input.sensorClosurePolicy === 'r8-registry-opt-out' && input.sensors.mode === 'opt-out';
|
|
239
249
|
if (sensors !== 'pass' && !registryOptOut)
|
|
240
|
-
return blocked(input, [diagnostic('ADMISSION_SENSORS_BLOCKED',
|
|
250
|
+
return blocked(input, [diagnostic('ADMISSION_SENSORS_BLOCKED', sensorBlockedMessage(input.sensors, sensors))], { planDigest: plan.planDigest, provider, executionMode: mode, currentness: input.requireCurrent ? 'current' : 'not-checked', cliCurrentness, sensors });
|
|
241
251
|
sensorsReported = sensors;
|
|
242
252
|
if (registryOptOut)
|
|
243
253
|
sensorEvidence = { sensorEvidence: 'r8-registry-opt-out' };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.failed = exports.fail = exports.line = void 0;
|
|
4
|
+
exports.inCwd = inCwd;
|
|
5
|
+
const command_result_1 = require("../command-result");
|
|
6
|
+
const text_safety_1 = require("./text-safety");
|
|
7
|
+
// What the Change Record commands (`change new`, `change validate`, `changes list`) share of their command line: one line of
|
|
8
|
+
// output, the stderr form of a failure and the working-directory guard of an action. Nothing here throws or imports a command.
|
|
9
|
+
/** One line of output: control characters escaped, at most 300 UTF-16 units (an ellipsis replaces the cut), never splitting a surrogate pair. */
|
|
10
|
+
const line = (text) => (0, text_safety_1.clipMessage)((0, text_safety_1.cleanText)(text));
|
|
11
|
+
exports.line = line;
|
|
12
|
+
/**
|
|
13
|
+
* Exit 2 with `code: message` as ONE clipped line (the code counts), and an optional static remedy as a second clipped line.
|
|
14
|
+
* Untrusted text is escaped by the caller with esc().
|
|
15
|
+
*/
|
|
16
|
+
const fail = (code, message, remedy) => ({
|
|
17
|
+
code: 2, stdout: '',
|
|
18
|
+
stderr: `${(0, exports.line)(`${code}: ${message}`)}\n${remedy ? `${(0, exports.line)(` remedy: ${remedy}`)}\n` : ''}`,
|
|
19
|
+
});
|
|
20
|
+
exports.fail = fail;
|
|
21
|
+
/** A shared-helper Failure as the stderr form. */
|
|
22
|
+
const failed = (f) => (0, exports.fail)(f.code, f.message, f.remedy);
|
|
23
|
+
exports.failed = failed;
|
|
24
|
+
/**
|
|
25
|
+
* The action wrapper: runs `command` in the working directory and emits its result. The working directory may be gone: then
|
|
26
|
+
* `noCwd()` is emitted instead. Nothing may throw out of the action.
|
|
27
|
+
*/
|
|
28
|
+
function inCwd(command, noCwd) {
|
|
29
|
+
let cwd;
|
|
30
|
+
try {
|
|
31
|
+
cwd = process.cwd();
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
(0, command_result_1.emit)(noCwd());
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
(0, command_result_1.emit)(command(cwd));
|
|
38
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.deriveFromCommits = deriveFromCommits;
|
|
7
|
+
exports.deriveModules = deriveModules;
|
|
8
|
+
exports.commitsFromRecords = commitsFromRecords;
|
|
9
|
+
exports.deriveFields = deriveFields;
|
|
10
|
+
const path_1 = __importDefault(require("path"));
|
|
11
|
+
const core_1 = require("../../release/core");
|
|
12
|
+
const git_1 = require("./git");
|
|
13
|
+
const text_safety_1 = require("./text-safety");
|
|
14
|
+
const types_1 = require("./types");
|
|
15
|
+
// Pathspecs relative to the repository top, so the result does not depend on the directory git runs in.
|
|
16
|
+
const ALL_FILES = ':/';
|
|
17
|
+
const EXCLUDE_RECORDS = `:(top,exclude,literal)${types_1.RECORD_DIR}`;
|
|
18
|
+
const PARSER_TYPE = /^\w+$/;
|
|
19
|
+
const RECORD_TYPE = /^[a-z]+$/;
|
|
20
|
+
const MODULE_DEPTH = 3;
|
|
21
|
+
// R3.3: the release parser's header regex backtracks quadratically on a long subject with blanks and line terminators, so a
|
|
22
|
+
// subject (the text before the first US of a record) over this many UTF-16 units is never handed to it.
|
|
23
|
+
const MAX_SUBJECT_UNITS = 1024;
|
|
24
|
+
/** Keeps the commits the release parser can produce (a `\w+` type, any spelling) with a scope that is usable text; anything else is ignored. */
|
|
25
|
+
function usable(commits) {
|
|
26
|
+
if (!Array.isArray(commits))
|
|
27
|
+
return [];
|
|
28
|
+
const out = [];
|
|
29
|
+
for (const c of commits) {
|
|
30
|
+
if (typeof c !== 'object' || c === null)
|
|
31
|
+
continue;
|
|
32
|
+
const { type, scope, breaking, subject } = c;
|
|
33
|
+
if (typeof type !== 'string' || !PARSER_TYPE.test(type))
|
|
34
|
+
continue;
|
|
35
|
+
const cleanScope = typeof scope === 'string' && scope.trim() !== '' && (0, text_safety_1.isSingleLine)(scope) ? scope.trim() : null;
|
|
36
|
+
out.push({ type, scope: cleanScope, breaking: breaking === true, subject: typeof subject === 'string' ? subject : '' });
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
const byCodeUnit = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
41
|
+
/**
|
|
42
|
+
* R3.3. `breaking` and `semver` cover every usable commit, with the release parser's exact-type semantics; the recorded type is
|
|
43
|
+
* the highest-impact commit's type lowercased, or `other` when that is still not purely letters. Null when no commit is usable.
|
|
44
|
+
*/
|
|
45
|
+
function deriveFromCommits(input) {
|
|
46
|
+
const commits = usable(input);
|
|
47
|
+
if (commits.length === 0)
|
|
48
|
+
return null;
|
|
49
|
+
const breaking = commits.some((c) => c.breaking);
|
|
50
|
+
const pick = commits.find((c) => c.breaking) ?? commits.find((c) => c.type === 'feat') ?? commits.find((c) => c.type === 'fix')
|
|
51
|
+
?? commits.find((c) => c.type === 'perf') ?? commits[0];
|
|
52
|
+
const counts = new Map();
|
|
53
|
+
for (const c of commits)
|
|
54
|
+
if (c.scope)
|
|
55
|
+
counts.set(c.scope, (counts.get(c.scope) ?? 0) + 1);
|
|
56
|
+
const scope = [...counts.entries()].sort((a, b) => b[1] - a[1] || byCodeUnit(a[0], b[0]))[0]?.[0] ?? null;
|
|
57
|
+
const semver = (0, core_1.determineBump)(commits) ?? 'none';
|
|
58
|
+
const type = pick.type.toLowerCase();
|
|
59
|
+
return { type: RECORD_TYPE.test(type) ? type : 'other', scope, semver, breaking };
|
|
60
|
+
}
|
|
61
|
+
/** R3.4. Directories a record cannot hold (control characters, backslashes, `..` ...) are left out rather than leaked into it. */
|
|
62
|
+
function deriveModules(files) {
|
|
63
|
+
if (!Array.isArray(files))
|
|
64
|
+
return [];
|
|
65
|
+
const modules = new Set();
|
|
66
|
+
for (const f of files) {
|
|
67
|
+
if (typeof f !== 'string' || f.length === 0 || f.startsWith(`${types_1.RECORD_DIR}/`))
|
|
68
|
+
continue;
|
|
69
|
+
const dir = path_1.default.posix.dirname(f);
|
|
70
|
+
const module = dir === '.' ? '.' : dir.split('/').slice(0, MODULE_DEPTH).join('/');
|
|
71
|
+
if (module === '.' || (0, text_safety_1.isSafeRelativePath)(module))
|
|
72
|
+
modules.add(module);
|
|
73
|
+
}
|
|
74
|
+
return [...modules].sort();
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The conventional commits of NUL-separated `git log` records. Measured on the raw record, before RS is stripped (the stripped text
|
|
78
|
+
* is never longer): like the release parser, the subject ends at the first US, so only the text before it counts, and a subject over
|
|
79
|
+
* MAX_SUBJECT_UNITS is never handed to the parser.
|
|
80
|
+
*/
|
|
81
|
+
function commitsFromRecords(records) {
|
|
82
|
+
return records.flatMap((record) => {
|
|
83
|
+
const end = record.indexOf(core_1.US);
|
|
84
|
+
return (end < 0 ? record.length : end) > MAX_SUBJECT_UNITS ? [] : (0, core_1.parseCommits)(record.split(core_1.RS).join(''));
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
const fail = (code, message) => ({ ok: false, diagnostic: { code, message: (0, text_safety_1.clipMessage)(message) } });
|
|
88
|
+
/**
|
|
89
|
+
* Derives from the non-merge commits in mergeBaseSha..HEAD, ignoring commits and files that only touch docs/changes/.
|
|
90
|
+
* `mergeBaseSha` must be a full commit id (the output of `mergeBase`); anything else is `invalid-input`.
|
|
91
|
+
*/
|
|
92
|
+
function deriveFields(root, mergeBaseSha) {
|
|
93
|
+
if (typeof root !== 'string' || root.length === 0)
|
|
94
|
+
return fail('invalid-input', 'repository root must be a non-empty string');
|
|
95
|
+
if (typeof mergeBaseSha !== 'string' || !types_1.COMMIT_ID.test(mergeBaseSha))
|
|
96
|
+
return fail('invalid-input', `merge-base must be a full commit id, got "${(0, text_safety_1.esc)(mergeBaseSha)}"`);
|
|
97
|
+
// HEAD is read once: the log and the diff below both use this sha, so a HEAD that moves meanwhile cannot mix snapshots.
|
|
98
|
+
const head = (0, git_1.headSha)(root);
|
|
99
|
+
if (head === null)
|
|
100
|
+
return fail('git-failed', 'git could not read HEAD');
|
|
101
|
+
const range = `${mergeBaseSha}..${head}`;
|
|
102
|
+
// --full-history: with a pathspec git would otherwise prune the side of a merge whose tree equals the other parent
|
|
103
|
+
// (`merge -s ours`), silently dropping real commits from the range.
|
|
104
|
+
// -z ends every commit with NUL, which a message cannot contain: each record is exactly one commit, so a message that
|
|
105
|
+
// embeds the parser's own separator cannot be read as a second commit.
|
|
106
|
+
const raw = (0, git_1.runGit)(root, ['log', ...git_1.LOG_SAFETY_ARGS, '--no-merges', '--full-history', '--reverse', '-z', `--format=${core_1.GIT_LOG_FORMAT}`, '--end-of-options', range, '--', ALL_FILES, EXCLUDE_RECORDS]);
|
|
107
|
+
if (raw === null)
|
|
108
|
+
return fail('git-failed', `git could not read the commits in ${range}`);
|
|
109
|
+
const records = raw.split('\0').filter((record) => record !== '');
|
|
110
|
+
if (records.length === 0)
|
|
111
|
+
return fail('no-commits', `no non-merge commits outside ${types_1.RECORD_DIR}/ in ${range}`);
|
|
112
|
+
const commits = commitsFromRecords(records);
|
|
113
|
+
const derived = deriveFromCommits(commits);
|
|
114
|
+
if (derived === null)
|
|
115
|
+
return fail('no-conventional-commits', 'no commit in the range uses a conventional-commit subject (type(scope): subject)');
|
|
116
|
+
const files = (0, git_1.changedFiles)(root, mergeBaseSha, head);
|
|
117
|
+
if (files === null)
|
|
118
|
+
return fail('git-failed', `git could not list the files changed in ${range}`);
|
|
119
|
+
return { ok: true, fields: { ...derived, modules: deriveModules(files) } };
|
|
120
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.isRegularFileInside = isRegularFileInside;
|
|
7
|
+
const fs_1 = __importDefault(require("fs"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const text_safety_1 = require("./text-safety");
|
|
10
|
+
// Read-only filesystem checks. No import of the write-capable record-io: the pure record validator depends on this module.
|
|
11
|
+
/** True when `name` is listed in `parent` exactly as spelled: on a case-insensitive file system `stat` also finds other spellings. */
|
|
12
|
+
function listedAs(parent, name) {
|
|
13
|
+
try {
|
|
14
|
+
return fs_1.default.readdirSync(parent).includes(name);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* A repo-relative path naming a regular file (not a link) that lies inside `root` once every link on the way is resolved, and
|
|
22
|
+
* whose every segment is spelled exactly as on disk (so the answer is the same on a case-insensitive file system as on Linux,
|
|
23
|
+
* where the validation of the committed record will run). Never throws.
|
|
24
|
+
*/
|
|
25
|
+
function isRegularFileInside(root, rel) {
|
|
26
|
+
if (typeof root !== 'string' || !(0, text_safety_1.isSafeRelativePath)(rel))
|
|
27
|
+
return false;
|
|
28
|
+
const segments = rel.split('/');
|
|
29
|
+
const abs = path_1.default.join(root, ...segments);
|
|
30
|
+
try {
|
|
31
|
+
if (!fs_1.default.lstatSync(abs).isFile())
|
|
32
|
+
return false;
|
|
33
|
+
const inside = path_1.default.relative(fs_1.default.realpathSync.native(root), fs_1.default.realpathSync.native(abs));
|
|
34
|
+
if (inside === '' || inside === '..' || inside.startsWith(`..${path_1.default.sep}`) || path_1.default.isAbsolute(inside))
|
|
35
|
+
return false;
|
|
36
|
+
let parent = root;
|
|
37
|
+
for (const segment of segments) {
|
|
38
|
+
if (!listedAs(parent, segment))
|
|
39
|
+
return false;
|
|
40
|
+
parent = path_1.default.join(parent, segment);
|
|
41
|
+
}
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|