knodin 0.12.2 → 0.13.1

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 (58) hide show
  1. package/README.md +16 -1
  2. package/dist/bin/cli.js +245 -18
  3. package/dist/src/agent-events.js +25 -7
  4. package/dist/src/authenticated-cursor.js +81 -0
  5. package/dist/src/backup-retention.js +345 -12
  6. package/dist/src/class-consumer-contract.js +18 -0
  7. package/dist/src/class-consumer-cursor.js +91 -0
  8. package/dist/src/class-consumer-delivery.js +22 -0
  9. package/dist/src/class-consumer-page.js +148 -0
  10. package/dist/src/cli-model.js +21 -3
  11. package/dist/src/docs-sections.js +1 -0
  12. package/dist/src/engine/apex-class-uses.js +430 -0
  13. package/dist/src/engine/apex-entry-points.js +98 -0
  14. package/dist/src/engine/apex-receiver.js +301 -0
  15. package/dist/src/engine/candidate-database.js +624 -66
  16. package/dist/src/engine/embedding-reuse.js +57 -0
  17. package/dist/src/engine/embeddings.js +22 -0
  18. package/dist/src/engine/index-coverage.js +215 -0
  19. package/dist/src/engine/index.js +3854 -448
  20. package/dist/src/engine/salesforce-components.js +460 -0
  21. package/dist/src/engine/seal.js +3 -0
  22. package/dist/src/engine/sqlite.js +44 -0
  23. package/dist/src/engine/state-paths.js +192 -31
  24. package/dist/src/evidence-bundle.js +283 -0
  25. package/dist/src/evidence-graph.js +163 -0
  26. package/dist/src/failure-diagnosis.js +80 -5
  27. package/dist/src/file-dependency.js +35 -0
  28. package/dist/src/graph-query-health.js +47 -1
  29. package/dist/src/implementation-search.js +69 -0
  30. package/dist/src/index-coverage-read.js +33 -0
  31. package/dist/src/investigation.js +195 -0
  32. package/dist/src/manager-update.js +9 -10
  33. package/dist/src/mcp-reliability.js +4 -0
  34. package/dist/src/mcp-worker-supervisor.js +122 -6
  35. package/dist/src/mirror.js +2 -0
  36. package/dist/src/progressive-evidence.js +4 -4
  37. package/dist/src/response-budget.js +129 -3
  38. package/dist/src/server.js +18 -5
  39. package/dist/src/shared-index/publisher.js +41 -1
  40. package/dist/src/shared-index/restore.js +16 -6
  41. package/dist/src/storage-budget.js +146 -0
  42. package/dist/src/storage-inventory.js +263 -0
  43. package/dist/src/storage-management.js +204 -0
  44. package/dist/src/storage-policy-contract.js +121 -0
  45. package/dist/src/tools/knodin-tools.js +224 -41
  46. package/dist/src/worktree-seed.js +7 -1
  47. package/docs/BACKUP-RETENTION.md +46 -2
  48. package/docs/CLI.md +98 -2
  49. package/docs/MCP.md +75 -0
  50. package/docs/PROGRESSIVE-EVIDENCE.md +62 -0
  51. package/docs/SALESFORCE-BINDINGS.md +121 -0
  52. package/docs/SALESFORCE-DEAD-CODE.md +45 -0
  53. package/docs/SCOPED-INDEXING.md +76 -0
  54. package/docs/apex-receiver-resolution.md +41 -0
  55. package/docs/releases/0.13.0.md +62 -0
  56. package/docs/releases/0.13.1.md +216 -0
  57. package/docs/structural-only-indexing.md +20 -0
  58. package/package.json +12 -4
