@h-ai/storage 1.0.0-beta.2 → 1.0.0-beta.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/node.js CHANGED
@@ -1,8 +1,8 @@
1
- import { HaiStorageError, StorageConfigSchema, PresignUploadOptionsSchema, PresignOptionsSchema } from './chunk-ANAESRWS.js';
2
- export { HaiStorageError, LocalConfigSchema, PresignOptionsSchema, PresignUploadOptionsSchema, S3ConfigSchema, StorageConfigSchema, StorageOperationLogConfigSchema, StorageTypeSchema } from './chunk-ANAESRWS.js';
3
- import { storageM, MIME_TYPES, MIME_TYPE_DEFAULT } from './chunk-TLZY4IP6.js';
4
- export { downloadAndSave, downloadWithPresignedUrl, formatFileSize, getFileExtension, getMimeType, uploadWithPresignedUrl } from './chunk-TLZY4IP6.js';
5
- export { STORAGE_DELETE_MAX_KEYS, STORAGE_KEY_MAX_BYTES, StorageDeleteKeysSchema, StorageKeySchema, StoragePrefixSchema, StoragePresignExpiresInSchema, getStorageKeyIssue } from './chunk-P65WYXJG.js';
1
+ import { HaiStorageError, StorageConfigSchema, PresignUploadOptionsSchema, PresignOptionsSchema } from './chunk-FK7KRCML.js';
2
+ export { HaiStorageError, LocalConfigSchema, PresignOptionsSchema, PresignUploadOptionsSchema, S3ConfigSchema, StorageConfigSchema, StorageOperationLogConfigSchema, StorageTypeSchema } from './chunk-FK7KRCML.js';
3
+ import { storageM, MIME_TYPES, MIME_TYPE_DEFAULT } from './chunk-JAJYHGLI.js';
4
+ export { downloadAndSave, downloadWithPresignedUrl, formatFileSize, getFileExtension, getMimeType, uploadWithPresignedUrl } from './chunk-JAJYHGLI.js';
5
+ export { STORAGE_DELETE_MAX_KEYS, STORAGE_KEY_MAX_BYTES, StorageDeleteKeysSchema, StorageKeySchema, StoragePrefixSchema, StoragePresignExpiresInSchema, getStorageKeyIssue } from './chunk-6YG3N3CW.js';
6
6
  import process from 'node:process';
7
7
  import { core, err, ok } from '@h-ai/core';
8
8
  import { Buffer } from 'node:buffer';
@@ -23,7 +23,9 @@ function toStorageDeleteResult(summary) {
23
23
  success: false,
24
24
  error: {
25
25
  ...HaiStorageError.OPERATION_FAILED,
26
- message: storageM("storage_deleteManyFailed", { params: { count: summary.failures.length } }),
26
+ message: storageM("storage_deleteManyFailed", {
27
+ params: { count: summary.failures.length }
28
+ }),
27
29
  ext: { deleteMany: summary }
28
30
  }
29
31
  };
