filegrc 0.12.3 → 0.12.4

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/src/validate.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { createReadStream } from "node:fs";
2
3
  import { readFile, stat } from "node:fs/promises";
3
4
  import { performance } from "node:perf_hooks";
4
5
  import { getResourceDefinition, modelSupports } from "../model/index.js";
@@ -36,6 +37,17 @@ import {
36
37
  } from "./reporting-route-integrity.js";
37
38
  import { validateWorkflowHistoryIntegrity } from "./workflow-history-integrity.js";
38
39
 
40
+ let fingerprintFileReadObserver = null;
41
+
42
+ export function setFingerprintFileReadObserverForTests(observer) {
43
+ if (observer !== null && typeof observer !== "function") {
44
+ throw new TypeError("The fingerprint file-read observer must be a function or null.");
45
+ }
46
+ const previous = fingerprintFileReadObserver;
47
+ fingerprintFileReadObserver = observer;
48
+ return () => { fingerprintFileReadObserver = previous; };
49
+ }
50
+
39
51
  const ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
40
52
  const NAMESPACE_PATTERN = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/;
41
53
  const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
@@ -229,6 +241,7 @@ async function validateWorkspaceUnmeasured(input) {
229
241
  ));
230
242
  }
231
243
  } catch (cause) {
244
+ if (cause?.code === "FILEGRC_GIT_DEADLINE") throw cause;
232
245
  diagnostics.push(error(
233
246
  "reconciliation-history-unavailable",
234
247
  "data/workspace.json",
@@ -1224,19 +1237,27 @@ function validateCollectionReviewSet(resources, pathById, diagnostics) {
1224
1237
  }
1225
1238
  }
1226
1239
 
1227
- export async function fingerprintWorkspace(input = process.cwd()) {
1240
+ export async function fingerprintWorkspace(input = process.cwd(), options = {}) {
1228
1241
  const loaded = typeof input === "object" && input.entries ? input : await loadWorkspace(input);
1229
1242
  const hash = createHash("sha256");
1230
1243
  hash.update(`model\0${loaded.model.modelVersion}\0`);
1231
- for (const entry of [...loaded.entries].sort((a, b) => a.relativePath.localeCompare(b.relativePath))) {
1244
+ const sourceEntries = loaded.sourceEntries || loaded.entries;
1245
+ const fingerprintBudget = { uncachedBytes: 0 };
1246
+ for (const entry of [...sourceEntries].sort((a, b) => a.relativePath.localeCompare(b.relativePath))) {
1232
1247
  hash.update(`record\0${entry.relativePath}\0${entry.source.length}\0${entry.source}`);
1248
+ }
1249
+ for (const entry of [...loaded.entries].sort((a, b) => a.relativePath.localeCompare(b.relativePath))) {
1233
1250
  const definition = loaded.model.resources[entry.record?.type];
1234
1251
  if (!definition) continue;
1235
1252
  for (const item of markdownEntries(loaded.model, entry.record).sort((a, b) => a.path.localeCompare(b.path))) {
1236
1253
  try {
1237
- const source = await readFile(resolveDataPath(loaded.root, item.path), "utf8");
1238
- hash.update(`markdown\0${item.path}\0${source.length}\0${source}`);
1239
- } catch {
1254
+ const resolvedPath = resolveDataPath(loaded.root, item.path);
1255
+ const file = await stat(resolvedPath);
1256
+ if (!file.isFile()) throw new Error("Markdown path is not a file.");
1257
+ const content = await fingerprintFileContent(resolvedPath, file, options, fingerprintBudget);
1258
+ hash.update(`markdown\0${item.path}\0${content.size}\0${content.digest}\0`);
1259
+ } catch (error) {
1260
+ if (error?.code === "FILEGRC_FINGERPRINT_BUDGET") throw error;
1240
1261
  hash.update(`markdown-missing\0${item.path}\0`);
1241
1262
  }
1242
1263
  }
@@ -1247,9 +1268,18 @@ export async function fingerprintWorkspace(input = process.cwd()) {
1247
1268
  const paths = Array.isArray(value) ? value : value === undefined || value === null ? [] : [value];
1248
1269
  for (const path of [...paths].sort()) {
1249
1270
  try {
1250
- const file = await stat(resolveDataPath(loaded.root, path));
1251
- hash.update(`data-path\0${path}\0${file.isFile() ? "file" : "other"}\0`);
1252
- } catch {
1271
+ const resolvedPath = resolveDataPath(loaded.root, path);
1272
+ const file = await stat(resolvedPath);
1273
+ if (file.isFile()) {
1274
+ const identity = `${file.dev}:${file.ino}:${file.mode}:${file.size}:${file.mtimeMs}:${file.ctimeMs}`;
1275
+ let content = options.fileDigestCache?.get(resolvedPath);
1276
+ if (!content || content.identity !== identity) {
1277
+ content = await fingerprintFileContent(resolvedPath, file, options, fingerprintBudget);
1278
+ }
1279
+ hash.update(`data-path\0${path}\0file\0${content.size}\0${content.digest}\0`);
1280
+ } else hash.update(`data-path\0${path}\0other\0`);
1281
+ } catch (error) {
1282
+ if (error?.code === "FILEGRC_FINGERPRINT_BUDGET") throw error;
1253
1283
  hash.update(`data-path-missing\0${path}\0`);
1254
1284
  }
1255
1285
  }
@@ -1258,6 +1288,41 @@ export async function fingerprintWorkspace(input = process.cwd()) {
1258
1288
  return { fingerprint: hash.digest("hex"), loaded };
1259
1289
  }
1260
1290
 
1291
+ async function fingerprintFileContent(path, file, options, budget) {
1292
+ const identity = `${file.dev}:${file.ino}:${file.mode}:${file.size}:${file.mtimeMs}:${file.ctimeMs}`;
1293
+ const cached = options.fileDigestCache?.get(path);
1294
+ if (cached?.identity === identity) return cached;
1295
+ assertFingerprintBudget(options, budget.uncachedBytes + file.size);
1296
+ budget.uncachedBytes += file.size;
1297
+ const contentHash = createHash("sha256");
1298
+ let size = 0;
1299
+ await fingerprintFileReadObserver?.(path);
1300
+ for await (const chunk of createReadStream(path)) {
1301
+ assertFingerprintBudget(options, budget.uncachedBytes - file.size + size + chunk.length);
1302
+ size += chunk.length;
1303
+ contentHash.update(chunk);
1304
+ }
1305
+ const content = { identity, size, digest: contentHash.digest("hex") };
1306
+ if (options.fileDigestCache?.size < 20_000 || options.fileDigestCache?.has(path)) {
1307
+ options.fileDigestCache.set(path, content);
1308
+ }
1309
+ return content;
1310
+ }
1311
+
1312
+ function assertFingerprintBudget(options, uncachedBytes) {
1313
+ if (options.deadlineAt !== undefined && performance.now() >= options.deadlineAt) {
1314
+ const error = new Error("Workspace attachment verification exceeded its time limit. Reload and try again.");
1315
+ error.code = "FILEGRC_FINGERPRINT_BUDGET";
1316
+ throw error;
1317
+ }
1318
+ if (options.maxUncachedFileBytes !== undefined
1319
+ && uncachedBytes > options.maxUncachedFileBytes) {
1320
+ const error = new Error("Workspace file verification exceeded its byte limit. Reload and try again.");
1321
+ error.code = "FILEGRC_FINGERPRINT_BUDGET";
1322
+ throw error;
1323
+ }
1324
+ }
1325
+
1261
1326
  function validateImplementedControlSchedules(record, obligationsByControl, path, diagnostics) {
1262
1327
  if (record.type !== "control" || record.status !== "implemented") return;
1263
1328
  const schedules = obligationsByControl.get(record.id) || [];
package/src/web.js CHANGED
@@ -95,6 +95,7 @@ let repositorySyncPollInFlight = false;
95
95
  let mutationStateRefreshInFlight = false;
96
96
  let mutationStateRefreshTimer = null;
97
97
  let programSelectionGeneration = 0;
98
+ let expiredStateRefresh = null;
98
99
 
99
100
  start().catch((error) => {
100
101
  root.innerHTML = '<main class="fatal"><h1>Could Not Load the Workspace</h1><pre></pre></main>';
@@ -193,14 +194,22 @@ function loadStateForRoute() {
193
194
  for (const section of desiredStateSections(route)) loadStateSection(section);
194
195
  }
195
196
 
196
- function loadStateSection(section) {
197
+ function loadStateSection(section, refreshExpired = true) {
197
198
  if (!state.stateToken || state.sections?.[section] === "complete") return Promise.resolve();
198
199
  if (stateSectionRequests.has(section)) return stateSectionRequests.get(section);
199
200
  state.sections[section] = "loading";
200
201
  const token = state.stateToken;
201
202
  const programQuery = state.selectedProgramId ? "&programId=" + encodeURIComponent(state.selectedProgramId) : "";
202
- const request = fetchJson("/api/state/" + encodeURIComponent(section) + "?token=" + encodeURIComponent(token) + programQuery)
203
- .then((result) => {
203
+ const request = localFetch("/api/state/" + encodeURIComponent(section) + "?token=" + encodeURIComponent(token) + programQuery)
204
+ .then(async (response) => {
205
+ if (response.status === 409 && refreshExpired && state.stateToken === token) {
206
+ await refreshExpiredAppState(token);
207
+ render();
208
+ if (stateSectionRequests.get(section) === request) stateSectionRequests.delete(section);
209
+ return loadStateSection(section, false);
210
+ }
211
+ if (!response.ok) throw new Error(await responseMessage(response));
212
+ const result = await response.json();
204
213
  if (state.stateToken !== result.stateToken) return;
205
214
  Object.assign(state, result.state);
206
215
  state.sections[section] = "complete";
@@ -2909,17 +2918,35 @@ function evidenceAttachmentPanel(entry) {
2909
2918
  }
2910
2919
 
2911
2920
  async function loadResourceDetail(type, id) {
2912
- const key = type + "\0" + id;
2921
+ const key = (state.stateToken || "live") + "\0" + type + "\0" + id;
2913
2922
  if (resourceDetailRequests.has(key)) return resourceDetailRequests.get(key);
2914
2923
  const request = (async () => {
2915
2924
  try {
2916
- const response = await localFetch("/api/resource/" + encodeURIComponent(type) + "/" + encodeURIComponent(id));
2917
- if (!response.ok) throw new Error(await responseMessage(response));
2918
- const detail = await response.json();
2925
+ let token = state.stateToken;
2926
+ let detail = null;
2927
+ for (let attempt = 0; attempt < 3; attempt += 1) {
2928
+ const tokenQuery = token ? "?token=" + encodeURIComponent(token) : "";
2929
+ const response = await localFetch("/api/resource/" + encodeURIComponent(type) + "/" + encodeURIComponent(id) + tokenQuery);
2930
+ if (response.status === 409 && token) {
2931
+ await refreshExpiredAppState(token);
2932
+ token = state.stateToken;
2933
+ continue;
2934
+ }
2935
+ if (!response.ok) throw new Error(await responseMessage(response));
2936
+ detail = await response.json();
2937
+ if (!token || (detail.stateToken === token && state.stateToken === token)) break;
2938
+ token = state.stateToken;
2939
+ detail = null;
2940
+ }
2941
+ if (!detail) throw new Error("The workspace state changed while loading this record. Reload and try again.");
2942
+ delete detail.stateToken;
2919
2943
  const index = state.resources.findIndex(({ record }) => record.type === type && record.id === id);
2920
2944
  if (index >= 0) state.resources[index] = detail;
2921
2945
  const route = parseRoute();
2922
- if (route.name === "detail" && route.type === type && route.id === id) render();
2946
+ if (route.name === "detail" && route.type === type && route.id === id) {
2947
+ render();
2948
+ loadStateForRoute();
2949
+ }
2923
2950
  } catch (error) {
2924
2951
  const route = parseRoute();
2925
2952
  if (route.name === "detail" && route.type === type && route.id === id) {
@@ -2927,13 +2954,52 @@ async function loadResourceDetail(type, id) {
2927
2954
  if (main) main.innerHTML = '<div class="page"><section class="panel"><div class="dialog-error" role="alert">' + esc(error.message) + '</div></section></div>';
2928
2955
  }
2929
2956
  } finally {
2930
- resourceDetailRequests.delete(key);
2957
+ for (const [requestKey, pending] of resourceDetailRequests) {
2958
+ if (pending === request) resourceDetailRequests.delete(requestKey);
2959
+ }
2931
2960
  }
2932
2961
  })();
2933
2962
  resourceDetailRequests.set(key, request);
2934
2963
  return request;
2935
2964
  }
2936
2965
 
2966
+ function refreshExpiredAppState(expectedToken) {
2967
+ if (expiredStateRefresh) {
2968
+ if (expiredStateRefresh.token === expectedToken) return expiredStateRefresh.promise;
2969
+ return expiredStateRefresh.promise
2970
+ .catch(() => {})
2971
+ .then(() => refreshExpiredAppState(expectedToken));
2972
+ }
2973
+ if (expectedToken && state.stateToken !== expectedToken) return Promise.resolve();
2974
+ const programQuery = state.selectedProgramId ? "?programId=" + encodeURIComponent(state.selectedProgramId) : "";
2975
+ const holder = { token: expectedToken, promise: null };
2976
+ const refresh = (async () => {
2977
+ const response = await fetch("/api/state/bootstrap" + programQuery);
2978
+ if (!response.ok) throw new Error(await responseMessage(response));
2979
+ const next = normalizeAppState(await response.json());
2980
+ if (expectedToken && state.stateToken !== expectedToken) return;
2981
+ rekeyResourceDetailRequests(state.stateToken, next.stateToken);
2982
+ state = next;
2983
+ stateSectionRequests.clear();
2984
+ })().finally(() => {
2985
+ if (expiredStateRefresh === holder) expiredStateRefresh = null;
2986
+ });
2987
+ holder.promise = refresh;
2988
+ expiredStateRefresh = holder;
2989
+ return refresh;
2990
+ }
2991
+
2992
+ function rekeyResourceDetailRequests(previousToken, nextToken) {
2993
+ if (!previousToken || !nextToken || previousToken === nextToken) return;
2994
+ const prefix = previousToken + "\0";
2995
+ for (const [key, request] of resourceDetailRequests) {
2996
+ if (!key.startsWith(prefix)) continue;
2997
+ const nextKey = nextToken + key.slice(previousToken.length);
2998
+ resourceDetailRequests.delete(key);
2999
+ if (!resourceDetailRequests.has(nextKey)) resourceDetailRequests.set(nextKey, request);
3000
+ }
3001
+ }
3002
+
2937
3003
  function issueSeed(type, source) {
2938
3004
  const owners = source.ownerIds || source.reviewerIds || source.assessorIds || source.testerIds || [];
2939
3005
  if (type === "finding") {
@@ -116,7 +116,7 @@ export async function validateWorkflowHistoryIntegrity(loaded, diagnostics) {
116
116
  entry.record.type === "exception"
117
117
  && historical.record.reportingRouteSetId
118
118
  && historical.commit !== commits.find((commit) => {
119
- try { return Number(JSON.parse(getFileAtRevision(loaded.root, commit, "data/workspace.json"))?.dataModelVersion) >= 10; } catch { return false; }
119
+ try { return Number(JSON.parse(getFileAtRevision(loaded.root, commit, "data/workspace.json"))?.dataModelVersion) >= 10; } catch (error) { rethrowGitDeadline(error); return false; }
120
120
  })
121
121
  ) {
122
122
  const records = recordsAtRevision(loaded, historical.commit);
@@ -146,7 +146,7 @@ export async function validateWorkflowHistoryIntegrity(loaded, diagnostics) {
146
146
  }
147
147
 
148
148
  const firstV10Commit = commits.find((commit) => {
149
- try { return Number(JSON.parse(getFileAtRevision(loaded.root, commit, "data/workspace.json"))?.dataModelVersion) >= 10; } catch { return false; }
149
+ try { return Number(JSON.parse(getFileAtRevision(loaded.root, commit, "data/workspace.json"))?.dataModelVersion) >= 10; } catch (error) { rethrowGitDeadline(error); return false; }
150
150
  });
151
151
  const protectedRouteSources = new Map();
152
152
  for (const [commit, records] of revisionRecords) {
@@ -307,7 +307,8 @@ function legacyCollectionReviewTransition(loaded, previous, current) {
307
307
  try {
308
308
  const workspace = JSON.parse(getFileAtRevision(loaded.root, current.commit, "data/workspace.json"));
309
309
  return Number(workspace?.dataModelVersion) < 9;
310
- } catch {
310
+ } catch (error) {
311
+ rethrowGitDeadline(error);
311
312
  return false;
312
313
  }
313
314
  }
@@ -319,7 +320,8 @@ function historicalState(loaded) {
319
320
  try {
320
321
  const workspace = JSON.parse(getFileAtRevision(loaded.root, commit, "data/workspace.json"));
321
322
  return Number(workspace?.dataModelVersion) >= 9;
322
- } catch {
323
+ } catch (error) {
324
+ rethrowGitDeadline(error);
323
325
  return false;
324
326
  }
325
327
  });
@@ -392,7 +394,8 @@ function legacyCollectionReview(loaded, historical) {
392
394
  let workspace;
393
395
  try {
394
396
  workspace = JSON.parse(getFileAtRevision(loaded.root, historical.commit, "data/workspace.json"));
395
- } catch {
397
+ } catch (error) {
398
+ rethrowGitDeadline(error);
396
399
  return false;
397
400
  }
398
401
  return Number(workspace?.dataModelVersion) < 9
@@ -408,7 +411,8 @@ function recordAtRevision(loaded, commit, id, identityHistory) {
408
411
  try {
409
412
  const record = JSON.parse(source);
410
413
  return record?.id === id ? { record, path: item.path } : null;
411
- } catch {
414
+ } catch (error) {
415
+ rethrowGitDeadline(error);
412
416
  return null;
413
417
  }
414
418
  }
@@ -454,7 +458,7 @@ async function validateReportingRouteAuthorityHistory(loaded, commits, revisionR
454
458
  ));
455
459
  if (!routeSets.length) return;
456
460
  const firstV10Commit = commits.find((commit) => {
457
- try { return Number(JSON.parse(getFileAtRevision(loaded.root, commit, "data/workspace.json"))?.dataModelVersion) >= 10; } catch { return false; }
461
+ try { return Number(JSON.parse(getFileAtRevision(loaded.root, commit, "data/workspace.json"))?.dataModelVersion) >= 10; } catch (error) { rethrowGitDeadline(error); return false; }
458
462
  });
459
463
  for (const route of routeSets) {
460
464
  const versions = changedVersions(route.id, commits, revisionRecords, historiesById);
@@ -733,7 +737,8 @@ function authorityAppointmentOverlapsRoute(appointment, route, workspaceId, time
733
737
  routeEnd = route.cancellation?.canceledAt
734
738
  ? currentCalendarDate(timezone, new Date(route.cancellation.canceledAt))
735
739
  : null;
736
- } catch {
740
+ } catch (error) {
741
+ rethrowGitDeadline(error);
737
742
  return false;
738
743
  }
739
744
  return (!routeEnd || appointment.startsOn <= routeEnd)
@@ -786,7 +791,8 @@ function historicalWorkspaceTimezoneAtRevision(loaded, commit) {
786
791
  if (typeof workspace?.timezone !== "string" || !workspace.timezone.trim()) return null;
787
792
  new Intl.DateTimeFormat("en", { timeZone: workspace.timezone }).format();
788
793
  return workspace.timezone;
789
- } catch {
794
+ } catch (error) {
795
+ rethrowGitDeadline(error);
790
796
  return null;
791
797
  }
792
798
  }
@@ -901,3 +907,7 @@ function integrityError(entry, message) {
901
907
  message
902
908
  };
903
909
  }
910
+
911
+ function rethrowGitDeadline(error) {
912
+ if (error?.code === "FILEGRC_GIT_DEADLINE") throw error;
913
+ }
package/src/workflow.js CHANGED
@@ -141,7 +141,9 @@ async function assessWorkflowUnmeasured(input, options = {}) {
141
141
  asOf,
142
142
  evaluatedAt,
143
143
  programId: programRecord.type === "program" ? programRecord.id : undefined,
144
- controlIds: options.auditId ? audits[0]?.controlIds : programRecord?.controlIds
144
+ controlIds: options.auditId ? audits[0]?.controlIds : programRecord?.controlIds,
145
+ historyDeadlineAt: options.historyDeadlineAt,
146
+ strictHistory: options.strictHistory
145
147
  });
146
148
  const guidedFindings = [
147
149
  ...programFindings(program),
@@ -732,7 +734,10 @@ async function assessPeriodHealth(loaded, options) {
732
734
  "component"
733
735
  ].includes(record.type));
734
736
  const paths = relevantEntries.map(({ relativePath }) => `data/${relativePath}`);
735
- const histories = getWorkspaceHistories(loaded.root, paths, 50);
737
+ const histories = getWorkspaceHistories(loaded.root, paths, 50, {
738
+ deadlineAt: options.historyDeadlineAt,
739
+ strict: options.strictHistory === true
740
+ });
736
741
  const allHistory = [...histories.values()].flat().filter(Boolean);
737
742
  const earliestCommitDate = allHistory
738
743
  .map(({ timestamp }) => timestamp?.slice(0, 10))
package/src/workspace.js CHANGED
@@ -14,11 +14,13 @@ async function loadWorkspaceUnmeasured(input) {
14
14
  const diagnostics = [];
15
15
  const files = await collectJsonFiles(dataRoot);
16
16
  const resources = [];
17
+ const sourceEntries = [];
17
18
 
18
19
  for (const path of files) {
19
20
  const relativePath = relative(dataRoot, path).split(sep).join("/");
20
21
  try {
21
22
  const source = await readFile(path, "utf8");
23
+ sourceEntries.push({ path, relativePath, source });
22
24
  const record = JSON.parse(source);
23
25
  resources.push({ record, path, relativePath, source });
24
26
  } catch (error) {
@@ -68,6 +70,7 @@ async function loadWorkspaceUnmeasured(input) {
68
70
  model,
69
71
  workspace: workspaceEntry?.record ?? null,
70
72
  entries: resources,
73
+ sourceEntries,
71
74
  resources: resources.map(({ record }) => record),
72
75
  diagnostics
73
76
  };