@@ -0,0 +1,263 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { listBackups } from "./backup-retention.js";
4
+ import { resolveDbPath, resolveStateDir } from "./engine/state-paths.js";
5
+ import { measureDatabaseFamily } from "./storage-budget.js";
6
+ const EXACT_BACKUP = /^db\.sqlite\.backup\.\d{13}\.[0-9a-f]{8}$/;
7
+ function category() {
8
+ return {
9
+ count: 0,
10
+ fileCount: 0,
11
+ apparentBytes: 0,
12
+ allocatedBytes: 0,
13
+ measuredApparentBytes: 0,
14
+ truncated: false,
15
+ errors: [],
16
+ };
17
+ }
18
+ function unknown(target, error, truncated = false) {
19
+ target.apparentBytes = null;
20
+ target.allocatedBytes = null;
21
+ target.errors.push(error);
22
+ target.truncated ||= truncated;
23
+ }
24
+ function sum(left, right) {
25
+ if (left === null || right === null)
26
+ return null;
27
+ const total = left + right;
28
+ return Number.isSafeInteger(total) && total >= 0 ? total : null;
29
+ }
30
+ function regularMetadata(target, root) {
31
+ const stat = fs.lstatSync(target);
32
+ if (!stat.isFile() || stat.isSymbolicLink() || path.dirname(fs.realpathSync(target)) !== root)
33
+ throw new Error(`unsafe/nonregular file: ${target}`);
34
+ return {
35
+ path: target,
36
+ apparentBytes: Number.isSafeInteger(stat.size) && stat.size >= 0 ? stat.size : null,
37
+ allocatedBytes: Number.isSafeInteger(stat.blocks) &&
38
+ stat.blocks >= 0 &&
39
+ Number.isSafeInteger(stat.blocks * 512)
40
+ ? stat.blocks * 512
41
+ : null,
42
+ modifiedAtMs: stat.mtimeMs,
43
+ };
44
+ }
45
+ function add(target, file) {
46
+ target.fileCount++;
47
+ target.apparentBytes = sum(target.apparentBytes, file.apparentBytes);
48
+ target.allocatedBytes = sum(target.allocatedBytes, file.allocatedBytes);
49
+ target.measuredApparentBytes =
50
+ sum(target.measuredApparentBytes, file.apparentBytes) ?? target.measuredApparentBytes;
51
+ if (file.apparentBytes === null || file.allocatedBytes === null)
52
+ target.errors.push(`unknown size/allocation: ${file.path}`);
53
+ }
54
+ function safeDirectory(target, parent) {
55
+ const stat = fs.lstatSync(target);
56
+ if (!stat.isDirectory() || stat.isSymbolicLink())
57
+ throw new Error(`unsafe/non-directory: ${target}`);
58
+ const real = fs.realpathSync(target);
59
+ if (parent && path.dirname(real) !== parent)
60
+ throw new Error(`directory escapes inventory root: ${target}`);
61
+ return real;
62
+ }
63
+ function boundedEntries(directory, limit) {
64
+ const stream = fs.opendirSync(directory);
65
+ const entries = [];
66
+ try {
67
+ for (let index = 0; index < limit; index++) {
68
+ const entry = stream.readSync();
69
+ if (!entry)
70
+ return { entries, truncated: false };
71
+ entries.push(entry);
72
+ }
73
+ return { entries, truncated: stream.readSync() !== null };
74
+ }
75
+ finally {
76
+ stream.closeSync();
77
+ }
78
+ }
79
+ /** Repository-local metadata only. Never indexes, hashes, follows links, creates or deletes files. */
80
+ export function inventoryRepositoryStorage(repository, dependencies = {}) {
81
+ const requestedRepository = path.resolve(repository);
82
+ let repo = requestedRepository;
83
+ try {
84
+ repo = fs.realpathSync(repo);
85
+ }
86
+ catch {
87
+ /* Missing roots retain unknown metadata results. */
88
+ }
89
+ const state = resolveStateDir(repo);
90
+ const categories = {
91
+ active: category(),
92
+ backups: category(),
93
+ candidates: category(),
94
+ cacheAndOther: category(),
95
+ };
96
+ const files = [];
97
+ const candidates = [];
98
+ const result = {
99
+ schemaVersion: 1,
100
+ repository: requestedRepository,
101
+ stateDirectory: state,
102
+ scope: state === path.join(repo, ".knodin")
103
+ ? "repository-local"
104
+ : "mirror-state",
105
+ categories,
106
+ files,
107
+ candidates,
108
+ outOfScope: {
109
+ classification: "external-not-backups",
110
+ mirrors: null,
111
+ harnessDirectories: null,
112
+ count: null,
113
+ allocatedBytes: null,
114
+ reason: "External mirrors, harnesses, sibling projects and worktrees are not scanned; this is not a whole-system count.",
115
+ },
116
+ };
117
+ let root;
118
+ try {
119
+ root = safeDirectory(state);
120
+ }
121
+ catch (error) {
122
+ for (const target of Object.values(categories))
123
+ unknown(target, String(error));
124
+ return result;
125
+ }
126
+ try {
127
+ const measured = (dependencies.measureFamily ?? measureDatabaseFamily)(state, resolveDbPath(repo));
128
+ Object.assign(categories.active, {
129
+ count: measured.fileCount > 0 ? 1 : 0,
130
+ fileCount: measured.fileCount,
131
+ apparentBytes: measured.apparentBytes,
132
+ allocatedBytes: measured.allocatedBytes,
133
+ measuredApparentBytes: measured.apparentBytes,
134
+ });
135
+ if (measured.allocatedBytes === null)
136
+ categories.active.errors.push("active database allocation unavailable");
137
+ }
138
+ catch (error) {
139
+ unknown(categories.active, String(error));
140
+ }
141
+ let stateEntries;
142
+ try {
143
+ stateEntries = boundedEntries(state, 1024);
144
+ }
145
+ catch (error) {
146
+ for (const target of [categories.backups, categories.candidates, categories.cacheAndOther])
147
+ unknown(target, String(error));
148
+ return result;
149
+ }
150
+ if (stateEntries.truncated) {
151
+ unknown(categories.backups, "state directory exceeds 1024-entry inventory bound; backup listing skipped", true);
152
+ unknown(categories.cacheAndOther, "state directory exceeds 1024-entry inventory bound", true);
153
+ unknown(categories.candidates, "candidate root may be outside bounded state entries", true);
154
+ }
155
+ else {
156
+ try {
157
+ if (state !== path.join(repo, ".knodin")) {
158
+ for (const entry of stateEntries.entries) {
159
+ if (!EXACT_BACKUP.test(entry.name))
160
+ continue;
161
+ try {
162
+ add(categories.backups, regularMetadata(path.join(state, entry.name), root));
163
+ categories.backups.count++;
164
+ }
165
+ catch (error) {
166
+ unknown(categories.backups, String(error));
167
+ }
168
+ }
169
+ }
170
+ else {
171
+ const backups = (dependencies.listBackups ?? listBackups)([repo], {
172
+ depth: 0,
173
+ includeRegistry: false,
174
+ });
175
+ categories.backups.count = backups.count;
176
+ for (const backup of backups.backups)
177
+ add(categories.backups, {
178
+ path: backup.path,
179
+ apparentBytes: backup.bytes,
180
+ allocatedBytes: backup.allocatedBytes,
181
+ modifiedAtMs: backup.modifiedAtMs,
182
+ });
183
+ for (const skipped of backups.skipped)
184
+ unknown(categories.backups, `${skipped.reason}: ${skipped.path ?? skipped.repository}`);
185
+ }
186
+ }
187
+ catch (error) {
188
+ unknown(categories.backups, String(error));
189
+ }
190
+ }
191
+ for (const entry of stateEntries.entries) {
192
+ if (["db.sqlite", "db.sqlite-wal", "db.sqlite-shm"].includes(entry.name) ||
193
+ EXACT_BACKUP.test(entry.name))
194
+ continue;
195
+ const target = path.join(state, entry.name);
196
+ if (entry.name === "candidates") {
197
+ try {
198
+ const candidateRoot = safeDirectory(target, root);
199
+ const listed = boundedEntries(target, 256);
200
+ if (listed.truncated)
201
+ unknown(categories.candidates, "candidate directory exceeds 256-entry bound", true);
202
+ for (const candidate of listed.entries) {
203
+ const candidatePath = path.join(target, candidate.name);
204
+ try {
205
+ if (!/^candidate-[A-Za-z0-9_-]+$/.test(candidate.name))
206
+ throw new Error(`unexpected candidate entry: ${candidatePath}`);
207
+ const candidateDirectory = safeDirectory(candidatePath, candidateRoot);
208
+ categories.candidates.count++;
209
+ const record = { path: candidatePath, files: [] };
210
+ candidates.push(record);
211
+ // At most five named metadata probes; never traverse candidate contents.
212
+ for (const name of [
213
+ "db.sqlite",
214
+ "db.sqlite-wal",
215
+ "db.sqlite-shm",
216
+ "status-audit-v1.json",
217
+ "candidate-owner.json",
218
+ ]) {
219
+ try {
220
+ const file = regularMetadata(path.join(candidatePath, name), candidateDirectory);
221
+ add(categories.candidates, file);
222
+ record.files.push(file);
223
+ }
224
+ catch (error) {
225
+ if (error.code !== "ENOENT")
226
+ unknown(categories.candidates, String(error));
227
+ }
228
+ }
229
+ const contents = boundedEntries(candidatePath, 5);
230
+ if (contents.truncated ||
231
+ contents.entries.some((child) => ![
232
+ "db.sqlite",
233
+ "db.sqlite-wal",
234
+ "db.sqlite-shm",
235
+ "status-audit-v1.json",
236
+ "candidate-owner.json",
237
+ ].includes(child.name)))
238
+ unknown(categories.candidates, `unmeasured unexpected candidate contents: ${candidatePath}`, contents.truncated);
239
+ }
240
+ catch (error) {
241
+ unknown(categories.candidates, String(error));
242
+ }
243
+ }
244
+ }
245
+ catch (error) {
246
+ unknown(categories.candidates, String(error));
247
+ }
248
+ continue;
249
+ }
250
+ categories.cacheAndOther.count++;
251
+ try {
252
+ const file = regularMetadata(target, root);
253
+ add(categories.cacheAndOther, file);
254
+ files.push(file);
255
+ }
256
+ catch (error) {
257
+ unknown(categories.cacheAndOther, `nested, unsafe or unmeasured entry (not a backup): ${target}: ${String(error)}`);
258
+ }
259
+ }
260
+ return result;
261
+ }
262
+ /** Compact CLI-compatible name for the same repository-bounded inventory. */
263
+ export const inventoryStorage = inventoryRepositoryStorage;
@@ -0,0 +1,204 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { maintainPromotionBackups, pruneBackups, retentionAppliesToRepository, retentionStatus, } from "./backup-retention.js";
5
+ import { acquireRepairLease } from "./repair-lease.js";
6
+ import { assertStoragePolicyWritable, DEFAULT_STORAGE_POLICY as DEFAULT_POLICY, readLocalStoragePolicy as localPolicy, STORAGE_POLICY_NAME as POLICY_NAME, storagePolicyStateDirectory as stateDirectory, validateStoragePolicy as validatePolicy, } from "./storage-policy-contract.js";
7
+ /** Read only. Legacy stores remain unmanaged; malformed/unsafe policy fails closed. */
8
+ export function resolveStoragePolicy(repository, options = {}) {
9
+ const local = localPolicy(repository);
10
+ if (local)
11
+ return local;
12
+ const installed = retentionStatus(options);
13
+ if (installed.status === "attention-required")
14
+ throw new Error(`storage-policy: unhealthy global retention (${installed.issues.join(", ")})`);
15
+ const global = installed.policy;
16
+ if (!global || installed.status !== "healthy")
17
+ return null;
18
+ const applies = retentionAppliesToRepository(repository, global);
19
+ // Existing age-only consent is not consent for aggressive count/byte eviction.
20
+ if (!applies || global.maxCount === undefined || global.maxAllocatedBytes === undefined)
21
+ return null;
22
+ return validatePolicy({
23
+ ...DEFAULT_POLICY,
24
+ dryRun: global.dryRun,
25
+ retentionDays: global.retentionDays,
26
+ keepNewest: Math.max(1, global.keepNewest),
27
+ maxCount: global.maxCount,
28
+ maxAllocatedBytes: global.maxAllocatedBytes,
29
+ });
30
+ }
31
+ function writePolicy(repository, policy, overwrite) {
32
+ assertStoragePolicyWritable(repository);
33
+ const state = stateDirectory(repository, true);
34
+ fs.mkdirSync(state, { mode: 0o700, recursive: true });
35
+ stateDirectory(repository);
36
+ const before = fs.lstatSync(state);
37
+ const target = path.join(state, POLICY_NAME);
38
+ const temporary = path.join(state, `.${POLICY_NAME}.${crypto.randomUUID()}.tmp`);
39
+ try {
40
+ const descriptor = fs.openSync(temporary, "wx", 0o600);
41
+ try {
42
+ fs.writeFileSync(descriptor, `${JSON.stringify(policy)}\n`);
43
+ fs.fsyncSync(descriptor);
44
+ }
45
+ finally {
46
+ fs.closeSync(descriptor);
47
+ }
48
+ const current = fs.lstatSync(state);
49
+ if (current.isSymbolicLink() || current.dev !== before.dev || current.ino !== before.ino)
50
+ throw new Error("storage-policy: state directory changed during write");
51
+ // Re-read before an explicitly consented replacement; malformed or symlink
52
+ // policies must not silently become defaults even on an apply request.
53
+ if (overwrite) {
54
+ localPolicy(repository);
55
+ fs.renameSync(temporary, target);
56
+ }
57
+ else
58
+ fs.linkSync(temporary, target); // atomic no-overwrite publication
59
+ try {
60
+ const directory = fs.openSync(state, "r");
61
+ try {
62
+ fs.fsyncSync(directory);
63
+ }
64
+ finally {
65
+ fs.closeSync(directory);
66
+ }
67
+ }
68
+ catch (error) {
69
+ // Windows cannot open/fsync directories on every supported filesystem.
70
+ // Keep actual I/O failures visible; the policy file itself is already synced.
71
+ if (process.platform !== "win32" ||
72
+ !["EPERM", "EINVAL", "ENOTSUP", "EISDIR"].includes(error.code ?? ""))
73
+ throw error;
74
+ }
75
+ }
76
+ finally {
77
+ try {
78
+ fs.unlinkSync(temporary);
79
+ }
80
+ catch {
81
+ // Temporary residue must not mask the original error or turn an
82
+ // already published policy into a failed transaction.
83
+ }
84
+ }
85
+ }
86
+ /** Caller must know this was a first successful index/promotion, never a legacy upgrade. */
87
+ export function initializeStoragePolicy(repository, options = {}) {
88
+ assertStoragePolicyWritable(repository);
89
+ const existing = resolveStoragePolicy(repository, options);
90
+ if (existing)
91
+ return { status: "existing", policy: existing };
92
+ const global = retentionStatus(options).policy;
93
+ // New stores get bounded defaults, but an applicable older explicit global
94
+ // dry-run is still a user's safety preference, not permission to apply.
95
+ const policy = {
96
+ ...DEFAULT_POLICY,
97
+ dryRun: Boolean(global?.dryRun && retentionAppliesToRepository(repository, global)),
98
+ };
99
+ writePolicy(repository, policy, false);
100
+ return { status: "initialized", policy };
101
+ }
102
+ export function configureStoragePolicy(repository, options, globalOptions = {}) {
103
+ if (options.apply) {
104
+ assertStoragePolicyWritable(repository);
105
+ // Reject unsafe state routing before the lease writes coordination metadata.
106
+ // The policy is resolved again under the lease before publication.
107
+ resolveStoragePolicy(repository, globalOptions);
108
+ const lease = acquireRepairLease(repository, "storage-policy-configuration");
109
+ try {
110
+ return configureStoragePolicyUnderLease(repository, options, globalOptions);
111
+ }
112
+ finally {
113
+ lease.release();
114
+ }
115
+ }
116
+ return configureStoragePolicyUnderLease(repository, options, globalOptions);
117
+ }
118
+ function configureStoragePolicyUnderLease(repository, options, globalOptions) {
119
+ const current = resolveStoragePolicy(repository, globalOptions);
120
+ const { apply, ...changes } = options;
121
+ const global = retentionStatus(globalOptions).policy;
122
+ const defaults = {
123
+ ...DEFAULT_POLICY,
124
+ dryRun: Boolean(global?.dryRun && retentionAppliesToRepository(repository, global)),
125
+ };
126
+ const policy = validatePolicy({ ...(current ?? defaults), ...changes, schemaVersion: 1 });
127
+ const classification = current ? "managed" : "legacy-unmanaged";
128
+ if (!apply)
129
+ return { status: "preview", classification, consentNeeded: true, current, policy };
130
+ writePolicy(repository, policy, true);
131
+ return { status: "configured", classification, consentNeeded: false, current, policy };
132
+ }
133
+ /** Call after releasing the lifecycle lease. Cleanup cannot invalidate durable promotion. */
134
+ export function postOperationStorageMaintenance(repository, options = {}) {
135
+ try {
136
+ assertStoragePolicyWritable(repository);
137
+ const policy = resolveStoragePolicy(repository, options);
138
+ if (!policy)
139
+ return {
140
+ status: "legacy-preview",
141
+ warnings: [
142
+ "Storage is unmanaged; run `knodin backups policy preview <repo>`, then explicitly consent with `knodin backups policy apply <repo>` before automatic cleanup.",
143
+ ],
144
+ policy: null,
145
+ };
146
+ const result = maintainPromotionBackups(repository, policy, (repo) => resolveStoragePolicy(repo, options));
147
+ const overQuota = "result" in result && result.result?.quotas.some((quota) => quota.overQuota !== false);
148
+ const cleanupWarnings = "result" in result && result.result
149
+ ? result.result.skipped.map((entry) => entry.reason)
150
+ : "reason" in result
151
+ ? [result.reason]
152
+ : [];
153
+ return {
154
+ ...result,
155
+ policy,
156
+ warnings: [
157
+ ...cleanupWarnings,
158
+ ...(overQuota
159
+ ? [
160
+ "Protected or unmeasured storage remains over budget; refuse unsafe future allocation until space is established.",
161
+ ]
162
+ : []),
163
+ ],
164
+ };
165
+ }
166
+ catch (error) {
167
+ return {
168
+ status: "failed",
169
+ warnings: [error instanceof Error ? error.message : String(error)],
170
+ policy: null,
171
+ };
172
+ }
173
+ }
174
+ /** Read-only allocation fence: protected/unknown quotas cannot be hand-waved away. */
175
+ export function assertManagedStorageAllocation(repository, options = {}) {
176
+ const policy = resolveStoragePolicy(repository, options);
177
+ if (!policy?.enabled)
178
+ return policy;
179
+ // A safely absent state directory is a known-empty first store, not a
180
+ // successful empty discovery of an existing (possibly external) store.
181
+ try {
182
+ fs.lstatSync(stateDirectory(repository, true));
183
+ }
184
+ catch (error) {
185
+ if (error.code === "ENOENT")
186
+ return policy;
187
+ throw error;
188
+ }
189
+ const preview = pruneBackups([fs.realpathSync(repository)], {
190
+ depth: 0,
191
+ includeRegistry: false,
192
+ apply: false,
193
+ protectRecoverable: true,
194
+ retentionDays: policy.retentionDays,
195
+ keepNewest: policy.keepNewest,
196
+ maxCount: policy.maxCount,
197
+ maxAllocatedBytes: policy.maxAllocatedBytes,
198
+ });
199
+ if (preview.quotas.some((quota) => quota.overQuota !== false) ||
200
+ preview.skipped.length > 0 ||
201
+ preview.quotas.length !== 1)
202
+ throw new Error("knodin storage: protected backup storage exceeds its budget or allocated bytes are unknown; preview `knodin backups prune <repo>` and explicitly resolve the quota before allocating another candidate. Active and recoverable databases were preserved.");
203
+ return policy;
204
+ }
@@ -0,0 +1,121 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { lookupMirror, resolveStateDir } from "./engine/state-paths.js";
4
+ export const MANAGED_BACKUP_DEFAULTS = Object.freeze({
5
+ enabled: true,
6
+ dryRun: false,
7
+ retentionDays: 3,
8
+ keepNewest: 1,
9
+ maxCount: 1,
10
+ maxAllocatedBytes: 1_073_741_824,
11
+ minFreeBytes: 536_870_912,
12
+ });
13
+ export const STORAGE_POLICY_NAME = "storage-policy.json";
14
+ export const DEFAULT_STORAGE_POLICY = {
15
+ schemaVersion: 1,
16
+ ...MANAGED_BACKUP_DEFAULTS,
17
+ };
18
+ const KEYS = [
19
+ "schemaVersion",
20
+ "enabled",
21
+ "dryRun",
22
+ "retentionDays",
23
+ "keepNewest",
24
+ "maxCount",
25
+ "maxAllocatedBytes",
26
+ "minFreeBytes",
27
+ ];
28
+ function missing(error) {
29
+ return error.code === "ENOENT";
30
+ }
31
+ function policyRepository(repository) {
32
+ const canonical = fs.realpathSync(repository);
33
+ const mirror = lookupMirror(repository);
34
+ return { path: mirror?.path ?? canonical, mirror: Boolean(mirror) };
35
+ }
36
+ export function storagePolicyStateDirectory(repository, allowMissing = false) {
37
+ const repo = fs.realpathSync(repository);
38
+ if (!fs.lstatSync(repo).isDirectory())
39
+ throw new Error("storage-policy: repository is not a directory");
40
+ const state = resolveStateDir(policyRepository(repository).path);
41
+ try {
42
+ const stat = fs.lstatSync(state);
43
+ if (!stat.isDirectory() || stat.isSymbolicLink())
44
+ throw new Error("storage-policy: unsafe state directory");
45
+ }
46
+ catch (error) {
47
+ if (!allowMissing || !missing(error))
48
+ throw error;
49
+ }
50
+ return state;
51
+ }
52
+ export function assertStoragePolicyWritable(repository) {
53
+ if (policyRepository(repository).mirror)
54
+ throw new Error("storage-policy: mirror repositories are read-only");
55
+ }
56
+ export function validateStoragePolicy(value) {
57
+ if (!value || typeof value !== "object" || Array.isArray(value))
58
+ throw new Error("storage-policy: malformed policy");
59
+ const record = value;
60
+ if (Object.keys(record).length !== KEYS.length ||
61
+ Object.keys(record).some((key) => !KEYS.includes(key)) ||
62
+ record.schemaVersion !== 1 ||
63
+ typeof record.enabled !== "boolean" ||
64
+ typeof record.dryRun !== "boolean")
65
+ throw new Error("storage-policy: invalid policy schema");
66
+ for (const key of [
67
+ "retentionDays",
68
+ "keepNewest",
69
+ "maxCount",
70
+ "maxAllocatedBytes",
71
+ "minFreeBytes",
72
+ ])
73
+ if (!Number.isSafeInteger(record[key]) || record[key] < 0)
74
+ throw new Error(`storage-policy: invalid ${key}`);
75
+ if (record.retentionDays > 3650 ||
76
+ record.keepNewest < 1 ||
77
+ record.keepNewest > 1000)
78
+ throw new Error("storage-policy: invalid retention bounds");
79
+ return record;
80
+ }
81
+ /** Shared bounded, no-follow parser; importing this module has no cleanup side effects. */
82
+ export function readLocalStoragePolicy(repository) {
83
+ const target = path.join(storagePolicyStateDirectory(repository, true), STORAGE_POLICY_NAME);
84
+ let stat;
85
+ try {
86
+ stat = fs.lstatSync(target);
87
+ }
88
+ catch (error) {
89
+ if (missing(error))
90
+ return null;
91
+ throw error;
92
+ }
93
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size <= 0 || stat.size > 16_384)
94
+ throw new Error("storage-policy: unsafe policy file");
95
+ const descriptor = fs.openSync(target, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
96
+ try {
97
+ const opened = fs.fstatSync(descriptor);
98
+ if (opened.dev !== stat.dev || opened.ino !== stat.ino || opened.size !== stat.size)
99
+ throw new Error("storage-policy: policy changed during read");
100
+ const buffer = Buffer.alloc(stat.size + 1);
101
+ const read = fs.readSync(descriptor, buffer, 0, buffer.length, 0);
102
+ const final = fs.fstatSync(descriptor);
103
+ const current = fs.lstatSync(target);
104
+ if (read !== stat.size ||
105
+ final.size !== stat.size ||
106
+ final.mtimeMs !== stat.mtimeMs ||
107
+ final.ctimeMs !== stat.ctimeMs ||
108
+ !current.isFile() ||
109
+ current.isSymbolicLink() ||
110
+ current.dev !== stat.dev ||
111
+ current.ino !== stat.ino ||
112
+ current.size !== stat.size ||
113
+ current.mtimeMs !== stat.mtimeMs ||
114
+ current.ctimeMs !== stat.ctimeMs)
115
+ throw new Error("storage-policy: policy changed during read");
116
+ return validateStoragePolicy(JSON.parse(buffer.subarray(0, read).toString("utf8")));
117
+ }
118
+ finally {
119
+ fs.closeSync(descriptor);
120
+ }
121
+ }