@@ -113,22 +115,33 @@ function logStorageOperation(logger4, config, category, operation, payload) {
113
115
  // src/providers/storage-provider-local.ts
114
116
  var logger = core.logger.child({ module: "storage", scope: "provider-local" });
115
117
  var PATH_SEGMENT_SPLIT_REGEX = /[\\/]+/;
118
+ var METADATA_DIRECTORY = ".hai-storage-meta";
116
119
  function toStorageError(error, key) {
117
120
  const e = error;
118
121
  if (e.code === "EEXIST") {
119
- return { ...HaiStorageError.ALREADY_EXISTS, message: storageM("storage_fileAlreadyExists", { params: { key: key ?? "" } }), cause: error };
122
+ return {
123
+ ...HaiStorageError.ALREADY_EXISTS,
124
+ message: storageM("storage_fileAlreadyExists", {
125
+ params: { key: key ?? "" }
126
+ }),
127
+ cause: error
128
+ };
120
129
  }
121
130
  if (e.code === "ENOENT") {
122
131
  return {
123
132
  ...HaiStorageError.NOT_FOUND,
124
- message: storageM("storage_fileNotFound", { params: { key: key ?? "" } }),
133
+ message: storageM("storage_fileNotFound", {
134
+ params: { key: key ?? "" }
135
+ }),
125
136
  cause: error
126
137
  };
127
138
  }
128
139
  if (e.code === "EACCES" || e.code === "EPERM") {
129
140
  return {
130
141
  ...HaiStorageError.PERMISSION_DENIED,
131
- message: storageM("storage_permissionDenied", { params: { key: key ?? "" } }),
142
+ message: storageM("storage_permissionDenied", {
143
+ params: { key: key ?? "" }
144
+ }),
132
145
  cause: error
133
146
  };
134
147
  }
@@ -142,7 +155,9 @@ function toStorageError(error, key) {
142
155
  if (e.code === "EISDIR") {
143
156
  return {
144
157
  ...HaiStorageError.INVALID_PATH,
145
- message: storageM("storage_pathIsDir", { params: { key: key ?? "" } }),
158
+ message: storageM("storage_pathIsDir", {
159
+ params: { key: key ?? "" }
160
+ }),
146
161
  cause: error
147
162
  };
148
163
  }
@@ -154,8 +169,8 @@ function toStorageError(error, key) {
154
169
  };
155
170
  }
156
171
  return {
157
- code: HaiStorageError.IO_ERROR.code,
158
- message: storageM("storage_ioError", { params: { error: e.message ?? "" } }),
172
+ ...HaiStorageError.IO_ERROR,
173
+ message: storageM("storage_ioError"),
159
174
  cause: error
160
175
  };
161
176
  }
@@ -164,10 +179,33 @@ function getMimeType2(filePath) {
164
179
  return MIME_TYPES[ext] || MIME_TYPE_DEFAULT;
165
180
  }
166
181
  function calculateEtag(stat2) {
167
- return `"${stat2.size.toString(16)}-${stat2.mtimeMs.toString(16)}"`;
182
+ const modified = "mtimeNs" in stat2 ? Number(stat2.mtimeNs) / 1e6 : stat2.mtimeMs;
183
+ return `"${stat2.size.toString(16)}-${modified.toString(16)}"`;
184
+ }
185
+ function metadataGeneration(stat2) {
186
+ return crypto.createHash("sha256").update([
187
+ stat2.dev,
188
+ stat2.ino,
189
+ stat2.birthtimeNs,
190
+ stat2.mtimeNs,
191
+ stat2.size
192
+ ].join(":")).digest("hex");
193
+ }
194
+ function isMetadataRecord(value) {
195
+ if (!value || typeof value !== "object" || Array.isArray(value))
196
+ return false;
197
+ if ("contentType" in value && typeof value.contentType !== "string")
198
+ return false;
199
+ if ("metadata" in value) {
200
+ const metadata = value.metadata;
201
+ if (!metadata || typeof metadata !== "object" || Array.isArray(metadata) || Object.values(metadata).some((item) => typeof item !== "string")) {
202
+ return false;
203
+ }
204
+ }
205
+ return true;
168
206
  }
169
207
  function isMetaFile(key) {
170
- return key.endsWith(".meta.json") || key.endsWith(".upload.tmp");
208
+ return key.toLowerCase().split(PATH_SEGMENT_SPLIT_REGEX).some((segment) => segment === METADATA_DIRECTORY || segment.endsWith(".meta.json") || segment.endsWith(".upload.tmp"));
171
209
  }
172
210
  function createInvalidPathError() {
173
211
  const error = new Error(storageM("storage_pathTraversal"));
@@ -182,7 +220,7 @@ function safePath(root, key) {
182
220
  if (path.isAbsolute(key)) {
183
221
  throw createInvalidPathError();
184
222
  }
185
- if (key.includes("\0") || hasPathTraversalSegment(key)) {
223
+ if (key.includes("\0") || hasPathTraversalSegment(key) || process.platform === "win32" && key.split(PATH_SEGMENT_SPLIT_REGEX).some((segment) => /[:. ]$/.test(segment) || segment.includes(":"))) {
186
224
  throw createInvalidPathError();
187
225
  }
188
226
  const normalized = path.normalize(key);
@@ -203,52 +241,96 @@ function createLocalProvider() {
203
241
  }
204
242
  return config;
205
243
  }
206
- function fullPath(key) {
207
- return safePath(getConfig().root, key);
244
+ async function fullPath(key) {
245
+ const root = getConfig().root;
246
+ const target = safePath(root, key);
247
+ let current = root;
248
+ for (const segment of path.relative(root, target).split(path.sep).filter(Boolean)) {
249
+ current = path.join(current, segment);
250
+ try {
251
+ if ((await fsp.lstat(current)).isSymbolicLink())
252
+ throw createInvalidPathError();
253
+ } catch (error) {
254
+ if (error.code === "ENOENT")
255
+ break;
256
+ throw error;
257
+ }
258
+ }
259
+ return target;
208
260
  }
209
261
  function logOperation(category, operation, payload) {
210
262
  logStorageOperation(logger, config?.operationLog, category, operation, payload);
211
263
  }
264
+ function metadataKey(filePath, stat2) {
265
+ return path.relative(getConfig().root, path.join(path.dirname(filePath), METADATA_DIRECTORY, `${metadataGeneration(stat2)}.meta.json`));
266
+ }
212
267
  const file = {
213
268
  async put(key, data, options = {}) {
214
269
  if (isMetaFile(key)) {
215
270
  return err(
216
271
  HaiStorageError.INVALID_PATH,
217
- storageM("storage_metaFileAccess", { params: { key } })
272
+ storageM("storage_metaFileAccess", {
273
+ params: { key }
274
+ })
218
275
  );
219
276
  }
220
277
  let temporaryPath;
278
+ let preparedMetadataPath;
279
+ let published = false;
221
280
  try {
222
- const filePath = fullPath(key);
281
+ const filePath = await fullPath(key);
282
+ const cfg = getConfig();
283
+ let previousMetadataKey;
284
+ try {
285
+ previousMetadataKey = metadataKey(filePath, await fsp.stat(filePath, { bigint: true }));
286
+ } catch (error) {
287
+ if (error.code !== "ENOENT")
288
+ throw error;
289
+ }
223
290
  logOperation("write", "file.put", { key, options });
224
291
  const dir2 = path.dirname(filePath);
225
- await fsp.mkdir(dir2, { recursive: true, mode: getConfig().directoryMode });
292
+ await fsp.mkdir(dir2, { recursive: true, mode: cfg.directoryMode });
226
293
  temporaryPath = `${filePath}.${crypto.randomUUID()}.upload.tmp`;
227
294
  await pipeline(
228
295
  Readable.from(toByteStream(data, options.signal), { objectMode: false }),
229
- fs.createWriteStream(temporaryPath, { flags: "wx", mode: getConfig().fileMode }),
296
+ fs.createWriteStream(temporaryPath, { flags: "wx", mode: cfg.fileMode }),
230
297
  { signal: options.signal }
231
298
  );
232
299
  options.signal?.throwIfAborted();
300
+ const stat2 = await fsp.stat(temporaryPath, { bigint: true });
301
+ const metaPath = await fullPath(metadataKey(filePath, stat2));
302
+ const serializedMetadata = JSON.stringify({ contentType: options.contentType, metadata: options.metadata });
303
+ await fsp.mkdir(path.dirname(metaPath), { recursive: true, mode: cfg.directoryMode });
304
+ const metadataFile = await fsp.open(metaPath, "wx", cfg.fileMode);
305
+ preparedMetadataPath = metaPath;
306
+ try {
307
+ await metadataFile.writeFile(serializedMetadata);
308
+ } finally {
309
+ await metadataFile.close();
310
+ }
311
+ options.signal?.throwIfAborted();
233
312
  if (options.ifNoneMatch === "*")
234
313
  await fsp.link(temporaryPath, filePath);
235
314
  else
236
315
  await fsp.rename(temporaryPath, filePath);
237
- const stat2 = await fsp.stat(filePath);
316
+ published = true;
238
317
  const metadata = {
239
318
  key,
240
- size: stat2.size,
319
+ size: Number(stat2.size),
241
320
  contentType: options.contentType || getMimeType2(key),
242
321
  lastModified: stat2.mtime,
243
322
  etag: calculateEtag(stat2),
244
323
  metadata: options.metadata
245
324
  };
246
- if (options.metadata || options.contentType) {
247
- const metaPath = `${filePath}.meta.json`;
248
- await fsp.writeFile(metaPath, JSON.stringify({
249
- contentType: options.contentType,
250
- metadata: options.metadata
251
- }));
325
+ if (previousMetadataKey) {
326
+ try {
327
+ const previousMetadataPath = await fullPath(previousMetadataKey);
328
+ if (previousMetadataPath !== metaPath)
329
+ await fsp.unlink(previousMetadataPath);
330
+ } catch (error) {
331
+ if (error.code !== "ENOENT")
332
+ logger.warn("Obsolete local metadata cleanup failed", { key });
333
+ }
252
334
  }
253
335
  return ok(metadata);
254
336
  } catch (error) {
@@ -258,6 +340,8 @@ function createLocalProvider() {
258
340
  if (temporaryPath)
259
341
  await fsp.unlink(temporaryPath).catch(() => {
260
342
  });
343
+ if (preparedMetadataPath && !published)
344
+ await fsp.unlink(preparedMetadataPath).catch(() => logger.warn("Unpublished local metadata cleanup failed", { key }));
261
345
  }
262
346
  },
263
347
  async get(key, options = {}) {
@@ -277,16 +361,21 @@ function createLocalProvider() {
277
361
  if (isMetaFile(key)) {
278
362
  return err(
279
363
  HaiStorageError.INVALID_PATH,
280
- storageM("storage_metaFileAccess", { params: { key } })
364
+ storageM("storage_metaFileAccess", {
365
+ params: { key }
366
+ })
281
367
  );
282
368
  }
283
369
  try {
284
- const filePath = fullPath(key);
370
+ const filePath = await fullPath(key);
285
371
  logOperation("read", "file.getStream", { key, options });
286
372
  options.signal?.throwIfAborted();
287
373
  const stat2 = await fsp.stat(filePath);
288
- if (stat2.isDirectory())
289
- return err(HaiStorageError.INVALID_PATH, storageM("storage_pathIsDir", { params: { key } }));
374
+ if (stat2.isDirectory()) {
375
+ return err(HaiStorageError.INVALID_PATH, storageM("storage_pathIsDir", {
376
+ params: { key }
377
+ }));
378
+ }
290
379
  async function* read() {
291
380
  const stream = fs.createReadStream(filePath, { start: options.rangeStart, end: options.rangeEnd, signal: options.signal });
292
381
  for await (const chunk of stream)
@@ -301,35 +390,59 @@ function createLocalProvider() {
301
390
  if (isMetaFile(key)) {
302
391
  return err(
303
392
  HaiStorageError.INVALID_PATH,
304
- storageM("storage_metaFileAccess", { params: { key } })
393
+ storageM("storage_metaFileAccess", {
394
+ params: { key }
395
+ })
305
396
  );
306
397
  }
307
398
  try {
308
- const filePath = fullPath(key);
399
+ const filePath = await fullPath(key);
309
400
  logOperation("read", "file.head", { key });
310
- const stat2 = await fsp.stat(filePath);
311
- if (stat2.isDirectory()) {
312
- return err(
313
- HaiStorageError.INVALID_PATH,
314
- storageM("storage_pathIsDir", { params: { key } })
315
- );
316
- }
317
- let customMetadata = {};
318
- try {
319
- const metaPath = `${filePath}.meta.json`;
320
- const metaContent = await fsp.readFile(metaPath, "utf-8");
321
- customMetadata = JSON.parse(metaContent);
322
- } catch {
401
+ for (let attempt = 0; attempt < 3; attempt++) {
402
+ const stat2 = await fsp.stat(filePath, { bigint: true });
403
+ if (stat2.isDirectory()) {
404
+ return err(HaiStorageError.INVALID_PATH, storageM("storage_pathIsDir", {
405
+ params: { key }
406
+ }));
407
+ }
408
+ let serializedMetadata;
409
+ try {
410
+ const metaPath = await fullPath(metadataKey(filePath, stat2));
411
+ serializedMetadata = await fsp.readFile(metaPath, "utf8");
412
+ } catch (error) {
413
+ if (!["ENOENT", "ENOTDIR"].includes(error.code ?? ""))
414
+ throw error;
415
+ if (metadataGeneration(await fsp.stat(filePath, { bigint: true })) !== metadataGeneration(stat2))
416
+ continue;
417
+ return err(HaiStorageError.OPERATION_FAILED, storageM("storage_metadataRequired"));
418
+ }
419
+ let customMetadata;
420
+ try {
421
+ customMetadata = JSON.parse(serializedMetadata);
422
+ } catch {
423
+ logger.warn("Invalid local metadata record", {
424
+ key,
425
+ generation: metadataGeneration(stat2)
426
+ });
427
+ return err(HaiStorageError.OPERATION_FAILED, storageM("storage_metadataInvalid"));
428
+ }
429
+ if (!isMetadataRecord(customMetadata)) {
430
+ logger.warn("Invalid local metadata shape", {
431
+ key,
432
+ generation: metadataGeneration(stat2)
433
+ });
434
+ return err(HaiStorageError.OPERATION_FAILED, storageM("storage_metadataInvalid"));
435
+ }
436
+ return ok({
437
+ key,
438
+ size: Number(stat2.size),
439
+ contentType: customMetadata.contentType || getMimeType2(key),
440
+ lastModified: stat2.mtime,
441
+ etag: calculateEtag(stat2),
442
+ metadata: customMetadata.metadata
443
+ });
323
444
  }
324
- const etag = `"${stat2.size.toString(16)}-${stat2.mtimeMs.toString(16)}"`;
325
- return ok({
326
- key,
327
- size: stat2.size,
328
- contentType: customMetadata.contentType || getMimeType2(key),
329
- lastModified: stat2.mtime,
330
- etag,
331
- metadata: customMetadata.metadata
332
- });
445
+ return err(HaiStorageError.OPERATION_FAILED, storageM("storage_fileChanged"));
333
446
  } catch (error) {
334
447
  return err(toStorageError(error, key));
335
448
  }
@@ -338,11 +451,13 @@ function createLocalProvider() {
338
451
  if (isMetaFile(key)) {
339
452
  return err(
340
453
  HaiStorageError.INVALID_PATH,
341
- storageM("storage_metaFileAccess", { params: { key } })
454
+ storageM("storage_metaFileAccess", {
455
+ params: { key }
456
+ })
342
457
  );
343
458
  }
344
459
  try {
345
- const filePath = fullPath(key);
460
+ const filePath = await fullPath(key);
346
461
  logOperation("read", "file.exists", { key });
347
462
  await fsp.access(filePath, fs.constants.F_OK);
348
463
  return ok(true);
@@ -357,17 +472,22 @@ function createLocalProvider() {
357
472
  if (isMetaFile(key)) {
358
473
  return err(
359
474
  HaiStorageError.INVALID_PATH,
360
- storageM("storage_metaFileAccess", { params: { key } })
475
+ storageM("storage_metaFileAccess", {
476
+ params: { key }
477
+ })
361
478
  );
362
479
  }
363
480
  try {
364
- const filePath = fullPath(key);
481
+ const filePath = await fullPath(key);
482
+ const generationKey = metadataKey(filePath, await fsp.stat(filePath, { bigint: true }));
365
483
  logOperation("write", "file.delete", { key });
366
484
  logger.debug("Deleting file", { key });
367
485
  await fsp.unlink(filePath);
368
486
  try {
369
- await fsp.unlink(`${filePath}.meta.json`);
370
- } catch {
487
+ await fsp.unlink(await fullPath(generationKey));
488
+ } catch (error) {
489
+ if (error.code !== "ENOENT")
490
+ logger.warn("Deleted object metadata cleanup failed", { key });
371
491
  }
372
492
  return ok(void 0);
373
493
  } catch (error) {
@@ -381,57 +501,47 @@ function createLocalProvider() {
381
501
  async deleteMany(keys) {
382
502
  logOperation("write", "file.deleteMany", { count: keys.length, keys });
383
503
  const summary = { deletedKeys: [], failures: [] };
384
- const uniqueKeys = [...new Set(keys)];
504
+ const uniqueKeys = [
505
+ ...new Set(keys)
506
+ ];
385
507
  for (let offset = 0; offset < uniqueKeys.length; offset += 32) {
386
508
  const batch = uniqueKeys.slice(offset, offset + 32);
387
509
  const results = await Promise.allSettled(batch.map((key) => file.delete(key)));
388
510
  for (const [index, result] of results.entries()) {
389
511
  const key = batch[index];
390
- if (result.status === "rejected")
391
- summary.failures.push({ key, code: "UnexpectedDeleteFailure", retryable: false, outcome: "failed" });
392
- else if (!result.value.success)
393
- summary.failures.push({ key, code: String(result.value.error.code), retryable: false, outcome: "failed" });
394
- else
512
+ if (result.status === "rejected") {
513
+ summary.failures.push({
514
+ key,
515
+ code: "UnexpectedDeleteFailure",
516
+ retryable: false,
517
+ outcome: "failed"
518
+ });
519
+ } else if (!result.value.success) {
520
+ summary.failures.push({
521
+ key,
522
+ code: String(result.value.error.code),
523
+ retryable: false,
524
+ outcome: "failed"
525
+ });
526
+ } else {
395
527
  summary.deletedKeys.push(key);
528
+ }
396
529
  }
397
530
  }
398
531
  return toStorageDeleteResult(summary);
399
532
  },
400
533
  async copy(sourceKey, destKey, options = {}) {
401
- if (isMetaFile(sourceKey) || isMetaFile(destKey)) {
402
- return err(
403
- HaiStorageError.INVALID_PATH,
404
- storageM("storage_metaFileAccess", { params: { key: sourceKey } }),
405
- sourceKey
406
- );
407
- }
408
- try {
409
- const sourcePath = fullPath(sourceKey);
410
- const destPath = fullPath(destKey);
411
- logOperation("write", "file.copy", { sourceKey, destKey, options });
412
- logger.debug("Copying file", { sourceKey, destKey });
413
- await fsp.mkdir(path.dirname(destPath), { recursive: true, mode: getConfig().directoryMode });
414
- await fsp.copyFile(sourcePath, destPath);
415
- const stat2 = await fsp.stat(destPath);
416
- const metadata = {
417
- key: destKey,
418
- size: stat2.size,
419
- contentType: options.contentType || getMimeType2(destKey),
420
- lastModified: stat2.mtime,
421
- etag: calculateEtag(stat2),
422
- metadata: options.metadata
423
- };
424
- if (options.contentType || options.metadata) {
425
- const metaPath = `${destPath}.meta.json`;
426
- await fsp.writeFile(metaPath, JSON.stringify({
427
- contentType: options.contentType,
428
- metadata: options.metadata
429
- }));
430
- }
431
- return ok(metadata);
432
- } catch (error) {
433
- return err(toStorageError(error, sourceKey));
434
- }
534
+ logOperation("write", "file.copy", { sourceKey, destKey, options });
535
+ const source = await file.head(sourceKey);
536
+ if (!source.success)
537
+ return source;
538
+ const stream = await file.getStream(sourceKey);
539
+ if (!stream.success)
540
+ return stream;
541
+ return file.put(destKey, stream.data, {
542
+ contentType: options.contentType ?? source.data.contentType,
543
+ metadata: options.metadata ?? source.data.metadata
544
+ });
435
545
  }
436
546
  };
437
547
  const dir = {
@@ -507,7 +617,12 @@ function createLocalProvider() {
507
617
  commonPrefixes.push(logicalKey);
508
618
  } else {
509
619
  const stat2 = await fsp.stat(entry.filePath);
510
- files.push({ key: entry.key, size: stat2.size, contentType: getMimeType2(entry.key), lastModified: stat2.mtime });
620
+ files.push({
621
+ key: entry.key,
622
+ size: stat2.size,
623
+ contentType: getMimeType2(entry.key),
624
+ lastModified: stat2.mtime
625
+ });
511
626
  }
512
627
  count++;
513
628
  last = logicalKey;
@@ -516,15 +631,22 @@ function createLocalProvider() {
516
631
  files,
517
632
  commonPrefixes,
518
633
  isTruncated,
519
- ...isTruncated ? { nextContinuationToken: Buffer.from(JSON.stringify([1, scope, last])).toString("base64url") } : {}
634
+ ...isTruncated ? {
635
+ nextContinuationToken: Buffer.from(JSON.stringify([1, scope, last])).toString("base64url")
636
+ } : {}
520
637
  });
521
638
  } catch (error) {
522
639
  return err(toStorageError(error));
523
640
  }
524
641
  },
525
642
  async delete(prefix) {
643
+ if (isMetaFile(prefix)) {
644
+ return err(HaiStorageError.INVALID_PATH, storageM("storage_metaFileAccess", {
645
+ params: { key: prefix }
646
+ }));
647
+ }
526
648
  try {
527
- const dirPath = fullPath(prefix);
649
+ const dirPath = await fullPath(prefix);
528
650
  logOperation("write", "dir.delete", { prefix });
529
651
  await fsp.rm(dirPath, { recursive: true, force: true });
530
652
  return ok(void 0);
@@ -566,6 +688,7 @@ function createLocalProvider() {
566
688
  config = cfg;
567
689
  logger.info("Connecting local provider", { root: cfg.root });
568
690
  await fsp.mkdir(cfg.root, { recursive: true, mode: cfg.directoryMode });
691
+ config = { ...cfg, root: await fsp.realpath(cfg.root) };
569
692
  connected = true;
570
693
  logger.info("Local provider connected", { root: cfg.root });
571
694
  return ok(void 0);
@@ -589,22 +712,38 @@ var logger2 = core.logger.child({ module: "storage", scope: "provider-s3" });
589
712
  var TRAILING_SLASHES_REGEX = /\/+$/;
590
713
  var LEADING_SLASHES_REGEX = /^\/+/;
591
714
  var DELETE_BATCH_SIZE = 1e3;
592
- var RETRYABLE_DELETE_CODES = /* @__PURE__ */ new Set(["InternalError", "ServiceUnavailable", "SlowDown", "RequestTimeout"]);
715
+ var RETRYABLE_DELETE_CODES = /* @__PURE__ */ new Set([
716
+ "InternalError",
717
+ "ServiceUnavailable",
718
+ "SlowDown",
719
+ "RequestTimeout"
720
+ ]);
593
721
  function toStorageError2(error, key) {
594
722
  const e = error;
595
- if (e.name === "PreconditionFailed" || e.$metadata?.httpStatusCode === 412)
596
- return { ...HaiStorageError.ALREADY_EXISTS, message: storageM("storage_fileAlreadyExists", { params: { key: key ?? "" } }), cause: error };
723
+ if (e.name === "PreconditionFailed" || e.$metadata?.httpStatusCode === 412) {
724
+ return {
725
+ ...HaiStorageError.ALREADY_EXISTS,
726
+ message: storageM("storage_fileAlreadyExists", {
727
+ params: { key: key ?? "" }
728
+ }),
729
+ cause: error
730
+ };
731
+ }
597
732
  if (e.name === "NoSuchKey" || e.Code === "NoSuchKey" || e.name === "NotFound" || e.Code === "NotFound" || e.name === "404" || e.$metadata?.httpStatusCode === 404) {
598
733
  return {
599
734
  ...HaiStorageError.NOT_FOUND,
600
- message: storageM("storage_fileNotFound", { params: { key: key ?? "" } }),
735
+ message: storageM("storage_fileNotFound", {
736
+ params: { key: key ?? "" }
737
+ }),
601
738
  cause: error
602
739
  };
603
740
  }
604
741
  if (e.name === "AccessDenied" || e.Code === "AccessDenied") {
605
742
  return {
606
743
  ...HaiStorageError.PERMISSION_DENIED,
607
- message: storageM("storage_permissionDenied", { params: { key: key ?? "" } }),
744
+ message: storageM("storage_permissionDenied", {
745
+ params: { key: key ?? "" }
746
+ }),
608
747
  cause: error
609
748
  };
610
749
  }
@@ -624,7 +763,9 @@ function toStorageError2(error, key) {
624
763
  }
625
764
  return {
626
765
  ...HaiStorageError.OPERATION_FAILED,
627
- message: storageM("storage_operationFailed", { params: { error: e.message ?? "" } }),
766
+ message: storageM("storage_operationFailed", {
767
+ params: { error: e.message ?? "" }
768
+ }),
628
769
  cause: error
629
770
  };
630
771
  }
@@ -636,7 +777,7 @@ function withPrefix(key, prefix) {
636
777
  function withoutPrefix(key, prefix) {
637
778
  if (!prefix)
638
779
  return key;
639
- const normalizedPrefix = `${prefix.replace(TRAILING_SLASHES_REGEX, "")}/`;
780
+ const normalizedPrefix = withPrefix("", prefix);
640
781
  if (key.startsWith(normalizedPrefix)) {
641
782
  return key.slice(normalizedPrefix.length);
642
783
  }
@@ -724,7 +865,12 @@ function createS3Provider() {
724
865
  ContentDisposition: options.contentDisposition,
725
866
  IfNoneMatch: options.ifNoneMatch
726
867
  };
727
- logOperation("write", "file.put", { key, fullPath, byteLength: buffered ? size : void 0, options });
868
+ logOperation("write", "file.put", {
869
+ key,
870
+ fullPath,
871
+ byteLength: buffered ? size : void 0,
872
+ options
873
+ });
728
874
  if (buffered) {
729
875
  await s3Client.send(new PutObjectCommand({ ...params, Body: body }), { abortSignal: options.signal });
730
876
  } else {
@@ -742,7 +888,13 @@ function createS3Provider() {
742
888
  if (options.signal?.aborted)
743
889
  abort();
744
890
  const { Upload } = await import('@aws-sdk/lib-storage');
745
- await new Upload({ client: s3Client, params: { ...params, Body: stream }, queueSize: 2, leavePartsOnError: false, abortController: controller }).done();
891
+ await new Upload({
892
+ client: s3Client,
893
+ params: { ...params, Body: stream },
894
+ queueSize: 2,
895
+ leavePartsOnError: false,
896
+ abortController: controller
897
+ }).done();
746
898
  } finally {
747
899
  options.signal?.removeEventListener("abort", abort);
748
900
  stream.destroy();
@@ -857,7 +1009,9 @@ function createS3Provider() {
857
1009
  const s3Client = getClient("deleteMany");
858
1010
  const s3Config = getConfig();
859
1011
  logOperation("write", "file.deleteMany", { count: keys.length, keys });
860
- const uniqueKeys = [...new Set(keys)];
1012
+ const uniqueKeys = [
1013
+ ...new Set(keys)
1014
+ ];
861
1015
  const summary = { deletedKeys: [], failures: [] };
862
1016
  for (let offset = 0; offset < uniqueKeys.length; offset += DELETE_BATCH_SIZE) {
863
1017
  let pending = uniqueKeys.slice(offset, offset + DELETE_BATCH_SIZE);
@@ -866,7 +1020,12 @@ function createS3Provider() {
866
1020
  try {
867
1021
  response = await s3Client.send(new DeleteObjectsCommand({
868
1022
  Bucket: s3Config.bucket,
869
- Delete: { Objects: [...new Set(pending.map(fullKey))].map((Key) => ({ Key })), Quiet: false }
1023
+ Delete: {
1024
+ Objects: [
1025
+ ...new Set(pending.map(fullKey))
1026
+ ].map((Key) => ({ Key })),
1027
+ Quiet: false
1028
+ }
870
1029
  }));
871
1030
  } catch (cause) {
872
1031
  const failure = cause && typeof cause === "object" ? cause : {};
@@ -875,7 +1034,12 @@ function createS3Provider() {
875
1034
  const retryable = status === void 0 || status === 408 || status === 429 || status >= 500;
876
1035
  const outcome = status !== void 0 && status >= 400 && status < 500 && status !== 408 ? "failed" : "unknown";
877
1036
  summary.failures.push(...pending.map((key) => ({ key, code, retryable, outcome })));
878
- summary.failures.push(...uniqueKeys.slice(offset + DELETE_BATCH_SIZE).map((key) => ({ key, code, retryable, outcome: "notAttempted" })));
1037
+ summary.failures.push(...uniqueKeys.slice(offset + DELETE_BATCH_SIZE).map((key) => ({
1038
+ key,
1039
+ code,
1040
+ retryable,
1041
+ outcome: "notAttempted"
1042
+ })));
879
1043
  return toStorageDeleteResult(summary);
880
1044
  }
881
1045
  const outcomes = /* @__PURE__ */ new Map();
@@ -883,7 +1047,10 @@ function createS3Provider() {
883
1047
  let malformed = false;
884
1048
  const items = [
885
1049
  ...(response.Deleted ?? []).map((item) => ({ key: item.Key, code: null })),
886
- ...(response.Errors ?? []).map((item) => ({ key: item.Key, code: item.Code && /^[\w.-]{1,80}$/.test(item.Code) ? item.Code : "UnknownError" }))
1050
+ ...(response.Errors ?? []).map((item) => ({
1051
+ key: item.Key,
1052
+ code: item.Code && /^[\w.-]{1,80}$/.test(item.Code) ? item.Code : "UnknownError"
1053
+ }))
887
1054
  ];
888
1055
  for (const item of items) {
889
1056
  if (!item.key || !requested.has(item.key) || outcomes.has(item.key)) {
@@ -893,19 +1060,35 @@ function createS3Provider() {
893
1060
  outcomes.set(item.key, item.code);
894
1061
  }
895
1062
  if (malformed || outcomes.size !== requested.size) {
896
- summary.failures.push(...pending.map((key) => ({ key, code: "InvalidDeleteResponse", retryable: false, outcome: "unknown" })));
897
- summary.failures.push(...uniqueKeys.slice(offset + DELETE_BATCH_SIZE).map((key) => ({ key, code: "InvalidDeleteResponse", retryable: false, outcome: "notAttempted" })));
1063
+ summary.failures.push(...pending.map((key) => ({
1064
+ key,
1065
+ code: "InvalidDeleteResponse",
1066
+ retryable: false,
1067
+ outcome: "unknown"
1068
+ })));
1069
+ summary.failures.push(...uniqueKeys.slice(offset + DELETE_BATCH_SIZE).map((key) => ({
1070
+ key,
1071
+ code: "InvalidDeleteResponse",
1072
+ retryable: false,
1073
+ outcome: "notAttempted"
1074
+ })));
898
1075
  return toStorageDeleteResult(summary);
899
1076
  }
900
1077
  const retry = [];
901
1078
  for (const key of pending) {
902
1079
  const code = outcomes.get(fullKey(key));
903
- if (code === null)
1080
+ if (code === null) {
904
1081
  summary.deletedKeys.push(key);
905
- else if (code && RETRYABLE_DELETE_CODES.has(code) && attempt < 2)
1082
+ } else if (code && RETRYABLE_DELETE_CODES.has(code) && attempt < 2) {
906
1083
  retry.push(key);
907
- else
908
- summary.failures.push({ key, code: code ?? "UnknownError", retryable: !!code && RETRYABLE_DELETE_CODES.has(code), outcome: "failed" });
1084
+ } else {
1085
+ summary.failures.push({
1086
+ key,
1087
+ code: code ?? "UnknownError",
1088
+ retryable: !!code && RETRYABLE_DELETE_CODES.has(code),
1089
+ outcome: "failed"
1090
+ });
1091
+ }
909
1092
  }
910
1093
  pending = retry;
911
1094
  if (pending.length > 0)
@@ -923,10 +1106,20 @@ function createS3Provider() {
923
1106
  const s3Config = getConfig();
924
1107
  const sourceFullPath = fullKey(sourceKey);
925
1108
  const destFullPath = fullKey(destKey);
926
- logOperation("write", "file.copy", { sourceKey, destKey, sourceFullPath, destFullPath, options });
1109
+ const copySource = [
1110
+ s3Config.bucket,
1111
+ ...sourceFullPath.split("/")
1112
+ ].map((part) => encodeURIComponent(part).replace(/[!'()*]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`)).join("/");
1113
+ logOperation("write", "file.copy", {
1114
+ sourceKey,
1115
+ destKey,
1116
+ sourceFullPath,
1117
+ destFullPath,
1118
+ options
1119
+ });
927
1120
  await s3Client.send(new CopyObjectCommand({
928
1121
  Bucket: s3Config.bucket,
929
- CopySource: `${s3Config.bucket}/${sourceFullPath}`,
1122
+ CopySource: copySource,
930
1123
  Key: destFullPath,
931
1124
  ContentType: options.contentType,
932
1125
  Metadata: options.metadata,
@@ -944,13 +1137,9 @@ function createS3Provider() {
944
1137
  const s3Client = getClient();
945
1138
  const s3Config = getConfig();
946
1139
  logOperation("read", "dir.list", { options });
947
- let prefix = s3Config.prefix || "";
948
- if (options.prefix) {
949
- prefix = withPrefix(options.prefix, s3Config.prefix);
950
- }
951
1140
  const response = await s3Client.send(new ListObjectsV2Command({
952
1141
  Bucket: s3Config.bucket,
953
- Prefix: prefix || void 0,
1142
+ Prefix: withPrefix(options.prefix ?? "", s3Config.prefix) || void 0,
954
1143
  Delimiter: options.delimiter,
955
1144
  MaxKeys: options.maxKeys,
956
1145
  ContinuationToken: options.continuationToken
@@ -1101,7 +1290,11 @@ function createS3Provider() {
1101
1290
  if (cfg.publicEndpoint) {
1102
1291
  const endpoint = new URL(cfg.publicEndpoint);
1103
1292
  publicBasePath = endpoint.pathname.replace(TRAILING_SLASHES_REGEX, "");
1104
- presignClient = new S3Client({ ...clientConfig, endpoint: endpoint.origin, forcePathStyle: publicBasePath ? true : cfg.forcePathStyle });
1293
+ presignClient = new S3Client({
1294
+ ...clientConfig,
1295
+ endpoint: endpoint.origin,
1296
+ forcePathStyle: publicBasePath ? true : cfg.forcePathStyle
1297
+ });
1105
1298
  }
1106
1299
  if (cfg.createBucket) {
1107
1300
  try {
@@ -1114,7 +1307,9 @@ function createS3Provider() {
1114
1307
  await client.send(new CreateBucketCommand({
1115
1308
  Bucket: cfg.bucket,
1116
1309
  // AWS us-east-1 要省略 LocationConstraint;兼容服务可使用自定义区域名。
1117
- ...cfg.region === "us-east-1" ? {} : { CreateBucketConfiguration: { LocationConstraint: cfg.region } }
1310
+ ...cfg.region === "us-east-1" ? {} : {
1311
+ CreateBucketConfiguration: { LocationConstraint: cfg.region }
1312
+ }
1118
1313
  }));
1119
1314
  logger2.info("S3 bucket created", { bucket: cfg.bucket });
1120
1315
  } catch (createError) {
@@ -1126,6 +1321,7 @@ function createS3Provider() {
1126
1321
  }
1127
1322
  await client.send(new ListObjectsV2Command({
1128
1323
  Bucket: cfg.bucket,
1324
+ Prefix: withPrefix("", cfg.prefix) || void 0,
1129
1325
  MaxKeys: 1
1130
1326
  }));
1131
1327
  config = cfg;
@@ -1139,7 +1335,10 @@ function createS3Provider() {
1139
1335
  await closeClients();
1140
1336
  return err(original);
1141
1337
  } catch (cleanupError) {
1142
- return err({ ...original, ext: { ...original.ext, cleanupPending: true, cleanupError } });
1338
+ return err({
1339
+ ...original,
1340
+ ext: { ...original.ext, cleanupPending: true, cleanupError }
1341
+ });
1143
1342
  }
1144
1343
  }
1145
1344
  },
@@ -1151,7 +1350,13 @@ function createS3Provider() {
1151
1350
  }
1152
1351
  var HEX_CHECKSUM = /^[\da-f]+$/i;
1153
1352
  var BASE64_CHECKSUM = /^(?:[A-Z\d+/]{4})*(?:[A-Z\d+/]{2}==|[A-Z\d+/]{3}=)?$/i;
1154
- var ALGORITHMS = /* @__PURE__ */ new Set(["sha256", "sha384", "sha512", "sha1", "md5"]);
1353
+ var ALGORITHMS = /* @__PURE__ */ new Set([
1354
+ "sha256",
1355
+ "sha384",
1356
+ "sha512",
1357
+ "sha1",
1358
+ "md5"
1359
+ ]);
1155
1360
  async function generate(data, options = {}) {
1156
1361
  try {
1157
1362
  const algorithm = options.algorithm ?? "sha256";
@@ -1203,9 +1408,16 @@ async function closeOwnedProvider() {
1203
1408
  return ok(void 0);
1204
1409
  } catch (error) {
1205
1410
  logger3.error("Provider cleanup failed; retaining resource for retry", { error });
1206
- return err({ ...HaiStorageError.CONNECTION_FAILED, message: storageM("storage_operationFailed", {
1207
- params: { error: error instanceof Error ? error.message : String(error) }
1208
- }), cause: error, ext: { cleanupPending: true } });
1411
+ return err({
1412
+ ...HaiStorageError.CONNECTION_FAILED,
1413
+ message: storageM("storage_operationFailed", {
1414
+ params: {
1415
+ error: error instanceof Error ? error.message : String(error)
1416
+ }
1417
+ }),
1418
+ cause: error,
1419
+ ext: { cleanupPending: true }
1420
+ });
1209
1421
  }
1210
1422
  }
1211
1423
  async function failInitialization(result) {
@@ -1216,9 +1428,15 @@ async function failInitialization(result) {
1216
1428
  const ext = { ...result.error.ext };
1217
1429
  delete ext.cleanupPending;
1218
1430
  delete ext.cleanupError;
1219
- return err({ ...result.error, ext: Object.keys(ext).length > 0 ? ext : void 0 });
1431
+ return err({
1432
+ ...result.error,
1433
+ ext: Object.keys(ext).length > 0 ? ext : void 0
1434
+ });
1220
1435
  }
1221
- return err({ ...result.error, ext: { ...result.error.ext, cleanupPending: true, cleanupError: cleanup.error } });
1436
+ return err({
1437
+ ...result.error,
1438
+ ext: { ...result.error.ext, cleanupPending: true, cleanupError: cleanup.error }
1439
+ });
1222
1440
  }
1223
1441
  function createProvider(config) {
1224
1442
  switch (config.type) {
@@ -1227,7 +1445,9 @@ function createProvider(config) {
1227
1445
  case "local":
1228
1446
  return createLocalProvider();
1229
1447
  default:
1230
- throw new Error(storageM("storage_unsupportedType", { params: { type: config.type } }));
1448
+ throw new Error(storageM("storage_unsupportedType", {
1449
+ params: { type: config.type }
1450
+ }));
1231
1451
  }
1232
1452
  }
1233
1453
  var notInitialized = core.module.createNotInitializedKit(
@@ -1268,7 +1488,9 @@ var storage = {
1268
1488
  logger3.warn("Storage init already in progress, skipping concurrent call");
1269
1489
  return err(
1270
1490
  HaiStorageError.OPERATION_FAILED,
1271
- storageM("storage_operationFailed", { params: { error: "Concurrent initialization detected" } })
1491
+ storageM("storage_operationFailed", {
1492
+ params: { error: "Concurrent initialization detected" }
1493
+ })
1272
1494
  );
1273
1495
  }
1274
1496
  initInProgress = true;
@@ -1286,7 +1508,9 @@ var storage = {
1286
1508
  logger3.error("Storage config validation failed", { error: parseResult.error.message });
1287
1509
  return err(
1288
1510
  HaiStorageError.CONFIG_ERROR,
1289
- storageM("storage_configError", { params: { error: parseResult.error.message } }),
1511
+ storageM("storage_configError", {
1512
+ params: { error: parseResult.error.message }
1513
+ }),
1290
1514
  parseResult.error
1291
1515
  );
1292
1516
  }
@@ -1313,7 +1537,9 @@ var storage = {
1313
1537
  return await failInitialization(err(
1314
1538
  HaiStorageError.CONNECTION_FAILED,
1315
1539
  storageM("storage_operationFailed", {
1316
- params: { error: error instanceof Error ? error.message : String(error) }
1540
+ params: {
1541
+ error: error instanceof Error ? error.message : String(error)
1542
+ }
1317
1543
  }),
1318
1544
  error
1319
1545
  ));