@abelxiaoxing/cadence 2.0.0 → 2.0.2

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.
@@ -19,6 +19,7 @@ import {
19
19
  text,
20
20
  } from "./contracts/core.ts";
21
21
  import {
22
+ DirectoryWalks,
22
23
  encoded,
23
24
  type FileSnapshot,
24
25
  hash,
@@ -282,8 +283,8 @@ export class ServiceCoordination {
282
283
  },
283
284
  };
284
285
  }
285
- assertNamespace(): void {
286
- if (!this.files.directory(this.directory)) refused();
286
+ assertNamespace(walks?: DirectoryWalks): void {
287
+ if (!this.files.directory(this.directory, false, walks)) refused();
287
288
  names(this.directory, [
288
289
  "format.json",
289
290
  "config.json",
@@ -295,13 +296,18 @@ export class ServiceCoordination {
295
296
  "host-config.json",
296
297
  "history-v2",
297
298
  ]);
298
- this.files.directory(join(this.directory, "history-v2"));
299
+ this.files.directory(join(this.directory, "history-v2"), false, walks);
299
300
  }
300
301
  private selected(): Selected | undefined {
301
- if (!this.files.directory(this.directory)) return undefined;
302
- this.assertNamespace();
303
- const manifest = this.files.read(this.path("format.json"));
304
- const config = this.files.read(this.path("config.json"));
302
+ const walks = new DirectoryWalks();
303
+ if (!this.files.directory(this.directory, false, walks)) return undefined;
304
+ this.assertNamespace(walks);
305
+ const manifest = this.files.read(
306
+ this.path("format.json"),
307
+ undefined,
308
+ walks,
309
+ );
310
+ const config = this.files.read(this.path("config.json"), undefined, walks);
305
311
  if (!manifest || !config) refused();
306
312
  const f = closed(json(manifest), [
307
313
  "schema",
@@ -314,12 +320,24 @@ export class ServiceCoordination {
314
320
  literal(f.stateHome, this.stateHome);
315
321
  literal(f.configIdentity, config.identity);
316
322
  const c = decodeOwnerConfig(json(config));
317
- const hostConfig = this.files.read(this.path("host-config.json"));
323
+ const hostConfig = this.files.read(
324
+ this.path("host-config.json"),
325
+ undefined,
326
+ walks,
327
+ );
318
328
  if (hostConfig && hostConfig.identity !== c.policyIdentity) refused();
319
- const registration = this.files.read(this.path("registration.json"));
320
- const owner = this.files.read(this.path("owner.lock"));
321
- const capability = this.files.read(this.path("local-capability.json"));
322
- const checkout = this.files.read(this.store.lockPath, 8192);
329
+ const registration = this.files.read(
330
+ this.path("registration.json"),
331
+ undefined,
332
+ walks,
333
+ );
334
+ const owner = this.files.read(this.path("owner.lock"), undefined, walks);
335
+ const capability = this.files.read(
336
+ this.path("local-capability.json"),
337
+ undefined,
338
+ walks,
339
+ );
340
+ const checkout = this.files.read(this.store.lockPath, 8192, walks);
323
341
  const value = registration
324
342
  ? decodeRegistration(json(registration))
325
343
  : undefined;
@@ -808,8 +826,9 @@ export class OwnerLease {
808
826
  this.selected.value.host !== hostname()
809
827
  )
810
828
  throw new ContractError("conflict");
811
- const c = this.coordination;
812
- c.assertNamespace();
829
+ const c = this.coordination,
830
+ walks = new DirectoryWalks();
831
+ c.assertNamespace(walks);
813
832
  for (const [name, snapshot] of [
814
833
  ["config.json", this.selected.config],
815
834
  ["host-config.json", this.selected.hostConfig],
@@ -818,8 +837,8 @@ export class OwnerLease {
818
837
  ["owner.lock", this.selected.owner],
819
838
  ["local-capability.json", this.selected.capability],
820
839
  ] as const)
821
- c.files.assert(c.path(name), snapshot);
822
- c.files.assert(c.store.lockPath, this.selected.checkout, 8192);
840
+ c.files.assert(c.path(name), snapshot, undefined, walks);
841
+ c.files.assert(c.store.lockPath, this.selected.checkout, 8192, walks);
823
842
  c.store.assertOwner(this.checkout);
824
843
  }
825
844
  phase(phase: OwnerPhase): void {
@@ -5,14 +5,16 @@ import {
5
5
  fstatSync,
6
6
  fsyncSync,
7
7
  lstatSync,
8
+ mkdirSync,
8
9
  openSync,
10
+ readSync,
9
11
  renameSync,
10
12
  type Stats,
11
13
  unlinkSync,
12
14
  writeFileSync,
13
15
  } from "node:fs";
14
16
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
15
- import { readBounded, safeDirectory } from "../task-store.ts";
17
+ import { retryWindowsFileOperation, safeDirectory } from "../task-store.ts";
16
18
  import { ContractError, parseWireJson } from "./contracts/core.ts";
17
19
 
18
20
  export const METADATA_BYTES = 16 * 1024;
@@ -46,37 +48,128 @@ function same(a: Stats, b: Stats): boolean {
46
48
  function missing(error: unknown): boolean {
47
49
  return error instanceof Error && "code" in error && error.code === "ENOENT";
48
50
  }
51
+ // Advances once per job, at its first microtask checkpoint. Nothing queued
52
+ // retains a scope, so a dropped read budget is collected as usual.
53
+ let job = 0,
54
+ advancing = false;
55
+ function currentJob(): number {
56
+ if (!advancing) {
57
+ advancing = true;
58
+ queueMicrotask(() => {
59
+ job++;
60
+ advancing = false;
61
+ });
62
+ }
63
+ return job;
64
+ }
65
+ /** Directories that ONE synchronous operation already walked. Reuse re-observes
66
+ * the directory itself, no-follow, with the walked dev/ino and private mode: an
67
+ * ancestor substituted for other content resolves elsewhere and walks again. It
68
+ * goes stale at the next microtask checkpoint, so an await or a later call
69
+ * always walks; an ancestor's mode change inside one operation is seen by the
70
+ * next. Absence, refusal and any changed identity fall back to the full walk.
71
+ */
72
+ export class DirectoryWalks {
73
+ private readonly job = currentJob();
74
+ private readonly walked = new Map<string, { dev: number; ino: number }>();
75
+ private open(): boolean {
76
+ if (this.job === job) return true;
77
+ this.walked.clear();
78
+ return false;
79
+ }
80
+ remember(path: string, st: Stats): void {
81
+ if (this.open()) this.walked.set(path, { dev: st.dev, ino: st.ino });
82
+ }
83
+ reuse(path: string): boolean {
84
+ const walked = this.open() ? this.walked.get(path) : undefined;
85
+ if (!walked) return false;
86
+ let st: Stats;
87
+ try {
88
+ st = lstatSync(path);
89
+ } catch (error) {
90
+ if (!missing(error)) throw error;
91
+ this.walked.delete(path);
92
+ return false;
93
+ }
94
+ if (
95
+ !st.isDirectory() ||
96
+ st.isSymbolicLink() ||
97
+ st.dev !== walked.dev ||
98
+ st.ino !== walked.ino
99
+ ) {
100
+ this.walked.delete(path);
101
+ return false;
102
+ }
103
+ privateMode(st);
104
+ return true;
105
+ }
106
+ }
49
107
  /** Strengthens the existing helpers only inside the owned private namespace.
50
108
  * Ambient ancestors retain TaskStore's canonicalization policy. No directory CAS
51
109
  * or hostile-same-user guarantee; failed temporaries are deliberately preserved.
52
110
  */
53
111
  export class PrivateFiles {
54
112
  constructor(readonly namespace: string) {}
55
- directory(path: string, create = false): boolean {
113
+ directory(path: string, create = false, walks?: DirectoryWalks): boolean {
56
114
  const tail = relative(this.namespace, path);
57
115
  if (isAbsolute(tail) || tail === ".." || tail.startsWith(`..${sep}`))
58
116
  refused();
59
117
  if (!create) {
118
+ const full = resolve(path);
119
+ if (walks?.reuse(full)) return true;
60
120
  const namespace = resolve(this.namespace);
61
- let inside = false;
62
- return safeDirectory(path, false, (at, st) => {
121
+ let inside = false,
122
+ last: Stats | undefined;
123
+ const found = safeDirectory(path, false, (at, st) => {
63
124
  // Exact segment boundary, not a textual prefix. One synchronous walk
64
125
  // checks every ambient ancestor and each existing private ancestor,
65
- // including an unsafe parent before a missing child. Nothing is cached.
126
+ // including an unsafe parent before a missing child. Only the caller's
127
+ // DirectoryWalks keeps the result, for that same synchronous operation.
66
128
  if (at === namespace) inside = true;
67
129
  if (inside) privateMode(st);
130
+ last = st;
68
131
  });
132
+ if (found && last) walks?.remember(full, last);
133
+ return found;
69
134
  }
135
+ // Walk the namespace and its ambient ancestors once, then check each
136
+ // private segment exactly once, creating a missing one privately.
137
+ if (!safeDirectory(this.namespace, true)) return false;
138
+ privateMode(lstatSync(this.namespace));
70
139
  let at = this.namespace;
71
- for (const part of ["", ...tail.split(sep).filter(Boolean)]) {
72
- if (part) at = join(at, part);
73
- if (!safeDirectory(at, create)) return false;
74
- privateMode(lstatSync(at));
140
+ for (const part of tail.split(sep).filter(Boolean)) {
141
+ at = join(at, part);
142
+ let st: Stats;
143
+ try {
144
+ st = lstatSync(at);
145
+ } catch (error) {
146
+ if (!missing(error)) throw error;
147
+ try {
148
+ mkdirSync(at, { mode: 0o700 });
149
+ } catch (mkdirError) {
150
+ if (
151
+ !(
152
+ mkdirError instanceof Error &&
153
+ "code" in mkdirError &&
154
+ mkdirError.code === "EEXIST"
155
+ )
156
+ )
157
+ throw mkdirError;
158
+ }
159
+ st = lstatSync(at);
160
+ }
161
+ if (!st.isDirectory() || st.isSymbolicLink())
162
+ throw new Error("Unsafe directory");
163
+ privateMode(st);
75
164
  }
76
165
  return true;
77
166
  }
78
- read(path: string, maximum = METADATA_BYTES): FileSnapshot | undefined {
79
- if (!this.directory(dirname(path))) return undefined;
167
+ read(
168
+ path: string,
169
+ maximum = METADATA_BYTES,
170
+ walks?: DirectoryWalks,
171
+ ): FileSnapshot | undefined {
172
+ if (!this.directory(dirname(path), false, walks)) return undefined;
80
173
  let before: Stats;
81
174
  try {
82
175
  before = lstatSync(path);
@@ -91,15 +184,20 @@ export class PrivateFiles {
91
184
  try {
92
185
  const opened = fstatSync(fd);
93
186
  if (!same(before, opened)) refused();
94
- // Reuse the bounded no-follow reader; surround its read with descriptor and
95
- // selected-path identity checks rather than weakening legacy behavior.
96
- // The caller's independent maximum was already checked above. Bound the
97
- // inner allocation to this exact selected size (+1 growth sentinel); keep
98
- // its full ancestor/open checks and our final descriptor/path checks.
99
- const bytes = readBounded(path, opened.size);
187
+ // Read this already opened, no-follow, identity-checked descriptor: its
188
+ // parents were walked just above, so a second walk and open add nothing.
189
+ // Bound the read to the selected size (+1 growth sentinel) and keep the
190
+ // final descriptor/path identity checks.
191
+ const buffer = Buffer.alloc(opened.size + 1);
192
+ let size = 0;
193
+ while (size < buffer.length) {
194
+ const n = readSync(fd, buffer, size, buffer.length - size, null);
195
+ if (!n) break;
196
+ size += n;
197
+ }
198
+ const bytes = buffer.subarray(0, size);
100
199
  if (
101
- !bytes ||
102
- bytes.length !== opened.size ||
200
+ size !== opened.size ||
103
201
  !same(opened, fstatSync(fd)) ||
104
202
  !same(opened, lstatSync(path))
105
203
  )
@@ -113,8 +211,9 @@ export class PrivateFiles {
113
211
  path: string,
114
212
  expected: FileSnapshot | undefined,
115
213
  maximum = METADATA_BYTES,
214
+ walks?: DirectoryWalks,
116
215
  ): void {
117
- const current = this.read(path, maximum);
216
+ const current = this.read(path, maximum, walks);
118
217
  if (
119
218
  current?.identity !== expected?.identity ||
120
219
  current?.stamp !== expected?.stamp
@@ -124,7 +223,7 @@ export class PrivateFiles {
124
223
  create(path: string, bytes: string, maximum = METADATA_BYTES): FileSnapshot {
125
224
  if (Buffer.byteLength(bytes) > maximum) throw new ContractError("oversize");
126
225
  this.directory(dirname(path), true);
127
- const fd = openSync(path, "wx", 0o600);
226
+ const fd = retryWindowsFileOperation(() => openSync(path, "wx", 0o600));
128
227
  try {
129
228
  writeFileSync(fd, bytes);
130
229
  fsyncSync(fd);
@@ -146,7 +245,10 @@ export class PrivateFiles {
146
245
  const temporary = join(dirname(path), `pending-${randomUUID()}.json`);
147
246
  this.create(temporary, bytes, maximum);
148
247
  this.assert(path, expected, maximum);
149
- renameSync(temporary, path);
248
+ retryWindowsFileOperation(
249
+ () => renameSync(temporary, path),
250
+ () => this.assert(path, expected, maximum),
251
+ );
150
252
  this.syncDirectory(dirname(path));
151
253
  const result = this.read(path, maximum);
152
254
  if (!result || result.identity !== hash(bytes)) refused();
@@ -154,7 +256,10 @@ export class PrivateFiles {
154
256
  }
155
257
  remove(path: string, expected: FileSnapshot, maximum = METADATA_BYTES): void {
156
258
  this.assert(path, expected, maximum);
157
- unlinkSync(path);
259
+ retryWindowsFileOperation(
260
+ () => unlinkSync(path),
261
+ () => this.assert(path, expected, maximum),
262
+ );
158
263
  this.syncDirectory(dirname(path));
159
264
  }
160
265
  private syncDirectory(path: string): void {