@takosjp/yurucommu-core 3.4.4 → 3.4.5

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.
@@ -17,6 +17,9 @@ import type {
17
17
  RunResult,
18
18
  StorageObject,
19
19
  } from "./types.ts";
20
+ import { constants as fsConstants } from "node:fs";
21
+ import type { FileHandle } from "node:fs/promises";
22
+ import { createHash } from "node:crypto";
20
23
  import {
21
24
  assertPathChainWithinBasePath,
22
25
  isPathWithinBasePath,
@@ -34,40 +37,222 @@ declare const require: (specifier: string) => unknown;
34
37
  // Re-export MemoryKV as it works in Bun too.
35
38
  export { MemoryKV };
36
39
 
37
- const { mkdir, unlink, readdir, stat, realpath } = await import("fs/promises");
40
+ const { mkdir, unlink, readdir, stat, lstat, realpath, open, rename, utimes } =
41
+ await import("fs/promises");
42
+
43
+ // Generation state is deliberately kept outside the public object namespace.
44
+ // The sibling directory name is derived from the storage directory, so a
45
+ // legacy object literally named `.yurucommu-objects` (or any descendant) is
46
+ // still a valid user key under the public root.
47
+ const INTERNAL_STORE_ROOT = ".yurucommu-objects";
48
+ const COMMIT_FILE = "commit.json";
49
+ const GENERATION_MARKER = "generation-";
50
+ const TEMP_MARKER = "tmp-";
51
+ const LEASE_SUFFIX = "lease";
52
+ const READER_LEASE_MARKER = "reader-";
53
+ const TEMP_LEASE_TTL_MS = 30_000;
54
+ const TEMP_LEASE_HEARTBEAT_MS = 1_000;
55
+ const MARKER_READ_ATTEMPTS = 32;
56
+ const OBJECT_RESOLVE_ATTEMPTS = 64;
57
+ const OPEN_READ_FLAGS = fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW;
58
+ const GENERATION_ID_PATTERN = /^[0-9a-f-]{16,}$/u;
59
+ const DIGEST_PATTERN = /^[0-9a-f]{64}$/u;
60
+
61
+ type CommitState =
62
+ | { version: 1; state: "committed"; generation: string }
63
+ | { version: 1; state: "deleted"; generation: null };
64
+
65
+ type CommitRecord = CommitState & { key: string; keyHash: string };
66
+
67
+ type ResolvedObject = {
68
+ filePath: string;
69
+ bodyHandle?: FileHandle;
70
+ metadata: {
71
+ httpMetadata?: ObjectMetadata["httpMetadata"];
72
+ customMetadata?: Record<string, string>;
73
+ };
74
+ releaseLease?: () => Promise<void>;
75
+ };
38
76
 
39
- /**
40
- * Drain a ReadableStream into a single Uint8Array.
41
- */
42
- async function drainStream(
77
+ type LiveLeaseState = {
78
+ generations: Set<string>;
79
+ reader: boolean;
80
+ };
81
+
82
+ type FileIdentity = {
83
+ dev: number;
84
+ ino: number;
85
+ nlink: number;
86
+ mode: number;
87
+ };
88
+
89
+ function fileIdentity(stats: FileIdentity): FileIdentity {
90
+ return {
91
+ dev: stats.dev,
92
+ ino: stats.ino,
93
+ nlink: stats.nlink,
94
+ mode: stats.mode,
95
+ };
96
+ }
97
+
98
+ function isSameFileIdentity(
99
+ expected: FileIdentity,
100
+ actual: FileIdentity,
101
+ ): boolean {
102
+ return (
103
+ expected.dev === actual.dev &&
104
+ expected.ino === actual.ino &&
105
+ // A concurrent atomic replacement may unlink the just-opened inode
106
+ // between lstat() and fstat(); POSIX then reports nlink=0 on the still
107
+ // valid descriptor. It is safe to accept that transition because the
108
+ // pre-open lstat required nlink===1 and dev/ino/type still match. Any
109
+ // other link count indicates a hardlink or inode substitution.
110
+ (expected.nlink === actual.nlink ||
111
+ actual.nlink === 0 ||
112
+ (expected.nlink === 0 && actual.nlink === 1)) &&
113
+ expected.mode === actual.mode
114
+ );
115
+ }
116
+
117
+ function isInternalStorageName(name: string): boolean {
118
+ return name.endsWith(".meta.json");
119
+ }
120
+
121
+ function keyHash(key: string): string {
122
+ return createHash("sha256").update(key, "utf8").digest("hex");
123
+ }
124
+
125
+ function isNotFoundError(error: unknown): boolean {
126
+ return (
127
+ typeof error === "object" &&
128
+ error !== null &&
129
+ "code" in error &&
130
+ (error as { code?: unknown }).code === "ENOENT"
131
+ );
132
+ }
133
+
134
+ function isFileIdentityRace(error: unknown): boolean {
135
+ return (
136
+ error instanceof Error &&
137
+ error.message === "BunStorage file changed while opening"
138
+ );
139
+ }
140
+
141
+ function yieldForFilesystem(): Promise<void> {
142
+ return new Promise((resolve) => setTimeout(resolve, 0));
143
+ }
144
+
145
+ export async function writeBufferFully(
146
+ handle: FileHandle,
147
+ buffer: Uint8Array,
148
+ ): Promise<void> {
149
+ let offset = 0;
150
+ while (offset < buffer.byteLength) {
151
+ const result = await handle.write(buffer.subarray(offset));
152
+ const bytesWritten =
153
+ typeof result === "number" ? result : result?.bytesWritten;
154
+ if (
155
+ !Number.isInteger(bytesWritten) ||
156
+ bytesWritten <= 0 ||
157
+ bytesWritten > buffer.byteLength - offset
158
+ ) {
159
+ throw new Error(
160
+ `filesystem short write: expected ${buffer.byteLength - offset} bytes, received ${String(bytesWritten)}`,
161
+ );
162
+ }
163
+ offset += bytesWritten;
164
+ }
165
+ }
166
+
167
+ async function writeStreamToFile(
168
+ handle: FileHandle,
43
169
  stream: ReadableStream<Uint8Array>,
44
- ): Promise<Uint8Array> {
45
- const chunks: Uint8Array[] = [];
170
+ ): Promise<void> {
46
171
  const reader = stream.getReader();
47
- while (true) {
48
- const { done, value } = await reader.read();
49
- if (done) break;
50
- chunks.push(value);
172
+ try {
173
+ while (true) {
174
+ const { done, value } = await reader.read();
175
+ if (done) return;
176
+ if (!(value instanceof Uint8Array)) {
177
+ throw new Error("filesystem stream yielded a non-byte chunk");
178
+ }
179
+ await writeBufferFully(handle, value);
180
+ }
181
+ } catch (error) {
182
+ await reader.cancel(error).catch(() => undefined);
183
+ throw error;
184
+ } finally {
185
+ reader.releaseLock();
51
186
  }
52
- const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
53
- const result = new Uint8Array(totalLength);
187
+ }
188
+
189
+ async function writeValueToFile(
190
+ handle: FileHandle,
191
+ value: Blob | ReadableStream | ArrayBuffer | string,
192
+ ): Promise<void> {
193
+ if (typeof value === "string") {
194
+ await writeBufferFully(handle, new TextEncoder().encode(value));
195
+ return;
196
+ }
197
+ if (value instanceof ArrayBuffer) {
198
+ await writeBufferFully(handle, new Uint8Array(value));
199
+ return;
200
+ }
201
+ const stream = value instanceof Blob ? value.stream() : value;
202
+ await writeStreamToFile(handle, stream as ReadableStream<Uint8Array>);
203
+ }
204
+
205
+ async function syncAndClose(handle: FileHandle): Promise<void> {
206
+ try {
207
+ await handle.sync();
208
+ } finally {
209
+ await handle.close();
210
+ }
211
+ }
212
+
213
+ async function syncDirectory(directoryPath: string): Promise<void> {
214
+ let handle: FileHandle | undefined;
215
+ try {
216
+ // On Bun's POSIX filesystem, syncing the containing directory makes the
217
+ // preceding atomic rename durable across power loss, not just the file
218
+ // contents themselves.
219
+ handle = await open(directoryPath, "r");
220
+ await handle.sync();
221
+ } finally {
222
+ if (handle) await handle.close().catch(() => undefined);
223
+ }
224
+ }
225
+
226
+ async function readFileHandleFully(handle: FileHandle): Promise<Uint8Array> {
227
+ const size = (await handle.stat()).size;
228
+ if (!Number.isSafeInteger(size) || size < 0) {
229
+ throw new Error("invalid filesystem object size");
230
+ }
231
+ const content = new Uint8Array(size);
54
232
  let offset = 0;
55
- for (const chunk of chunks) {
56
- result.set(chunk, offset);
57
- offset += chunk.length;
233
+ while (offset < content.byteLength) {
234
+ const result = await handle.read(
235
+ content,
236
+ offset,
237
+ content.byteLength - offset,
238
+ null,
239
+ );
240
+ if (
241
+ !Number.isInteger(result.bytesRead) ||
242
+ result.bytesRead <= 0 ||
243
+ result.bytesRead > content.byteLength - offset
244
+ ) {
245
+ throw new Error(
246
+ `filesystem short read: expected ${content.byteLength - offset} bytes, received ${String(result.bytesRead)}`,
247
+ );
248
+ }
249
+ offset += result.bytesRead;
58
250
  }
59
- return result;
251
+ return content;
60
252
  }
61
253
 
62
- /**
63
- * Convert a put() value to Uint8Array.
64
- */
65
- async function toUint8Array(
66
- value: ReadableStream | ArrayBuffer | string,
67
- ): Promise<Uint8Array> {
68
- if (typeof value === "string") return new TextEncoder().encode(value);
69
- if (value instanceof ArrayBuffer) return new Uint8Array(value);
70
- return drainStream(value);
254
+ async function readFileHandleText(handle: FileHandle): Promise<string> {
255
+ return new TextDecoder().decode(await readFileHandleFully(handle));
71
256
  }
72
257
 
73
258
  /**
@@ -210,15 +395,55 @@ class BunPreparedStatement implements PreparedStatement {
210
395
  */
211
396
  export class BunStorage implements IObjectStorage {
212
397
  private basePath: string;
398
+ /**
399
+ * Keep the directory-sync dependency injectable for deterministic storage
400
+ * durability tests. Production callers use the real fsync implementation.
401
+ */
402
+ private readonly syncDirectory: typeof syncDirectory;
213
403
  private realBasePath: string | null = null;
404
+ private realInternalStorePath: string | null = null;
405
+ /**
406
+ * Generations currently being assembled by this adapter instance. A
407
+ * concurrent writer can have renamed its body before publishing the commit
408
+ * marker; keep that generation out of eager GC until its marker is durable.
409
+ */
410
+ private readonly activeGenerations = new Set<string>();
411
+ /**
412
+ * In-process read leases keep a resolved generation alive until its bytes
413
+ * have been opened/read. GC may run concurrently with a reader, so path
414
+ * resolution alone is not a sufficient lifetime guarantee.
415
+ */
416
+ private readonly generationReaders = new Map<string, number>();
417
+ /**
418
+ * A short reservation held while a reader resolves the current marker and
419
+ * opens its generation. It closes the race between reading the marker and
420
+ * acquiring the generation-specific lease.
421
+ */
422
+ private readonly keyReadReservations = new Map<string, number>();
423
+ /**
424
+ * Last marker successfully read by this adapter. Atomic marker replacement
425
+ * can make a path disappear or resolve to the next inode for a few syscalls;
426
+ * retaining the last validated record lets readers use the old generation
427
+ * while the marker is in flight instead of reporting a false null.
428
+ */
429
+ private readonly lastCommitRecords = new Map<string, CommitRecord>();
214
430
 
215
- constructor(basePath: string) {
431
+ constructor(
432
+ basePath: string,
433
+ options: { syncDirectory?: typeof syncDirectory } = {},
434
+ ) {
216
435
  this.basePath = basePath;
436
+ this.syncDirectory = options.syncDirectory ?? syncDirectory;
217
437
  }
218
438
 
219
- static async create(basePath: string): Promise<BunStorage> {
439
+ static async create(
440
+ basePath: string,
441
+ options: { syncDirectory?: typeof syncDirectory } = {},
442
+ ): Promise<BunStorage> {
220
443
  await mkdir(basePath, { recursive: true });
221
- return new BunStorage(basePath);
444
+ const storage = new BunStorage(basePath, options);
445
+ await storage.recoverInternalStore();
446
+ return storage;
222
447
  }
223
448
 
224
449
  private getFilePath(key: string): string {
@@ -232,6 +457,217 @@ export class BunStorage implements IObjectStorage {
232
457
  );
233
458
  }
234
459
 
460
+ private getInternalStorePath(): string {
461
+ const publicRoot = this.getResolvedBasePath();
462
+ return path.join(
463
+ path.dirname(publicRoot),
464
+ `.${path.basename(publicRoot) || "root"}${INTERNAL_STORE_ROOT}`,
465
+ );
466
+ }
467
+
468
+ private getInternalObjectPath(key: string): string {
469
+ return path.join(this.getInternalStorePath(), keyHash(key));
470
+ }
471
+
472
+ private isInternalStorePath(filePath: string): boolean {
473
+ return isPathWithinBasePath(
474
+ this.getInternalStorePath(),
475
+ path.resolve(filePath),
476
+ );
477
+ }
478
+
479
+ private getCommitPath(key: string): string {
480
+ return path.join(this.getInternalObjectPath(key), COMMIT_FILE);
481
+ }
482
+
483
+ private getGenerationPath(
484
+ key: string,
485
+ generation: string,
486
+ suffix: "body" | "meta.json",
487
+ ): string {
488
+ if (!GENERATION_ID_PATTERN.test(generation)) {
489
+ throw new Error("Invalid BunStorage generation identifier");
490
+ }
491
+ return path.join(
492
+ this.getInternalObjectPath(key),
493
+ `${GENERATION_MARKER}${generation}.${suffix}`,
494
+ );
495
+ }
496
+
497
+ private generationLeaseKey(key: string, generation: string): string {
498
+ return `${keyHash(key)}:${generation}`;
499
+ }
500
+
501
+ private acquireGenerationLease(
502
+ key: string,
503
+ generation: string,
504
+ ): () => Promise<void> {
505
+ const leaseKey = this.generationLeaseKey(key, generation);
506
+ this.generationReaders.set(
507
+ leaseKey,
508
+ (this.generationReaders.get(leaseKey) ?? 0) + 1,
509
+ );
510
+ let released = false;
511
+ return async () => {
512
+ if (released) return;
513
+ released = true;
514
+ const count = this.generationReaders.get(leaseKey) ?? 0;
515
+ if (count <= 1) this.generationReaders.delete(leaseKey);
516
+ else this.generationReaders.set(leaseKey, count - 1);
517
+ // A writer may have deferred this generation while the reader held its
518
+ // lease. Re-run conservative GC after release; failure is non-fatal and
519
+ // startup recovery remains the final orphan cleanup authority.
520
+ await this.reclaimUnreferencedGenerations(key);
521
+ };
522
+ }
523
+
524
+ private hasGenerationLease(key: string, generation: string): boolean {
525
+ return (
526
+ (this.generationReaders.get(this.generationLeaseKey(key, generation)) ??
527
+ 0) > 0
528
+ );
529
+ }
530
+
531
+ private acquireKeyReadReservation(key: string): () => void {
532
+ this.keyReadReservations.set(
533
+ key,
534
+ (this.keyReadReservations.get(key) ?? 0) + 1,
535
+ );
536
+ let released = false;
537
+ return () => {
538
+ if (released) return;
539
+ released = true;
540
+ const count = this.keyReadReservations.get(key) ?? 0;
541
+ if (count <= 1) this.keyReadReservations.delete(key);
542
+ else this.keyReadReservations.set(key, count - 1);
543
+ };
544
+ }
545
+
546
+ private hasKeyReadReservation(key: string): boolean {
547
+ return (this.keyReadReservations.get(key) ?? 0) > 0;
548
+ }
549
+
550
+ private async createTempLease(
551
+ objectPath: string,
552
+ leaseId: string,
553
+ kind: "generation" | "reader" = "generation",
554
+ ): Promise<() => Promise<void>> {
555
+ const marker = kind === "reader" ? READER_LEASE_MARKER : "";
556
+ const leasePath = path.join(
557
+ objectPath,
558
+ `${TEMP_MARKER}${marker}${leaseId}.${LEASE_SUFFIX}`,
559
+ );
560
+ const leaseOwner = JSON.stringify({
561
+ pid: process.pid,
562
+ token: crypto.randomUUID(),
563
+ });
564
+ let leaseHandle: FileHandle | undefined;
565
+ try {
566
+ leaseHandle = await open(leasePath, "wx");
567
+ await writeBufferFully(leaseHandle, new TextEncoder().encode(leaseOwner));
568
+ await syncAndClose(leaseHandle);
569
+ leaseHandle = undefined;
570
+ } catch (error) {
571
+ if (leaseHandle) await leaseHandle.close().catch(() => undefined);
572
+ await this.unlinkOwnedInternalFile(leasePath);
573
+ throw error;
574
+ }
575
+ await this.assertOwnedInternalFile(leasePath);
576
+
577
+ const heartbeat = setInterval(() => {
578
+ void utimes(leasePath, new Date(), new Date()).catch(() => undefined);
579
+ }, TEMP_LEASE_HEARTBEAT_MS);
580
+ (heartbeat as unknown as { unref?: () => void }).unref?.();
581
+ let released = false;
582
+ return async () => {
583
+ if (released) return;
584
+ released = true;
585
+ clearInterval(heartbeat);
586
+ await this.unlinkOwnedInternalFile(leasePath);
587
+ };
588
+ }
589
+
590
+ private async createReaderLease(
591
+ key: string,
592
+ ): Promise<(() => Promise<void>) | undefined> {
593
+ const objectPath = this.getInternalObjectPath(key);
594
+ try {
595
+ await assertPathChainWithinBasePath(
596
+ await this.getRealInternalStorePath(),
597
+ objectPath,
598
+ realpath,
599
+ );
600
+ await this.assertRealInternalDirectory(objectPath);
601
+ return await this.createTempLease(
602
+ objectPath,
603
+ crypto.randomUUID(),
604
+ "reader",
605
+ );
606
+ } catch (error) {
607
+ if (isNotFoundError(error)) return undefined;
608
+ throw error;
609
+ }
610
+ }
611
+
612
+ private async isLiveTempLease(leasePath: string): Promise<boolean> {
613
+ let leaseHandle: FileHandle | undefined;
614
+ try {
615
+ const opened = await this.openOwnedInternalFile(leasePath);
616
+ leaseHandle = opened.handle;
617
+ const leaseStats = await leaseHandle.stat();
618
+ if (Date.now() - leaseStats.mtimeMs > TEMP_LEASE_TTL_MS) return false;
619
+ const lease = JSON.parse(await readFileHandleText(leaseHandle)) as {
620
+ pid?: unknown;
621
+ };
622
+ if (!Number.isInteger(lease.pid) || Number(lease.pid) <= 0) return false;
623
+ try {
624
+ process.kill(Number(lease.pid), 0);
625
+ return true;
626
+ } catch {
627
+ return false;
628
+ }
629
+ } catch {
630
+ return false;
631
+ } finally {
632
+ if (leaseHandle) await leaseHandle.close().catch(() => undefined);
633
+ }
634
+ }
635
+
636
+ private async liveTempLeases(
637
+ objectPath: string,
638
+ entries: Array<{ name: string; isDirectory(): boolean }>,
639
+ ): Promise<LiveLeaseState> {
640
+ const generations = new Set<string>();
641
+ let reader = false;
642
+ for (const entry of entries) {
643
+ if (entry.isDirectory()) continue;
644
+ const generationMatch = entry.name.match(
645
+ /^tmp-([0-9a-f-]{16,})\.lease$/u,
646
+ );
647
+ const readerMatch = entry.name.match(
648
+ /^tmp-reader-([0-9a-f-]{16,})\.lease$/u,
649
+ );
650
+ if (!generationMatch && !readerMatch) continue;
651
+ const leasePath = path.join(objectPath, entry.name);
652
+ if (await this.isLiveTempLease(leasePath)) {
653
+ if (readerMatch) reader = true;
654
+ else generations.add(generationMatch![1]!);
655
+ } else {
656
+ await this.unlinkOwnedInternalFile(leasePath);
657
+ }
658
+ }
659
+ return { generations, reader };
660
+ }
661
+
662
+ private async hasLiveReaderLease(objectPath: string): Promise<boolean> {
663
+ try {
664
+ const entries = await readdir(objectPath, { withFileTypes: true });
665
+ return (await this.liveTempLeases(objectPath, entries)).reader;
666
+ } catch {
667
+ return false;
668
+ }
669
+ }
670
+
235
671
  private getResolvedBasePath(): string {
236
672
  return path.resolve(this.basePath);
237
673
  }
@@ -247,6 +683,47 @@ export class BunStorage implements IObjectStorage {
247
683
  return this.realBasePath;
248
684
  }
249
685
 
686
+ private async getRealInternalStorePath(): Promise<string> {
687
+ if (this.realInternalStorePath) {
688
+ // Re-check the lexical root on every access. A rolling process or an
689
+ // operator-side repair can replace the directory with a symlink after
690
+ // startup; the cached realpath must not make that substitution trusted.
691
+ await this.assertRealInternalDirectory(this.getInternalStorePath());
692
+ return this.realInternalStorePath;
693
+ }
694
+ const internalRoot = this.getInternalStorePath();
695
+ const internalParent = path.dirname(internalRoot);
696
+ const realInternalParent = await realpath(internalParent);
697
+ // The metadata namespace is allowed outside the public root, but it must
698
+ // remain within the same trusted parent and may not be redirected through
699
+ // a symlink to an unrelated filesystem location.
700
+ await assertPathChainWithinBasePath(
701
+ realInternalParent,
702
+ internalRoot,
703
+ realpath,
704
+ );
705
+ try {
706
+ const rootStats = await lstat(internalRoot);
707
+ if (rootStats.isSymbolicLink() || !rootStats.isDirectory()) {
708
+ throw new Error("BunStorage metadata root must be a real directory");
709
+ }
710
+ } catch (error) {
711
+ if (!isNotFoundError(error)) throw error;
712
+ }
713
+ await mkdir(internalRoot, { recursive: true });
714
+ const createdRootStats = await lstat(internalRoot);
715
+ if (createdRootStats.isSymbolicLink() || !createdRootStats.isDirectory()) {
716
+ throw new Error("BunStorage metadata root must be a real directory");
717
+ }
718
+ await this.syncDirectory(internalParent);
719
+ const resolvedRoot = await realpath(internalRoot);
720
+ if (!isPathWithinBasePath(realInternalParent, resolvedRoot)) {
721
+ throw new Error("BunStorage metadata path escapes its parent");
722
+ }
723
+ this.realInternalStorePath = resolvedRoot;
724
+ return resolvedRoot;
725
+ }
726
+
250
727
  private async resolveExistingPath(filePath: string): Promise<string | null> {
251
728
  try {
252
729
  const realPath = await realpath(filePath);
@@ -260,16 +737,795 @@ export class BunStorage implements IObjectStorage {
260
737
  }
261
738
  }
262
739
 
740
+ private async resolveExistingInternalPath(
741
+ filePath: string,
742
+ ): Promise<string | null> {
743
+ try {
744
+ const realPath = await realpath(filePath);
745
+ const realInternalRoot = await this.getRealInternalStorePath();
746
+ if (!isPathWithinBasePath(realInternalRoot, realPath)) {
747
+ throw new Error("BunStorage metadata path escapes its root");
748
+ }
749
+ return realPath;
750
+ } catch {
751
+ return null;
752
+ }
753
+ }
754
+
755
+ private async assertRealInternalDirectory(
756
+ directoryPath: string,
757
+ ): Promise<void> {
758
+ const directoryStats = await lstat(directoryPath);
759
+ if (directoryStats.isSymbolicLink() || !directoryStats.isDirectory()) {
760
+ throw new Error("BunStorage internal path must be a real directory");
761
+ }
762
+ }
763
+
764
+ /**
765
+ * Open an adapter-owned regular file without following a final symlink or
766
+ * accepting a hardlink to an external inode. The lstat/fstat identity check
767
+ * closes the realpath-to-open replacement window; callers read through the
768
+ * returned descriptor rather than reopening the path.
769
+ */
770
+ private async openOwnedRegularFile(
771
+ filePath: string,
772
+ rootPath: string,
773
+ ): Promise<{ handle: FileHandle; identity: FileIdentity }> {
774
+ await this.assertOwnedPathChain(rootPath, filePath);
775
+ const before = await lstat(filePath);
776
+ if (
777
+ before.isSymbolicLink() ||
778
+ !before.isFile() ||
779
+ // During an atomic rename/unlink, Bun can expose the just-unlinked
780
+ // inode as nlink=0 for one stat result. It is still safe to open only
781
+ // when no additional link (nlink>1) is present.
782
+ before.nlink < 0 ||
783
+ before.nlink > 1
784
+ ) {
785
+ throw new Error("BunStorage file is not an owned regular file");
786
+ }
787
+ const expected = fileIdentity(before);
788
+ let handle: FileHandle | undefined;
789
+ try {
790
+ handle = await open(filePath, OPEN_READ_FLAGS);
791
+ const after = fileIdentity(await handle.stat());
792
+ if (!isSameFileIdentity(expected, after)) {
793
+ throw new Error("BunStorage file changed while opening");
794
+ }
795
+ // Re-check the path after opening as well. O_NOFOLLOW protects the
796
+ // final component, while this catches a parent-directory replacement
797
+ // that happened between the initial containment check and open().
798
+ await this.assertOwnedPathChain(rootPath, filePath, false);
799
+ return { handle, identity: after };
800
+ } catch (error) {
801
+ if (handle) await handle.close().catch(() => undefined);
802
+ throw error;
803
+ }
804
+ }
805
+
806
+ /**
807
+ * Internal files are adapter-owned, so every path component must be a real
808
+ * directory/file rather than a symlink. realpath containment alone is not
809
+ * sufficient: a symlink can point to another in-root inode and still win a
810
+ * later path lookup, and the final lstat/open pair has a TOCTOU window.
811
+ */
812
+ private async assertOwnedPathChain(
813
+ rootPath: string,
814
+ targetPath: string,
815
+ includeTarget = true,
816
+ ): Promise<void> {
817
+ const root = path.resolve(rootPath);
818
+ const target = path.resolve(targetPath);
819
+ if (!isPathWithinBasePath(root, target)) {
820
+ throw new Error("BunStorage path escapes its owned root");
821
+ }
822
+ const relativePath = path.relative(root, target);
823
+ let current = root;
824
+ const components = relativePath ? relativePath.split(path.sep) : [];
825
+ if (!includeTarget) components.pop();
826
+ for (const component of components) {
827
+ current = path.join(current, component);
828
+ const stats = await lstat(current);
829
+ if (stats.isSymbolicLink()) {
830
+ throw new Error("BunStorage internal path contains a symlink");
831
+ }
832
+ }
833
+ }
834
+
835
+ private async readOwnedInternalFileText(filePath: string): Promise<string> {
836
+ const opened = await this.openOwnedInternalFile(filePath);
837
+ try {
838
+ return await readFileHandleText(opened.handle);
839
+ } finally {
840
+ await opened.handle.close().catch(() => undefined);
841
+ }
842
+ }
843
+
844
+ private async readOwnedInternalFileMetadata(filePath: string): Promise<{
845
+ httpMetadata?: ObjectMetadata["httpMetadata"];
846
+ customMetadata?: Record<string, string>;
847
+ }> {
848
+ return this.parseGenerationMetadata(
849
+ await this.readOwnedInternalFileText(filePath),
850
+ );
851
+ }
852
+
853
+ private async openOwnedInternalFile(
854
+ filePath: string,
855
+ ): Promise<{ handle: FileHandle; identity: FileIdentity }> {
856
+ return this.openOwnedRegularFile(
857
+ filePath,
858
+ await this.getRealInternalStorePath(),
859
+ );
860
+ }
861
+
862
+ private async assertOwnedRegularFile(
863
+ filePath: string,
864
+ rootPath: string,
865
+ ): Promise<FileIdentity> {
866
+ const opened = await this.openOwnedRegularFile(filePath, rootPath);
867
+ await opened.handle.close();
868
+ return opened.identity;
869
+ }
870
+
871
+ private async assertOwnedInternalFile(
872
+ filePath: string,
873
+ ): Promise<FileIdentity> {
874
+ return this.assertOwnedRegularFile(
875
+ filePath,
876
+ await this.getRealInternalStorePath(),
877
+ );
878
+ }
879
+
880
+ /**
881
+ * Remove only an owned regular file. A symlink or hardlink is left in place
882
+ * for recovery/security inspection rather than unlinking an unowned inode.
883
+ */
884
+ private async unlinkOwnedRegularFile(
885
+ filePath: string,
886
+ rootPath: string,
887
+ ): Promise<void> {
888
+ try {
889
+ await this.assertOwnedRegularFile(filePath, rootPath);
890
+ } catch (error) {
891
+ if (isNotFoundError(error)) return;
892
+ return;
893
+ }
894
+ await unlink(filePath).catch(() => undefined);
895
+ }
896
+
897
+ private async unlinkOwnedInternalFile(filePath: string): Promise<void> {
898
+ await this.unlinkOwnedRegularFile(
899
+ filePath,
900
+ await this.getRealInternalStorePath(),
901
+ );
902
+ }
903
+
904
+ private async recoverInternalStore(): Promise<void> {
905
+ try {
906
+ // Validate the sibling namespace before creating or traversing it. A
907
+ // symlink at this exact path must never redirect recovery elsewhere.
908
+ const resolvedRoot = await this.getRealInternalStorePath();
909
+ const objectEntries = await readdir(resolvedRoot, {
910
+ withFileTypes: true,
911
+ });
912
+ for (const objectEntry of objectEntries) {
913
+ if (!DIGEST_PATTERN.test(objectEntry.name)) {
914
+ continue;
915
+ }
916
+ const objectPath = path.join(resolvedRoot, objectEntry.name);
917
+ await this.assertRealInternalDirectory(objectPath);
918
+ const resolvedObjectPath =
919
+ await this.resolveExistingInternalPath(objectPath);
920
+ if (!resolvedObjectPath) continue;
921
+ const files = await readdir(resolvedObjectPath, {
922
+ withFileTypes: true,
923
+ });
924
+ const liveLeaseState = await this.liveTempLeases(
925
+ resolvedObjectPath,
926
+ files,
927
+ );
928
+ let retainedGeneration: string | null = null;
929
+ try {
930
+ const markerPath = path.join(resolvedObjectPath, COMMIT_FILE);
931
+ const markerStats = await lstat(markerPath).catch(() => null);
932
+ if (markerStats && markerStats.isFile() && markerStats.nlink === 1) {
933
+ const record = JSON.parse(
934
+ await this.readOwnedInternalFileText(markerPath),
935
+ ) as Partial<CommitRecord>;
936
+ if (
937
+ record.version === 1 &&
938
+ typeof record.key === "string" &&
939
+ record.keyHash === objectEntry.name &&
940
+ keyHash(record.key) === objectEntry.name &&
941
+ record.state === "committed" &&
942
+ typeof record.generation === "string" &&
943
+ GENERATION_ID_PATTERN.test(record.generation)
944
+ ) {
945
+ const bodyPath = path.join(
946
+ resolvedObjectPath,
947
+ `${GENERATION_MARKER}${record.generation}.body`,
948
+ );
949
+ const metaPath = path.join(
950
+ resolvedObjectPath,
951
+ `${GENERATION_MARKER}${record.generation}.meta.json`,
952
+ );
953
+ try {
954
+ const body = await this.openOwnedInternalFile(bodyPath);
955
+ await body.handle.close();
956
+ // Parse metadata during recovery so a partially-written or
957
+ // corrupt sidecar cannot be retained as a live generation.
958
+ await this.readGenerationMetadata(metaPath);
959
+ retainedGeneration = record.generation;
960
+ } catch {
961
+ // Missing, symlinked, hardlinked, or corrupt generations are
962
+ // not eligible to remain referenced by the marker.
963
+ }
964
+ }
965
+ }
966
+ } catch {
967
+ retainedGeneration = null;
968
+ }
969
+
970
+ for (const entry of files) {
971
+ if (entry.isDirectory()) continue;
972
+ const leaseMatch = entry.name.match(/^tmp-([0-9a-f-]{16,})\.lease$/u);
973
+ const readerLeaseMatch = entry.name.match(
974
+ /^tmp-reader-([0-9a-f-]{16,})\.lease$/u,
975
+ );
976
+ if (leaseMatch || readerLeaseMatch) {
977
+ if (
978
+ (leaseMatch && liveLeaseState.generations.has(leaseMatch[1]!)) ||
979
+ (readerLeaseMatch && liveLeaseState.reader)
980
+ ) {
981
+ continue;
982
+ }
983
+ await this.unlinkOwnedInternalFile(
984
+ path.join(resolvedObjectPath, entry.name),
985
+ );
986
+ continue;
987
+ }
988
+ if (entry.name.startsWith(TEMP_MARKER)) {
989
+ const tempGeneration = entry.name.match(
990
+ /^tmp-([0-9a-f-]{16,})\./u,
991
+ )?.[1];
992
+ if (
993
+ tempGeneration &&
994
+ liveLeaseState.generations.has(tempGeneration)
995
+ ) {
996
+ continue;
997
+ }
998
+ await this.unlinkOwnedInternalFile(
999
+ path.join(resolvedObjectPath, entry.name),
1000
+ );
1001
+ continue;
1002
+ }
1003
+ const generationMatch = entry.name.match(
1004
+ /^generation-([0-9a-f-]{16,})\.(?:body|meta\.json)$/u,
1005
+ );
1006
+ if (
1007
+ generationMatch &&
1008
+ generationMatch[1] !== retainedGeneration &&
1009
+ !liveLeaseState.generations.has(generationMatch[1]!) &&
1010
+ !liveLeaseState.reader
1011
+ ) {
1012
+ await this.unlinkOwnedInternalFile(
1013
+ path.join(resolvedObjectPath, entry.name),
1014
+ );
1015
+ }
1016
+ }
1017
+ }
1018
+ } catch (error) {
1019
+ // A storage directory can disappear between mkdir and recovery; the
1020
+ // first normal put will recreate its digest directory. Security and
1021
+ // namespace validation failures must remain observable instead of being
1022
+ // mistaken for an empty store.
1023
+ if (!isNotFoundError(error)) throw error;
1024
+ }
1025
+ }
1026
+
1027
+ private async readCommitRecord(
1028
+ key: string,
1029
+ ): Promise<CommitRecord | undefined> {
1030
+ const markerPath = this.getCommitPath(key);
1031
+ let markerObserved = false;
1032
+ let markerText: string | undefined;
1033
+ let lastNotFound: unknown;
1034
+ for (let attempt = 0; attempt < MARKER_READ_ATTEMPTS; attempt += 1) {
1035
+ let markerHandle: FileHandle | undefined;
1036
+ try {
1037
+ // Validate every existing path component before opening the marker.
1038
+ // This distinguishes a missing marker (normal legacy fallback) from a
1039
+ // symlinked internal namespace that escapes its sibling root.
1040
+ const opened = await this.openOwnedInternalFile(markerPath);
1041
+ markerHandle = opened.handle;
1042
+ markerObserved = true;
1043
+ // Read through an open descriptor. Resolving the path and then asking
1044
+ // Bun.file() to open it leaves a rename/unlink window where Bun can
1045
+ // retain a stale `(... deleted)` path; an FD is either the old
1046
+ // complete marker or the new complete marker after atomic rename.
1047
+ markerText = await readFileHandleText(markerHandle);
1048
+ break;
1049
+ } catch (error) {
1050
+ if (!isNotFoundError(error) && !isFileIdentityRace(error)) throw error;
1051
+ lastNotFound = error;
1052
+ try {
1053
+ const markerStats = await lstat(markerPath);
1054
+ if (
1055
+ markerStats.isSymbolicLink() ||
1056
+ !markerStats.isFile() ||
1057
+ markerStats.nlink < 0 ||
1058
+ markerStats.nlink > 1
1059
+ ) {
1060
+ throw new Error("BunStorage commit marker is not owned");
1061
+ }
1062
+ markerObserved = true;
1063
+ } catch (probeError) {
1064
+ if (!isNotFoundError(probeError)) throw probeError;
1065
+ }
1066
+ if (attempt + 1 < MARKER_READ_ATTEMPTS) {
1067
+ await yieldForFilesystem();
1068
+ continue;
1069
+ }
1070
+ } finally {
1071
+ if (markerHandle) await markerHandle.close().catch(() => undefined);
1072
+ }
1073
+ }
1074
+ if (markerText === undefined) {
1075
+ // A marker that was ever visible must not silently fall through to the
1076
+ // legacy path after a rename race. The caller's resolve loop will reread
1077
+ // the marker; a truly absent marker still enables legacy compatibility.
1078
+ const cached = this.lastCommitRecords.get(key);
1079
+ if (cached && lastNotFound) return cached;
1080
+ if (markerObserved && lastNotFound) throw lastNotFound;
1081
+ return undefined;
1082
+ }
1083
+
1084
+ const record = JSON.parse(markerText) as Partial<CommitRecord>;
1085
+ if (
1086
+ record.version !== 1 ||
1087
+ typeof record.key !== "string" ||
1088
+ typeof record.keyHash !== "string" ||
1089
+ record.key !== key ||
1090
+ record.keyHash !== keyHash(key)
1091
+ ) {
1092
+ throw new Error("Invalid BunStorage commit marker identity");
1093
+ }
1094
+ if (record.state === "deleted" && record.generation === null) {
1095
+ this.lastCommitRecords.set(key, record as CommitRecord);
1096
+ return record as CommitRecord;
1097
+ }
1098
+ if (
1099
+ record.state !== "committed" ||
1100
+ typeof record.generation !== "string" ||
1101
+ !GENERATION_ID_PATTERN.test(record.generation)
1102
+ ) {
1103
+ throw new Error("Invalid BunStorage commit marker");
1104
+ }
1105
+ this.lastCommitRecords.set(key, record as CommitRecord);
1106
+ return record as CommitRecord;
1107
+ }
1108
+
1109
+ private async readGenerationMetadata(metaPath: string): Promise<{
1110
+ httpMetadata?: ObjectMetadata["httpMetadata"];
1111
+ customMetadata?: Record<string, string>;
1112
+ }> {
1113
+ return this.readOwnedInternalFileMetadata(metaPath);
1114
+ }
1115
+
1116
+ private parseGenerationMetadata(valueText: string): {
1117
+ httpMetadata?: ObjectMetadata["httpMetadata"];
1118
+ customMetadata?: Record<string, string>;
1119
+ } {
1120
+ const value = JSON.parse(valueText) as {
1121
+ httpMetadata?: ObjectMetadata["httpMetadata"];
1122
+ customMetadata?: Record<string, string>;
1123
+ };
1124
+ if (value === null || typeof value !== "object") {
1125
+ throw new Error("Invalid BunStorage generation metadata");
1126
+ }
1127
+ return value;
1128
+ }
1129
+
1130
+ private async readGenerationMetadataHandle(handle: FileHandle): Promise<{
1131
+ httpMetadata?: ObjectMetadata["httpMetadata"];
1132
+ customMetadata?: Record<string, string>;
1133
+ }> {
1134
+ return this.parseGenerationMetadata(await readFileHandleText(handle));
1135
+ }
1136
+
1137
+ private async resolveObject(
1138
+ key: string,
1139
+ withLease = false,
1140
+ ): Promise<ResolvedObject | null> {
1141
+ const releaseKeyRead = withLease
1142
+ ? this.acquireKeyReadReservation(key)
1143
+ : undefined;
1144
+ let readerLease: (() => Promise<void>) | undefined;
1145
+ let readerLeaseOwned = true;
1146
+ const releaseReaderLease = async () => {
1147
+ const release = readerLease;
1148
+ readerLease = undefined;
1149
+ await release?.();
1150
+ if (release) await this.reclaimUnreferencedGenerations(key);
1151
+ };
1152
+ try {
1153
+ if (withLease) readerLease = await this.createReaderLease(key);
1154
+ for (let attempt = 0; attempt < OBJECT_RESOLVE_ATTEMPTS; attempt += 1) {
1155
+ let commit: CommitRecord | undefined;
1156
+ try {
1157
+ commit = await this.readCommitRecord(key);
1158
+ } catch (error) {
1159
+ if (
1160
+ (isNotFoundError(error) || isFileIdentityRace(error)) &&
1161
+ attempt + 1 < OBJECT_RESOLVE_ATTEMPTS
1162
+ ) {
1163
+ await yieldForFilesystem();
1164
+ continue;
1165
+ }
1166
+ throw error;
1167
+ }
1168
+ if (commit) {
1169
+ if (commit.state === "deleted") return null;
1170
+ const generation = commit.generation;
1171
+ const generationLease = withLease
1172
+ ? this.acquireGenerationLease(key, generation)
1173
+ : undefined;
1174
+ let bodyHandle: FileHandle | undefined;
1175
+ let metadataHandle: FileHandle | undefined;
1176
+ let released = false;
1177
+ const releaseGenerationLease = async () => {
1178
+ if (released) return;
1179
+ released = true;
1180
+ if (metadataHandle) {
1181
+ await metadataHandle.close().catch(() => undefined);
1182
+ metadataHandle = undefined;
1183
+ }
1184
+ if (bodyHandle) {
1185
+ await bodyHandle.close().catch(() => undefined);
1186
+ bodyHandle = undefined;
1187
+ }
1188
+ await generationLease?.();
1189
+ };
1190
+ const releaseLease = async () => {
1191
+ await releaseGenerationLease();
1192
+ await releaseReaderLease();
1193
+ };
1194
+ try {
1195
+ const filePath = this.getGenerationPath(key, generation, "body");
1196
+ const metaPath = this.getGenerationPath(
1197
+ key,
1198
+ generation,
1199
+ "meta.json",
1200
+ );
1201
+ const resolvedFilePath =
1202
+ await this.resolveExistingInternalPath(filePath);
1203
+ const resolvedMetaPath =
1204
+ await this.resolveExistingInternalPath(metaPath);
1205
+ if (!resolvedFilePath || !resolvedMetaPath) {
1206
+ await releaseGenerationLease();
1207
+ await yieldForFilesystem();
1208
+ continue;
1209
+ }
1210
+
1211
+ if (withLease) {
1212
+ // Open both files before releasing the marker read. Once the body
1213
+ // descriptor is open, a concurrent unlink cannot invalidate this
1214
+ // read on POSIX; a missing path simply causes a bounded marker
1215
+ // reread/retry against the winning generation. The owned-open
1216
+ // helper rejects hardlinks/symlinks and reads through the FD,
1217
+ // closing both the identity and path-swap windows.
1218
+ bodyHandle = (await this.openOwnedInternalFile(filePath)).handle;
1219
+ metadataHandle = (await this.openOwnedInternalFile(metaPath))
1220
+ .handle;
1221
+ const metadata =
1222
+ await this.readGenerationMetadataHandle(metadataHandle);
1223
+ readerLeaseOwned = false;
1224
+ return {
1225
+ filePath: resolvedFilePath,
1226
+ bodyHandle,
1227
+ metadata,
1228
+ releaseLease,
1229
+ };
1230
+ }
1231
+
1232
+ const openedBody = await this.openOwnedInternalFile(filePath);
1233
+ const openedMeta = await this.openOwnedInternalFile(metaPath);
1234
+ await openedBody.handle.close();
1235
+ await openedMeta.handle.close();
1236
+ const metadata =
1237
+ await this.readGenerationMetadata(resolvedMetaPath);
1238
+ await releaseGenerationLease();
1239
+ return { filePath: resolvedFilePath, metadata };
1240
+ } catch (error) {
1241
+ await releaseGenerationLease();
1242
+ if (
1243
+ isNotFoundError(error) &&
1244
+ attempt + 1 < OBJECT_RESOLVE_ATTEMPTS
1245
+ ) {
1246
+ await yieldForFilesystem();
1247
+ continue;
1248
+ }
1249
+ throw error;
1250
+ }
1251
+ }
1252
+
1253
+ // If the generation namespace exists but its marker was observed as
1254
+ // missing, an atomic rename may be between unlink and replacement.
1255
+ // Do not fall through to the legacy path (and return null) during that
1256
+ // churn; retry the marker read while the internal object directory is
1257
+ // present. A genuinely marker-less legacy object still falls through
1258
+ // after the bounded resolve attempts.
1259
+ if (withLease) {
1260
+ const internalObjectPath = this.getInternalObjectPath(key);
1261
+ if (
1262
+ (await this.resolveExistingInternalPath(internalObjectPath)) &&
1263
+ attempt + 1 < OBJECT_RESOLVE_ATTEMPTS
1264
+ ) {
1265
+ await yieldForFilesystem();
1266
+ continue;
1267
+ }
1268
+ }
1269
+
1270
+ // Objects written by older BunStorage versions use the body + metadata
1271
+ // sidecar layout. Keep those reads working until the object is rewritten.
1272
+ const filePath = this.getFilePath(key);
1273
+ const resolvedFilePath = await this.resolveExistingPath(filePath);
1274
+ if (!resolvedFilePath) return null;
1275
+ const file = Bun.file(resolvedFilePath);
1276
+ if (!(await file.exists())) return null;
1277
+ if (withLease) {
1278
+ let bodyHandle: FileHandle | undefined;
1279
+ let metadataHandle: FileHandle | undefined;
1280
+ try {
1281
+ const openedBody = await this.openOwnedRegularFile(
1282
+ resolvedFilePath,
1283
+ await this.getRealBasePath(),
1284
+ );
1285
+ bodyHandle = openedBody.handle;
1286
+ const resolvedMetaPath = await this.resolveExistingPath(
1287
+ this.getMetaPath(key),
1288
+ );
1289
+ let metadata: ResolvedObject["metadata"] = {};
1290
+ if (resolvedMetaPath) {
1291
+ const openedMeta = await this.openOwnedRegularFile(
1292
+ resolvedMetaPath,
1293
+ await this.getRealBasePath(),
1294
+ );
1295
+ metadataHandle = openedMeta.handle;
1296
+ metadata = this.parseGenerationMetadata(
1297
+ await readFileHandleText(metadataHandle),
1298
+ );
1299
+ }
1300
+ let released = false;
1301
+ const releaseLease = async () => {
1302
+ if (released) return;
1303
+ released = true;
1304
+ if (metadataHandle) {
1305
+ await metadataHandle.close().catch(() => undefined);
1306
+ metadataHandle = undefined;
1307
+ }
1308
+ if (bodyHandle) {
1309
+ await bodyHandle.close().catch(() => undefined);
1310
+ bodyHandle = undefined;
1311
+ }
1312
+ await releaseReaderLease();
1313
+ };
1314
+ readerLeaseOwned = false;
1315
+ return {
1316
+ filePath: resolvedFilePath,
1317
+ bodyHandle,
1318
+ metadata,
1319
+ releaseLease,
1320
+ };
1321
+ } catch (error) {
1322
+ if (metadataHandle)
1323
+ await metadataHandle.close().catch(() => undefined);
1324
+ if (bodyHandle) await bodyHandle.close().catch(() => undefined);
1325
+ if (isNotFoundError(error) && attempt < 2) continue;
1326
+ throw error;
1327
+ }
1328
+ }
1329
+ const resolvedMetaPath = await this.resolveExistingPath(
1330
+ this.getMetaPath(key),
1331
+ );
1332
+ const metadata = resolvedMetaPath
1333
+ ? await readMetadata(resolvedMetaPath)
1334
+ : {};
1335
+ return { filePath: resolvedFilePath, metadata };
1336
+ }
1337
+ return null;
1338
+ } finally {
1339
+ releaseKeyRead?.();
1340
+ if (readerLeaseOwned) await releaseReaderLease();
1341
+ }
1342
+ }
1343
+
1344
+ private async writeCommitRecord(
1345
+ key: string,
1346
+ record: CommitState,
1347
+ ): Promise<void> {
1348
+ const objectPath = this.getInternalObjectPath(key);
1349
+ await assertPathChainWithinBasePath(
1350
+ await this.getRealInternalStorePath(),
1351
+ objectPath,
1352
+ realpath,
1353
+ );
1354
+ await mkdir(objectPath, { recursive: true });
1355
+ await this.assertRealInternalDirectory(objectPath);
1356
+ await this.syncDirectory(path.dirname(objectPath));
1357
+ const markerPath = this.getCommitPath(key);
1358
+ const markerGeneration = crypto.randomUUID();
1359
+ const markerTempPath = path.join(
1360
+ objectPath,
1361
+ `${TEMP_MARKER}${markerGeneration}.commit.json`,
1362
+ );
1363
+ const releaseLease = await this.createTempLease(
1364
+ objectPath,
1365
+ markerGeneration,
1366
+ );
1367
+ const commitPayload: CommitRecord = {
1368
+ ...record,
1369
+ key,
1370
+ keyHash: keyHash(key),
1371
+ };
1372
+ let markerHandle: FileHandle | undefined;
1373
+ let markerTempCreated = false;
1374
+ try {
1375
+ markerHandle = await open(markerTempPath, "wx");
1376
+ markerTempCreated = true;
1377
+ await writeBufferFully(
1378
+ markerHandle,
1379
+ new TextEncoder().encode(JSON.stringify(commitPayload)),
1380
+ );
1381
+ await syncAndClose(markerHandle);
1382
+ markerHandle = undefined;
1383
+ await rename(markerTempPath, markerPath);
1384
+ // The marker rename is the publication point. Mark the temporary path
1385
+ // gone immediately so a subsequent directory-fsync error cannot make
1386
+ // finally misclassify the committed marker as unpublished.
1387
+ markerTempCreated = false;
1388
+ await this.syncDirectory(objectPath);
1389
+ } finally {
1390
+ if (markerHandle) await markerHandle.close().catch(() => undefined);
1391
+ if (markerTempCreated) await this.unlinkOwnedInternalFile(markerTempPath);
1392
+ await releaseLease();
1393
+ }
1394
+ }
1395
+
1396
+ private async removeGenerationIfUnreferenced(
1397
+ key: string,
1398
+ generation: string | null | undefined,
1399
+ ): Promise<void> {
1400
+ if (!generation) {
1401
+ await this.reclaimUnreferencedGenerations(key);
1402
+ return;
1403
+ }
1404
+ let current: CommitRecord | undefined;
1405
+ try {
1406
+ current = await this.readCommitRecord(key);
1407
+ } catch {
1408
+ // A malformed marker is fail-closed; leave bytes for startup recovery
1409
+ // rather than risking removal of a generation we cannot identify.
1410
+ return;
1411
+ }
1412
+ if (!(
1413
+ current?.state === "committed" && current.generation === generation
1414
+ )) {
1415
+ const activeKey = `${keyHash(key)}:${generation}`;
1416
+ const leasePath = path.join(
1417
+ this.getInternalObjectPath(key),
1418
+ `${TEMP_MARKER}${generation}.${LEASE_SUFFIX}`,
1419
+ );
1420
+ const liveLease = await this.isLiveTempLease(leasePath);
1421
+ const readerLease = await this.hasLiveReaderLease(
1422
+ this.getInternalObjectPath(key),
1423
+ );
1424
+ if (
1425
+ !this.activeGenerations.has(activeKey) &&
1426
+ !this.hasGenerationLease(key, generation) &&
1427
+ !this.hasKeyReadReservation(key) &&
1428
+ !liveLease &&
1429
+ !readerLease
1430
+ ) {
1431
+ for (const suffix of ["body", "meta.json"] as const) {
1432
+ const filePath = this.getGenerationPath(key, generation, suffix);
1433
+ await this.unlinkOwnedInternalFile(filePath);
1434
+ }
1435
+ }
1436
+ }
1437
+ await this.reclaimUnreferencedGenerations(key);
1438
+ }
1439
+
1440
+ private async reclaimUnreferencedGenerations(key: string): Promise<void> {
1441
+ let current: CommitRecord | undefined;
1442
+ try {
1443
+ current = await this.readCommitRecord(key);
1444
+ } catch {
1445
+ // A malformed marker is fail-closed; startup recovery will handle it.
1446
+ return;
1447
+ }
1448
+ const retainedGeneration =
1449
+ current?.state === "committed" ? current.generation : null;
1450
+ const objectPath = this.getInternalObjectPath(key);
1451
+ const resolvedObjectPath =
1452
+ await this.resolveExistingInternalPath(objectPath);
1453
+ if (!resolvedObjectPath) return;
1454
+ let entries;
1455
+ try {
1456
+ entries = await readdir(resolvedObjectPath, { withFileTypes: true });
1457
+ } catch {
1458
+ return;
1459
+ }
1460
+ const liveLeaseState = await this.liveTempLeases(
1461
+ resolvedObjectPath,
1462
+ entries,
1463
+ );
1464
+ if (liveLeaseState.reader) return;
1465
+ for (const entry of entries) {
1466
+ if (entry.isDirectory()) continue;
1467
+ const generationMatch = entry.name.match(
1468
+ /^generation-([0-9a-f-]{16,})\.(?:body|meta\.json)$/u,
1469
+ );
1470
+ if (!generationMatch) continue;
1471
+ const generation = generationMatch[1]!;
1472
+ if (generation === retainedGeneration) continue;
1473
+ const activeKey = `${keyHash(key)}:${generation}`;
1474
+ // A writer may have renamed one or both generation files while its
1475
+ // commit marker is still pending. Preserve it until that writer has
1476
+ // either committed or cleaned up its temporary files.
1477
+ if (
1478
+ this.activeGenerations.has(activeKey) ||
1479
+ this.hasGenerationLease(key, generation) ||
1480
+ this.hasKeyReadReservation(key) ||
1481
+ liveLeaseState.generations.has(generation)
1482
+ ) {
1483
+ continue;
1484
+ }
1485
+ await this.unlinkOwnedInternalFile(
1486
+ path.join(resolvedObjectPath, entry.name),
1487
+ );
1488
+ }
1489
+ // A crashed writer can leave body/metadata/marker temp files after its
1490
+ // lease expires. Keep only temps belonging to an active/live generation;
1491
+ // stale artifacts must be reclaimed during steady-state GC as well as
1492
+ // startup recovery.
1493
+ for (const entry of entries) {
1494
+ if (entry.isDirectory() || !entry.name.startsWith(TEMP_MARKER)) {
1495
+ continue;
1496
+ }
1497
+ if (entry.name.endsWith(`.${LEASE_SUFFIX}`)) continue;
1498
+ const tempGeneration = entry.name.match(/^tmp-([0-9a-f-]{16,})\./u)?.[1];
1499
+ if (
1500
+ tempGeneration &&
1501
+ (this.activeGenerations.has(`${keyHash(key)}:${tempGeneration}`) ||
1502
+ this.hasGenerationLease(key, tempGeneration) ||
1503
+ this.hasKeyReadReservation(key) ||
1504
+ liveLeaseState.generations.has(tempGeneration))
1505
+ ) {
1506
+ continue;
1507
+ }
1508
+ await this.unlinkOwnedInternalFile(
1509
+ path.join(resolvedObjectPath, entry.name),
1510
+ );
1511
+ }
1512
+ }
1513
+
263
1514
  async put(
264
1515
  key: string,
265
- value: ReadableStream | ArrayBuffer | string,
1516
+ value: Blob | ReadableStream | ArrayBuffer | string,
266
1517
  options?: {
267
1518
  httpMetadata?: ObjectMetadata["httpMetadata"];
268
1519
  customMetadata?: Record<string, string>;
269
1520
  },
270
1521
  ): Promise<void> {
1522
+ // Serialize before touching the filesystem. A metadata-shape failure must
1523
+ // leave the currently committed generation completely untouched.
1524
+ const metadataPayload = JSON.stringify({
1525
+ httpMetadata: options?.httpMetadata,
1526
+ customMetadata: options?.customMetadata,
1527
+ });
271
1528
  const filePath = this.getFilePath(key);
272
- const dir = path.dirname(filePath);
273
1529
 
274
1530
  await assertPathChainWithinBasePath(
275
1531
  await this.getRealBasePath(),
@@ -277,56 +1533,149 @@ export class BunStorage implements IObjectStorage {
277
1533
  realpath,
278
1534
  );
279
1535
 
280
- await mkdir(dir, { recursive: true });
281
-
282
- const realBasePath = await this.getRealBasePath();
283
- let realFilePath: string | null = null;
1536
+ const internalObjectPath = this.getInternalObjectPath(key);
1537
+ await assertPathChainWithinBasePath(
1538
+ await this.getRealInternalStorePath(),
1539
+ internalObjectPath,
1540
+ realpath,
1541
+ );
1542
+ await mkdir(internalObjectPath, { recursive: true });
1543
+ await this.assertRealInternalDirectory(internalObjectPath);
1544
+ await this.syncDirectory(path.dirname(internalObjectPath));
1545
+
1546
+ const previousCommit = await this.readCommitRecord(key);
1547
+ const previousGeneration =
1548
+ previousCommit?.state === "committed"
1549
+ ? previousCommit.generation
1550
+ : undefined;
1551
+
1552
+ // Body and metadata are prepared as one generation. Readers only follow
1553
+ // the commit marker, which is atomically replaced after both files are
1554
+ // complete and synced. This prevents bytes from one writer pairing with
1555
+ // metadata from another writer.
1556
+ const generation = crypto.randomUUID();
1557
+ const generationPath = this.getGenerationPath(key, generation, "body");
1558
+ const generationMetaPath = this.getGenerationPath(
1559
+ key,
1560
+ generation,
1561
+ "meta.json",
1562
+ );
1563
+ const commitPath = this.getCommitPath(key);
1564
+ const bodyTempPath = path.join(
1565
+ internalObjectPath,
1566
+ `${TEMP_MARKER}${generation}.body`,
1567
+ );
1568
+ const metadataTempPath = path.join(
1569
+ internalObjectPath,
1570
+ `${TEMP_MARKER}${generation}.meta.json`,
1571
+ );
1572
+ const commitTempPath = path.join(
1573
+ internalObjectPath,
1574
+ `${TEMP_MARKER}${generation}.commit.json`,
1575
+ );
1576
+ const activeGenerationKey = `${keyHash(key)}:${generation}`;
1577
+ this.activeGenerations.add(activeGenerationKey);
1578
+ let releaseLease: (() => Promise<void>) | undefined;
1579
+ let bodyHandle: FileHandle | undefined;
1580
+ let metadataHandle: FileHandle | undefined;
1581
+ let commitHandle: FileHandle | undefined;
1582
+ let bodyTempCreated = false;
1583
+ let metadataTempCreated = false;
1584
+ let commitTempCreated = false;
1585
+ let bodyGenerationCreated = false;
1586
+ let metadataGenerationCreated = false;
1587
+ let commitPublished = false;
284
1588
  try {
285
- realFilePath = await realpath(filePath);
286
- } catch {
287
- realFilePath = null;
288
- }
289
- if (realFilePath) {
290
- if (!isPathWithinBasePath(realBasePath, realFilePath)) {
291
- throw new Error("Path escapes base directory");
292
- }
293
- } else {
294
- const realDirPath = await realpath(dir);
295
- if (!isPathWithinBasePath(realBasePath, realDirPath)) {
296
- throw new Error("Path escapes base directory");
1589
+ releaseLease = await this.createTempLease(internalObjectPath, generation);
1590
+ bodyHandle = await open(bodyTempPath, "wx");
1591
+ bodyTempCreated = true;
1592
+ await writeValueToFile(bodyHandle, value);
1593
+ await syncAndClose(bodyHandle);
1594
+ bodyHandle = undefined;
1595
+ await rename(bodyTempPath, generationPath);
1596
+ await this.syncDirectory(internalObjectPath);
1597
+ bodyTempCreated = false;
1598
+ bodyGenerationCreated = true;
1599
+
1600
+ metadataHandle = await open(metadataTempPath, "wx");
1601
+ metadataTempCreated = true;
1602
+ await writeBufferFully(
1603
+ metadataHandle,
1604
+ new TextEncoder().encode(metadataPayload),
1605
+ );
1606
+ await syncAndClose(metadataHandle);
1607
+ metadataHandle = undefined;
1608
+ await rename(metadataTempPath, generationMetaPath);
1609
+ await this.syncDirectory(internalObjectPath);
1610
+ metadataTempCreated = false;
1611
+ metadataGenerationCreated = true;
1612
+
1613
+ const commitPayload: CommitRecord = {
1614
+ version: 1,
1615
+ key,
1616
+ keyHash: keyHash(key),
1617
+ state: "committed",
1618
+ generation,
1619
+ };
1620
+ commitHandle = await open(commitTempPath, "wx");
1621
+ commitTempCreated = true;
1622
+ await writeBufferFully(
1623
+ commitHandle,
1624
+ new TextEncoder().encode(JSON.stringify(commitPayload)),
1625
+ );
1626
+ await syncAndClose(commitHandle);
1627
+ commitHandle = undefined;
1628
+ await rename(commitTempPath, commitPath);
1629
+ // Rename makes the complete body+metadata generation authoritative.
1630
+ // Set both flags before fsync: if syncing the containing directory
1631
+ // fails, the marker still names a valid committed generation and must
1632
+ // never be deleted by the cleanup path below.
1633
+ commitTempCreated = false;
1634
+ commitPublished = true;
1635
+ await this.syncDirectory(internalObjectPath);
1636
+ } finally {
1637
+ if (bodyHandle) await bodyHandle.close().catch(() => undefined);
1638
+ if (metadataHandle) await metadataHandle.close().catch(() => undefined);
1639
+ if (commitHandle) await commitHandle.close().catch(() => undefined);
1640
+ if (!commitPublished) {
1641
+ if (bodyTempCreated) await this.unlinkOwnedInternalFile(bodyTempPath);
1642
+ if (metadataTempCreated)
1643
+ await this.unlinkOwnedInternalFile(metadataTempPath);
1644
+ if (commitTempCreated)
1645
+ await this.unlinkOwnedInternalFile(commitTempPath);
1646
+ if (bodyGenerationCreated)
1647
+ await this.unlinkOwnedInternalFile(generationPath);
1648
+ if (metadataGenerationCreated)
1649
+ await this.unlinkOwnedInternalFile(generationMetaPath);
297
1650
  }
1651
+ this.activeGenerations.delete(activeGenerationKey);
1652
+ await releaseLease?.();
298
1653
  }
299
1654
 
300
- const content = await toUint8Array(value);
301
- await Bun.write(filePath, content);
302
-
303
- if (options?.httpMetadata || options?.customMetadata) {
304
- await Bun.write(
305
- this.getMetaPath(key),
306
- JSON.stringify({
307
- httpMetadata: options.httpMetadata,
308
- customMetadata: options.customMetadata,
309
- }),
310
- );
1655
+ // Legacy body/sidecar files are no longer needed once the generation is
1656
+ // committed. Removing them is best-effort; the marker remains the source
1657
+ // of truth even if an operator-owned filesystem refuses cleanup.
1658
+ if (!this.isInternalStorePath(filePath)) {
1659
+ await unlink(filePath).catch(() => undefined);
311
1660
  }
1661
+ const legacyMetaPath = this.getMetaPath(key);
1662
+ if (!this.isInternalStorePath(legacyMetaPath)) {
1663
+ await unlink(legacyMetaPath).catch(() => undefined);
1664
+ }
1665
+ await this.removeGenerationIfUnreferenced(key, previousGeneration);
312
1666
  }
313
1667
 
314
1668
  async get(key: string): Promise<StorageObject | null> {
315
- const filePath = this.getFilePath(key);
316
-
1669
+ let releaseLease: (() => Promise<void>) | undefined;
317
1670
  try {
318
- const resolvedFilePath = await this.resolveExistingPath(filePath);
319
- if (!resolvedFilePath) return null;
320
- const file = Bun.file(resolvedFilePath);
321
- if (!(await file.exists())) return null;
322
-
323
- const content = new Uint8Array(await file.arrayBuffer());
324
- const resolvedMetaPath = await this.resolveExistingPath(
325
- this.getMetaPath(key),
326
- );
327
- const metadata = resolvedMetaPath
328
- ? await readMetadata(resolvedMetaPath)
329
- : {};
1671
+ const resolvedObject = await this.resolveObject(key, true);
1672
+ if (!resolvedObject) return null;
1673
+ releaseLease = resolvedObject.releaseLease;
1674
+
1675
+ const content = resolvedObject.bodyHandle
1676
+ ? await readFileHandleFully(resolvedObject.bodyHandle)
1677
+ : new Uint8Array(await Bun.file(resolvedObject.filePath).arrayBuffer());
1678
+ const metadata = resolvedObject.metadata;
330
1679
 
331
1680
  let bodyUsed = false;
332
1681
 
@@ -356,24 +1705,82 @@ export class BunStorage implements IObjectStorage {
356
1705
  };
357
1706
  } catch {
358
1707
  return null;
1708
+ } finally {
1709
+ await releaseLease?.();
359
1710
  }
360
1711
  }
361
1712
 
362
1713
  async delete(key: string | string[]): Promise<void> {
363
1714
  const keys = Array.isArray(key) ? key : [key];
364
1715
  for (const k of keys) {
1716
+ let markerPath: string;
1717
+ let legacyPath: string;
1718
+ let legacyMetaPath: string;
1719
+ try {
1720
+ markerPath = this.getCommitPath(k);
1721
+ legacyPath = this.getFilePath(k);
1722
+ legacyMetaPath = this.getMetaPath(k);
1723
+ } catch {
1724
+ // Preserve the historical delete contract: an invalid/traversal key
1725
+ // is ignored rather than becoming an observable filesystem error.
1726
+ continue;
1727
+ }
1728
+ try {
1729
+ await assertPathChainWithinBasePath(
1730
+ await this.getRealInternalStorePath(),
1731
+ markerPath,
1732
+ realpath,
1733
+ );
1734
+ } catch {
1735
+ // Do not create a tombstone through a symlinked path outside storage.
1736
+ continue;
1737
+ }
1738
+ const markerExists = await Bun.file(markerPath).exists();
1739
+ const legacyExists =
1740
+ !this.isInternalStorePath(legacyPath) &&
1741
+ (await Bun.file(legacyPath).exists());
1742
+ const legacyMetaExists =
1743
+ !this.isInternalStorePath(legacyMetaPath) &&
1744
+ (await Bun.file(legacyMetaPath).exists());
1745
+ if (!markerExists && !legacyExists && !legacyMetaExists) continue;
1746
+
1747
+ let previousGeneration: string | undefined;
365
1748
  try {
366
- const filePath = await this.resolveExistingPath(this.getFilePath(k));
367
- if (filePath) await unlink(filePath);
1749
+ const previousCommit = await this.readCommitRecord(k);
1750
+ previousGeneration =
1751
+ previousCommit?.state === "committed"
1752
+ ? previousCommit.generation
1753
+ : undefined;
1754
+ } catch {
1755
+ // A malformed marker is still replaced with a tombstone below; no
1756
+ // generation is trusted for eager cleanup in that case.
1757
+ }
1758
+
1759
+ // Publish a tombstone before removing legacy files. If cleanup is
1760
+ // interrupted, the tombstone still prevents a stale legacy body from
1761
+ // resurfacing through the compatibility read path.
1762
+ await this.writeCommitRecord(k, {
1763
+ version: 1,
1764
+ state: "deleted",
1765
+ generation: null,
1766
+ });
1767
+ try {
1768
+ if (!this.isInternalStorePath(legacyPath)) {
1769
+ const filePath = await this.resolveExistingPath(legacyPath);
1770
+ if (filePath) await unlink(filePath);
1771
+ }
368
1772
  } catch {
369
1773
  /* ignore */
370
1774
  }
371
1775
  try {
372
- const metaPath = await this.resolveExistingPath(this.getMetaPath(k));
373
- if (metaPath) await unlink(metaPath);
1776
+ if (!this.isInternalStorePath(legacyMetaPath)) {
1777
+ const metaPath = await this.resolveExistingPath(legacyMetaPath);
1778
+ if (metaPath) await unlink(metaPath);
1779
+ }
374
1780
  } catch {
375
1781
  /* ignore */
376
1782
  }
1783
+ await this.removeGenerationIfUnreferenced(k, previousGeneration);
377
1784
  }
378
1785
  }
379
1786
 
@@ -385,6 +1792,9 @@ export class BunStorage implements IObjectStorage {
385
1792
  }): Promise<ListObjectsResult> {
386
1793
  const objects: ListObjectsResult["objects"] = [];
387
1794
  const realBasePath = await this.getRealBasePath();
1795
+ const committed = new Map<string, ResolvedObject>();
1796
+ const deleted = new Set<string>();
1797
+ const legacy: Array<{ key: string; filePath: string }> = [];
388
1798
 
389
1799
  const readDirRecursive = async (dir: string, prefix: string = "") => {
390
1800
  try {
@@ -397,15 +1807,8 @@ export class BunStorage implements IObjectStorage {
397
1807
 
398
1808
  if (entry.isDirectory()) {
399
1809
  await readDirRecursive(fullPath, key);
400
- } else if (!entry.name.endsWith(".meta.json")) {
401
- if (!options?.prefix || key.startsWith(options.prefix)) {
402
- const stats = await stat(fullPath);
403
- objects.push({
404
- key,
405
- size: stats.size,
406
- uploaded: stats.mtime,
407
- });
408
- }
1810
+ } else if (!isInternalStorageName(entry.name)) {
1811
+ legacy.push({ key, filePath: fullPath });
409
1812
  }
410
1813
  }
411
1814
  } catch {
@@ -415,6 +1818,93 @@ export class BunStorage implements IObjectStorage {
415
1818
 
416
1819
  await readDirRecursive(realBasePath);
417
1820
 
1821
+ // Enumerate only validated commit records from the hidden generation
1822
+ // store. This is deliberately separate from the legacy body scan so no
1823
+ // user key can collide with an internal filename.
1824
+ const internalRoot = this.getInternalStorePath();
1825
+ try {
1826
+ const resolvedRoot = await this.resolveExistingInternalPath(internalRoot);
1827
+ if (resolvedRoot) {
1828
+ const objectEntries = await readdir(resolvedRoot, {
1829
+ withFileTypes: true,
1830
+ });
1831
+ for (const objectEntry of objectEntries) {
1832
+ if (
1833
+ !objectEntry.isDirectory() ||
1834
+ !DIGEST_PATTERN.test(objectEntry.name)
1835
+ ) {
1836
+ continue;
1837
+ }
1838
+ const objectPath = path.join(resolvedRoot, objectEntry.name);
1839
+ const resolvedObjectPath =
1840
+ await this.resolveExistingInternalPath(objectPath);
1841
+ if (!resolvedObjectPath) continue;
1842
+ const markerPath = path.join(resolvedObjectPath, COMMIT_FILE);
1843
+ try {
1844
+ const record = JSON.parse(
1845
+ await this.readOwnedInternalFileText(markerPath),
1846
+ ) as Partial<CommitRecord>;
1847
+ if (
1848
+ record.version !== 1 ||
1849
+ typeof record.key !== "string" ||
1850
+ record.keyHash !== objectEntry.name ||
1851
+ keyHash(record.key) !== objectEntry.name
1852
+ ) {
1853
+ continue;
1854
+ }
1855
+ if (record.state === "deleted" && record.generation === null) {
1856
+ deleted.add(record.key);
1857
+ continue;
1858
+ }
1859
+ if (
1860
+ record.state !== "committed" ||
1861
+ typeof record.generation !== "string" ||
1862
+ !GENERATION_ID_PATTERN.test(record.generation)
1863
+ ) {
1864
+ continue;
1865
+ }
1866
+ const resolved = await this.resolveObject(record.key, true);
1867
+ if (resolved) committed.set(record.key, resolved);
1868
+ } catch {
1869
+ // A malformed or incomplete marker is never exposed as an object.
1870
+ }
1871
+ }
1872
+ }
1873
+ } catch {
1874
+ // The hidden store may not exist yet.
1875
+ }
1876
+
1877
+ for (const [key, resolved] of committed) {
1878
+ try {
1879
+ if (deleted.has(key)) continue;
1880
+ if (options?.prefix && !key.startsWith(options.prefix)) continue;
1881
+ const stats = resolved.bodyHandle
1882
+ ? await resolved.bodyHandle.stat()
1883
+ : await stat(resolved.filePath);
1884
+ objects.push({ key, size: stats.size, uploaded: stats.mtime });
1885
+ } catch {
1886
+ // The generation disappeared between the marker and this read.
1887
+ } finally {
1888
+ await resolved.releaseLease?.();
1889
+ }
1890
+ }
1891
+ for (const candidate of legacy) {
1892
+ if (committed.has(candidate.key) || deleted.has(candidate.key)) continue;
1893
+ if (options?.prefix && !candidate.key.startsWith(options.prefix)) {
1894
+ continue;
1895
+ }
1896
+ try {
1897
+ const stats = await stat(candidate.filePath);
1898
+ objects.push({
1899
+ key: candidate.key,
1900
+ size: stats.size,
1901
+ uploaded: stats.mtime,
1902
+ });
1903
+ } catch {
1904
+ // The legacy object disappeared between traversal and stat.
1905
+ }
1906
+ }
1907
+
418
1908
  const limit = options?.limit ?? 1000;
419
1909
  const truncated = objects.length > limit;
420
1910
 
@@ -426,27 +1916,23 @@ export class BunStorage implements IObjectStorage {
426
1916
  }
427
1917
 
428
1918
  async head(key: string): Promise<ObjectMetadata | null> {
429
- const filePath = this.getFilePath(key);
430
-
1919
+ let releaseLease: (() => Promise<void>) | undefined;
431
1920
  try {
432
- const resolvedFilePath = await this.resolveExistingPath(filePath);
433
- if (!resolvedFilePath) return null;
434
- const file = Bun.file(resolvedFilePath);
435
- if (!(await file.exists())) return null;
436
-
437
- const resolvedMetaPath = await this.resolveExistingPath(
438
- this.getMetaPath(key),
439
- );
440
- const metadata = resolvedMetaPath
441
- ? await readMetadata(resolvedMetaPath)
442
- : {};
1921
+ const resolvedObject = await this.resolveObject(key, true);
1922
+ if (!resolvedObject) return null;
1923
+ releaseLease = resolvedObject.releaseLease;
1924
+ const metadata = resolvedObject.metadata;
443
1925
  return {
444
- contentLength: file.size,
1926
+ contentLength: resolvedObject.bodyHandle
1927
+ ? (await resolvedObject.bodyHandle.stat()).size
1928
+ : Bun.file(resolvedObject.filePath).size,
445
1929
  httpMetadata: metadata.httpMetadata,
446
1930
  customMetadata: metadata.customMetadata,
447
1931
  };
448
1932
  } catch {
449
1933
  return null;
1934
+ } finally {
1935
+ await releaseLease?.();
450
1936
  }
451
1937
  }
452
1938
  }