@indigoai-us/hq-cloud 5.46.0 → 5.47.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/sync-runner.d.ts +12 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +39 -0
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +27 -1
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +17 -2
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +2 -0
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync-scope.test.js +1 -0
- package/dist/cli/sync-scope.test.js.map +1 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +11 -1
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +1 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/object-io.d.ts +218 -0
- package/dist/object-io.d.ts.map +1 -0
- package/dist/object-io.js +588 -0
- package/dist/object-io.js.map +1 -0
- package/dist/object-io.test.d.ts +11 -0
- package/dist/object-io.test.d.ts.map +1 -0
- package/dist/object-io.test.js +568 -0
- package/dist/object-io.test.js.map +1 -0
- package/dist/s3.d.ts +37 -0
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +225 -201
- package/dist/s3.js.map +1 -1
- package/dist/s3.test.js +21 -0
- package/dist/s3.test.js.map +1 -1
- package/dist/vault-client.d.ts +68 -0
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +35 -0
- package/dist/vault-client.js.map +1 -1
- package/package.json +1 -1
- package/scripts/presign-transport-e2e.mjs +203 -0
- package/scripts/vault-rebaseline.sh +275 -0
- package/scripts/vault-rescue.sh +8 -0
- package/src/bin/sync-runner.test.ts +41 -0
- package/src/bin/sync-runner.ts +52 -0
- package/src/cli/share.test.ts +2 -0
- package/src/cli/share.ts +29 -2
- package/src/cli/sync-scope.test.ts +1 -0
- package/src/cli/sync.test.ts +1 -0
- package/src/cli/sync.ts +22 -1
- package/src/object-io.test.ts +663 -0
- package/src/object-io.ts +782 -0
- package/src/s3.test.ts +24 -0
- package/src/s3.ts +277 -237
- package/src/vault-client.ts +101 -0
package/dist/s3.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"s3.d.ts","sourceRoot":"","sources":["../src/s3.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;
|
|
1
|
+
{"version":3,"file":"s3.d.ts","sourceRoot":"","sources":["../src/s3.ts"],"names":[],"mappings":"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"}
|
package/dist/s3.js
CHANGED
|
@@ -7,22 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import * as fs from "fs";
|
|
9
9
|
import * as path from "path";
|
|
10
|
-
import {
|
|
11
|
-
/**
|
|
12
|
-
* Build an S3Client from an EntityContext's STS-scoped credentials.
|
|
13
|
-
* A new client is created each time to ensure fresh credentials are used
|
|
14
|
-
* (the caller handles caching/refresh at the EntityContext level).
|
|
15
|
-
*/
|
|
16
|
-
function buildClient(ctx) {
|
|
17
|
-
return new S3Client({
|
|
18
|
-
region: ctx.region,
|
|
19
|
-
credentials: {
|
|
20
|
-
accessKeyId: ctx.credentials.accessKeyId,
|
|
21
|
-
secretAccessKey: ctx.credentials.secretAccessKey,
|
|
22
|
-
sessionToken: ctx.credentials.sessionToken,
|
|
23
|
-
},
|
|
24
|
-
});
|
|
25
|
-
}
|
|
10
|
+
import { resolveObjectIO } from "./object-io.js";
|
|
26
11
|
/**
|
|
27
12
|
* S3 user metadata is ASCII-only (lowercased on read, capped at 2 KB total).
|
|
28
13
|
* Values that fail the printable-ASCII test or would push the keys over the
|
|
@@ -212,100 +197,172 @@ export const FILE_BTIME_META_KEY = "hq-btime";
|
|
|
212
197
|
export function encodeSymlinkBody(target) {
|
|
213
198
|
return Buffer.from(SYMLINK_BODY_PREFIX + target, "utf-8");
|
|
214
199
|
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
//
|
|
259
|
-
// Compare the floored values (not raw lstat.birthtimeMs vs
|
|
260
|
-
// lstat.mtimeMs) because APFS exposes sub-millisecond fractions —
|
|
261
|
-
// two timestamps representing the "same moment" for sync purposes
|
|
262
|
-
// can differ by < 1 ms and pass a strict `!==` check while serializing
|
|
263
|
-
// to the same integer-ms string. Comparing floor-to-floor matches
|
|
264
|
-
// what we actually emit on the wire.
|
|
265
|
-
const btimeFloor = Math.floor(lstat.birthtimeMs);
|
|
266
|
-
if (Number.isFinite(lstat.birthtimeMs) &&
|
|
267
|
-
btimeFloor > 0 &&
|
|
268
|
-
btimeFloor !== mtimeFloor) {
|
|
269
|
-
btimeMsStamp = String(btimeFloor);
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
catch {
|
|
274
|
-
// Leave stamps undefined; receiver applies its umask default and
|
|
275
|
-
// leaves mtime at write-time (the legacy back-compat path).
|
|
200
|
+
/**
|
|
201
|
+
* Batch pre-mint transport URLs for `keys` under `op` so the subsequent
|
|
202
|
+
* per-file transfer calls (downloadFile/headRemoteFile/…) reuse them instead
|
|
203
|
+
* of presigning one key at a time. On the presigned-URL transport this turns
|
|
204
|
+
* an N-file leg from N presign requests into ceil(N/100) — the difference
|
|
205
|
+
* between completing a bulk pull and 429ing past the 100-req/hr limit. No-op
|
|
206
|
+
* on the S3 SDK transport (which has no presign step) and harmless if called
|
|
207
|
+
* with an empty list. Best-effort: a prime failure never propagates — the
|
|
208
|
+
* per-file path falls back to a single presign.
|
|
209
|
+
*
|
|
210
|
+
* Call it once, right before a transfer loop, with the full key set the loop
|
|
211
|
+
* will touch. The presigned transport memoizes one IO instance per company for
|
|
212
|
+
* the run, so the warmed cache is the same one the loop drains.
|
|
213
|
+
*/
|
|
214
|
+
export async function primeObjectTransport(ctx, op, keys) {
|
|
215
|
+
if (keys.length === 0)
|
|
216
|
+
return;
|
|
217
|
+
const io = resolveObjectIO(ctx);
|
|
218
|
+
if (!io.prime)
|
|
219
|
+
return;
|
|
220
|
+
await io.prime(op, keys.map((key) => ({ key })));
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Source-side mode + mtime (+ btime when distinct) metadata for a regular
|
|
224
|
+
* file, from a single lstat. Symlinks carry none (OS-controlled mode; a link's
|
|
225
|
+
* mtime isn't user-meaningful — the wire body is the target string, not file
|
|
226
|
+
* content). Shared by uploadFile and the primeUploads pre-pass so the PUT
|
|
227
|
+
* metadata they produce is byte-identical. See the FILE_*_META_KEY docs for the
|
|
228
|
+
* per-field rationale.
|
|
229
|
+
*/
|
|
230
|
+
function buildModeTimeMetadata(lstat) {
|
|
231
|
+
const meta = {};
|
|
232
|
+
if (lstat.isSymbolicLink())
|
|
233
|
+
return meta;
|
|
234
|
+
meta[FILE_MODE_META_KEY] = (lstat.mode & 0o777).toString(8);
|
|
235
|
+
const mtimeFloor = Math.floor(lstat.mtimeMs);
|
|
236
|
+
if (Number.isFinite(lstat.mtimeMs))
|
|
237
|
+
meta[FILE_MTIME_META_KEY] = String(mtimeFloor);
|
|
238
|
+
const btimeFloor = Math.floor(lstat.birthtimeMs);
|
|
239
|
+
if (Number.isFinite(lstat.birthtimeMs) &&
|
|
240
|
+
btimeFloor > 0 &&
|
|
241
|
+
btimeFloor !== mtimeFloor) {
|
|
242
|
+
meta[FILE_BTIME_META_KEY] = String(btimeFloor);
|
|
276
243
|
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
244
|
+
return meta;
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Resolve the created-at to stamp: the existing object's value (preserved
|
|
248
|
+
* across re-uploads so the hq-console NEW-pill window doesn't reset) or now for
|
|
249
|
+
* a first upload. HEAD failure / no author → now. Shared by upload* and
|
|
250
|
+
* primeUploads so both agree on the value signed into the PUT.
|
|
251
|
+
*/
|
|
252
|
+
async function resolveCreatedAt(io, key, author) {
|
|
282
253
|
let createdAt = new Date().toISOString();
|
|
283
254
|
if (author) {
|
|
284
255
|
try {
|
|
285
|
-
const head = await
|
|
286
|
-
const existing = head
|
|
256
|
+
const head = await io.headObject(key);
|
|
257
|
+
const existing = head?.metadata?.["created-at"];
|
|
287
258
|
if (typeof existing === "string" && existing.length > 0) {
|
|
288
259
|
createdAt = existing;
|
|
289
260
|
}
|
|
290
261
|
}
|
|
291
262
|
catch {
|
|
292
|
-
// Object doesn't exist yet, or HEAD
|
|
263
|
+
// Object doesn't exist yet, or HEAD failed — keep now (first upload).
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return createdAt;
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Batch pre-mint PUT URLs (+ the created-at HEADs they depend on) for a set of
|
|
270
|
+
* uploads, signing the SAME metadata uploadFile/uploadSymlink would compute so
|
|
271
|
+
* the transfer loop can replay the cached headers. Turns an N-file push from
|
|
272
|
+
* ~N presign calls (1 per PUT, sometimes 2-3 with HEADs) into ceil(N/1000) GET
|
|
273
|
+
* + ceil(N/1000) PUT — the difference between completing a bulk push and 429ing
|
|
274
|
+
* past the 100/hr limit. No-op on the S3 SDK transport; best-effort.
|
|
275
|
+
*
|
|
276
|
+
* The per-item created-at HEADs run over the GET cache primed first, so they
|
|
277
|
+
* cost S3 round-trips but NO extra presign calls (not counted against 100/hr).
|
|
278
|
+
*/
|
|
279
|
+
export async function primeUploads(ctx, items) {
|
|
280
|
+
const io = resolveObjectIO(ctx);
|
|
281
|
+
if (!io.prime || items.length === 0)
|
|
282
|
+
return;
|
|
283
|
+
// Prime GET first so each item's created-at HEAD reuses a cached URL.
|
|
284
|
+
await io.prime("get", items.map((i) => ({ key: i.key })));
|
|
285
|
+
// Build per-key PUT metadata with the SAME builders the upload path uses,
|
|
286
|
+
// bounded-concurrently (the HEADs are cheap cached-GET fetches).
|
|
287
|
+
const putKeys = [];
|
|
288
|
+
const CONCURRENCY = 16;
|
|
289
|
+
let next = 0;
|
|
290
|
+
const worker = async () => {
|
|
291
|
+
while (next < items.length) {
|
|
292
|
+
const it = items[next++];
|
|
293
|
+
const createdAt = await resolveCreatedAt(io, it.key, it.author);
|
|
294
|
+
if (it.isSymlink) {
|
|
295
|
+
putKeys.push({
|
|
296
|
+
key: it.key,
|
|
297
|
+
contentType: "application/octet-stream",
|
|
298
|
+
metadata: {
|
|
299
|
+
[SYMLINK_TARGET_META_KEY]: SYMLINK_MARKER_META_VALUE,
|
|
300
|
+
...(it.author ? buildAuthorMetadata(it.author, createdAt) : {}),
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
let modeTime = {};
|
|
306
|
+
try {
|
|
307
|
+
modeTime = buildModeTimeMetadata(fs.lstatSync(it.localPath));
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
// raced rm / EPERM — leave stamps off (receiver umask default).
|
|
311
|
+
}
|
|
312
|
+
putKeys.push({
|
|
313
|
+
key: it.key,
|
|
314
|
+
contentType: getMimeType(it.key),
|
|
315
|
+
metadata: {
|
|
316
|
+
...(it.author ? buildAuthorMetadata(it.author, createdAt) : {}),
|
|
317
|
+
...modeTime,
|
|
318
|
+
},
|
|
319
|
+
});
|
|
320
|
+
}
|
|
293
321
|
}
|
|
322
|
+
};
|
|
323
|
+
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
|
|
324
|
+
await io.prime("put", putKeys);
|
|
325
|
+
}
|
|
326
|
+
export async function uploadFile(ctx, localPath, key, author) {
|
|
327
|
+
const io = resolveObjectIO(ctx);
|
|
328
|
+
const body = fs.readFileSync(localPath);
|
|
329
|
+
// Fast path: a primeUploads() pre-pass already signed this file's metadata
|
|
330
|
+
// into a cached PUT URL. Skip the lstat-metadata + created-at HEAD and just
|
|
331
|
+
// send the body — putObject replays the cached headers (computed by the SAME
|
|
332
|
+
// builders below, so identical). hasPrimedPut only reports true with >60s of
|
|
333
|
+
// URL lifetime left, so the cache can't expire before the putObject below.
|
|
334
|
+
if (io.hasPrimedPut?.(key)) {
|
|
335
|
+
const primed = await io.putObject({
|
|
336
|
+
key,
|
|
337
|
+
body,
|
|
338
|
+
contentType: getMimeType(key),
|
|
339
|
+
metadata: {},
|
|
340
|
+
});
|
|
341
|
+
return { etag: primed.etag };
|
|
342
|
+
}
|
|
343
|
+
// Source-side mode/mtime/btime (Bug #5 + 5.37.0) and the preserved
|
|
344
|
+
// created-at (so the NEW-pill window doesn't reset on re-upload). Both via
|
|
345
|
+
// the shared builders that primeUploads uses, so a primed PUT carries the
|
|
346
|
+
// identical metadata — see buildModeTimeMetadata / resolveCreatedAt.
|
|
347
|
+
let modeTime = {};
|
|
348
|
+
try {
|
|
349
|
+
modeTime = buildModeTimeMetadata(fs.lstatSync(localPath));
|
|
350
|
+
}
|
|
351
|
+
catch {
|
|
352
|
+
// raced rm / EPERM — leave stamps off; receiver keeps its umask default.
|
|
294
353
|
}
|
|
354
|
+
const createdAt = await resolveCreatedAt(io, key, author);
|
|
295
355
|
const Metadata = {
|
|
296
356
|
...(author ? buildAuthorMetadata(author, createdAt) : {}),
|
|
297
|
-
...
|
|
298
|
-
...(mtimeMsStamp ? { [FILE_MTIME_META_KEY]: mtimeMsStamp } : {}),
|
|
299
|
-
...(btimeMsStamp ? { [FILE_BTIME_META_KEY]: btimeMsStamp } : {}),
|
|
357
|
+
...modeTime,
|
|
300
358
|
};
|
|
301
|
-
const response = await
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
}
|
|
308
|
-
return { etag: response.ETag || "" };
|
|
359
|
+
const response = await io.putObject({
|
|
360
|
+
key,
|
|
361
|
+
body,
|
|
362
|
+
contentType: getMimeType(key),
|
|
363
|
+
metadata: Metadata,
|
|
364
|
+
});
|
|
365
|
+
return { etag: response.etag };
|
|
309
366
|
}
|
|
310
367
|
/**
|
|
311
368
|
* Upload a symlink as a zero-byte object whose user metadata carries the
|
|
@@ -320,23 +377,22 @@ export async function uploadFile(ctx, localPath, key, author) {
|
|
|
320
377
|
* caller (currently: upload anyway, never silently rewrite).
|
|
321
378
|
*/
|
|
322
379
|
export async function uploadSymlink(ctx, target, key, author) {
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
}
|
|
336
|
-
catch {
|
|
337
|
-
// First upload of this key, or HEAD denied — keep `now`.
|
|
338
|
-
}
|
|
380
|
+
const io = resolveObjectIO(ctx);
|
|
381
|
+
const symlinkBody = encodeSymlinkBody(target);
|
|
382
|
+
// Fast path: primeUploads() already signed this symlink's metadata into a
|
|
383
|
+
// cached PUT URL — send the body, replay the cached headers.
|
|
384
|
+
if (io.hasPrimedPut?.(key)) {
|
|
385
|
+
const primed = await io.putObject({
|
|
386
|
+
key,
|
|
387
|
+
body: symlinkBody,
|
|
388
|
+
contentType: "application/octet-stream",
|
|
389
|
+
metadata: {},
|
|
390
|
+
});
|
|
391
|
+
return { etag: primed.etag };
|
|
339
392
|
}
|
|
393
|
+
// Same created-at preservation as uploadFile (shared resolveCreatedAt) so the
|
|
394
|
+
// NEW-pill window doesn't reset on re-upload, and so a primed PUT matches.
|
|
395
|
+
const createdAt = await resolveCreatedAt(io, key, author);
|
|
340
396
|
const Metadata = {
|
|
341
397
|
// Marker-only: a constant flag value, not the target. The body
|
|
342
398
|
// is the source of truth for the target (no 2 KiB cap, no
|
|
@@ -345,20 +401,19 @@ export async function uploadSymlink(ctx, target, key, author) {
|
|
|
345
401
|
[SYMLINK_TARGET_META_KEY]: SYMLINK_MARKER_META_VALUE,
|
|
346
402
|
...(author ? buildAuthorMetadata(author, createdAt) : {}),
|
|
347
403
|
};
|
|
348
|
-
const response = await
|
|
349
|
-
|
|
350
|
-
Key: key,
|
|
404
|
+
const response = await io.putObject({
|
|
405
|
+
key,
|
|
351
406
|
// Body = SYMLINK_BODY_PREFIX + target (UTF-8). The prefix is what
|
|
352
407
|
// makes a symlink record's ETag distinguishable from a regular
|
|
353
408
|
// file whose contents happen to equal the target string — the
|
|
354
409
|
// LIST-based pull planner can't see per-object metadata, so ETag
|
|
355
410
|
// is its only drift signal across symlink ↔ regular-file
|
|
356
411
|
// transitions. See SYMLINK_BODY_PREFIX doc above.
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
Metadata,
|
|
360
|
-
})
|
|
361
|
-
return { etag: response.
|
|
412
|
+
body: symlinkBody,
|
|
413
|
+
contentType: "application/octet-stream",
|
|
414
|
+
metadata: Metadata,
|
|
415
|
+
});
|
|
416
|
+
return { etag: response.etag };
|
|
362
417
|
}
|
|
363
418
|
/**
|
|
364
419
|
* Download an object to localPath and return its S3 user-metadata.
|
|
@@ -370,14 +425,13 @@ export async function uploadSymlink(ctx, target, key, author) {
|
|
|
370
425
|
* attribute downloaded files to their author with zero extra network.
|
|
371
426
|
*/
|
|
372
427
|
export async function downloadFile(ctx, key, localPath) {
|
|
373
|
-
const
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
}
|
|
428
|
+
const io = resolveObjectIO(ctx);
|
|
429
|
+
// The transport returns the full object body buffered + its user metadata.
|
|
430
|
+
// downloadFile already buffered the whole object (writeFileSync of the
|
|
431
|
+
// concatenated chunks), so buffering at the transport layer is behavior-
|
|
432
|
+
// preserving — symlink record bodies are tiny and regular files were read
|
|
433
|
+
// fully into memory regardless.
|
|
434
|
+
const { body: objectBody, metadata } = await io.getObject(key);
|
|
381
435
|
const dir = path.dirname(localPath);
|
|
382
436
|
if (!fs.existsSync(dir)) {
|
|
383
437
|
fs.mkdirSync(dir, { recursive: true });
|
|
@@ -390,21 +444,25 @@ export async function downloadFile(ctx, key, localPath) {
|
|
|
390
444
|
// S3 lowercases user-metadata keys on read (and sometimes on
|
|
391
445
|
// write), so the lookup uses the lowercased form. We don't
|
|
392
446
|
// normalize Metadata keys ourselves — the AWS SDK already does it.
|
|
393
|
-
const symlinkMarker =
|
|
394
|
-
|
|
447
|
+
const symlinkMarker = metadata?.[SYMLINK_TARGET_META_KEY];
|
|
448
|
+
// Discriminator: the metadata marker is the primary signal, but the body
|
|
449
|
+
// prefix is a header-loss fallback (S3 cross-region replication of data
|
|
450
|
+
// only, a console copy that drops Metadata, a metadata-stripping transport,
|
|
451
|
+
// or a poisoned regular-file re-upload of a sentinel). Honor BOTH — a
|
|
452
|
+
// marker-less object whose body starts with SYMLINK_BODY_PREFIX still
|
|
453
|
+
// rematerializes as a link instead of being written out as plain
|
|
454
|
+
// `hq-symlink:<target>` text, which would poison the key on the next push.
|
|
455
|
+
// The body is already buffered (tiny for symlink records); reading it here
|
|
456
|
+
// is behavior-preserving for regular files (whose body never starts with
|
|
457
|
+
// the prefix per the SYMLINK_BODY_PREFIX doc). See SYMLINK_BODY_PREFIX.
|
|
458
|
+
const bodyString = objectBody.toString("utf-8");
|
|
459
|
+
const isSymlinkRecord = (typeof symlinkMarker === "string" && symlinkMarker.length > 0) ||
|
|
460
|
+
bodyString.startsWith(SYMLINK_BODY_PREFIX);
|
|
395
461
|
if (isSymlinkRecord) {
|
|
396
|
-
//
|
|
397
|
-
// are bounded by target length (typically
|
|
398
|
-
// relative paths, hard-capped by S3's 5 GB object
|
|
399
|
-
//
|
|
400
|
-
// released back to the connection pool — without this, a sync
|
|
401
|
-
// over a tree with many symlinks can stall or pool-exhaust.
|
|
402
|
-
const chunks = [];
|
|
403
|
-
const stream = response.Body;
|
|
404
|
-
for await (const chunk of stream) {
|
|
405
|
-
chunks.push(Buffer.from(chunk));
|
|
406
|
-
}
|
|
407
|
-
const bodyString = Buffer.concat(chunks).toString("utf-8");
|
|
462
|
+
// The target lives in the body (marker-only metadata convention).
|
|
463
|
+
// Symlink record bodies are bounded by target length (typically
|
|
464
|
+
// <300 bytes for relative paths, hard-capped by S3's 5 GB object
|
|
465
|
+
// size); the transport already buffered it.
|
|
408
466
|
let symlinkTarget;
|
|
409
467
|
if (bodyString.startsWith(SYMLINK_BODY_PREFIX)) {
|
|
410
468
|
symlinkTarget = bodyString.slice(SYMLINK_BODY_PREFIX.length);
|
|
@@ -414,8 +472,12 @@ export async function downloadFile(ctx, key, localPath) {
|
|
|
414
472
|
// this PR's lifetime stored the target in metadata (raw or
|
|
415
473
|
// base64) rather than the body. decodeSymlinkMetadataValue
|
|
416
474
|
// round-trip-validates so a raw value passes through and a
|
|
417
|
-
// base64 value decodes; either way we get the target.
|
|
418
|
-
|
|
475
|
+
// base64 value decodes; either way we get the target. This branch
|
|
476
|
+
// is only reachable when the body lacks the prefix, which (given
|
|
477
|
+
// isSymlinkRecord) means the marker is present — the `?? ""` is a
|
|
478
|
+
// type guard for that invariant, and the length-0 check below
|
|
479
|
+
// catches the impossible empty case rather than passing it on.
|
|
480
|
+
symlinkTarget = decodeSymlinkMetadataValue(symlinkMarker ?? "");
|
|
419
481
|
}
|
|
420
482
|
if (symlinkTarget.length === 0) {
|
|
421
483
|
throw new Error(`Symlink record for ${key} had no target (body: ${bodyString.length} bytes, marker: ${symlinkMarker})`);
|
|
@@ -436,7 +498,7 @@ export async function downloadFile(ctx, key, localPath) {
|
|
|
436
498
|
}
|
|
437
499
|
}
|
|
438
500
|
fs.symlinkSync(symlinkTarget, localPath);
|
|
439
|
-
return { metadata
|
|
501
|
+
return { metadata };
|
|
440
502
|
}
|
|
441
503
|
// Symmetric to the symlink branch above: when a key was previously a
|
|
442
504
|
// symlink and is later replaced in S3 by a regular object, the local
|
|
@@ -460,12 +522,7 @@ export async function downloadFile(ctx, key, localPath) {
|
|
|
460
522
|
throw err;
|
|
461
523
|
}
|
|
462
524
|
}
|
|
463
|
-
|
|
464
|
-
const stream = response.Body;
|
|
465
|
-
for await (const chunk of stream) {
|
|
466
|
-
chunks.push(Buffer.from(chunk));
|
|
467
|
-
}
|
|
468
|
-
fs.writeFileSync(localPath, Buffer.concat(chunks));
|
|
525
|
+
fs.writeFileSync(localPath, objectBody);
|
|
469
526
|
// Bug #5 — apply source-side mode after the byte write. See
|
|
470
527
|
// FILE_MODE_META_KEY for the metadata contract. Parses defensively:
|
|
471
528
|
// a malformed value falls through with no chmod so the umask default
|
|
@@ -480,7 +537,7 @@ export async function downloadFile(ctx, key, localPath) {
|
|
|
480
537
|
// requires 1–4 pure octal digits (`[0-7]{1,4}$`), which matches what
|
|
481
538
|
// the upload side stamps (`(mode & 0o777).toString(8)` → at most
|
|
482
539
|
// three digits, all 0–7) and rejects everything else.
|
|
483
|
-
const modeOctal =
|
|
540
|
+
const modeOctal = metadata?.[FILE_MODE_META_KEY];
|
|
484
541
|
if (typeof modeOctal === "string" && /^[0-7]{1,4}$/.test(modeOctal)) {
|
|
485
542
|
const parsed = parseInt(modeOctal, 8);
|
|
486
543
|
if (Number.isFinite(parsed) && parsed >= 0 && parsed <= 0o777) {
|
|
@@ -519,7 +576,7 @@ export async function downloadFile(ctx, key, localPath) {
|
|
|
519
576
|
// similarly lstats after downloadFile). If a future caller stamps the
|
|
520
577
|
// journal BEFORE downloadFile completes, the fast-path will stale and
|
|
521
578
|
// re-hash every sync forever — keep the call-site invariant intact.
|
|
522
|
-
const mtimeRaw =
|
|
579
|
+
const mtimeRaw = metadata?.[FILE_MTIME_META_KEY];
|
|
523
580
|
if (typeof mtimeRaw === "string" && /^-?[0-9]{1,16}$/.test(mtimeRaw)) {
|
|
524
581
|
const mtimeMs = parseInt(mtimeRaw, 10);
|
|
525
582
|
if (Number.isFinite(mtimeMs)) {
|
|
@@ -543,26 +600,15 @@ export async function downloadFile(ctx, key, localPath) {
|
|
|
543
600
|
// The push side already emits hq-btime when the source FS tracks a
|
|
544
601
|
// distinct creation time, so a future receiver upgrade picks it up
|
|
545
602
|
// automatically without a server-side data migration.
|
|
546
|
-
return { metadata
|
|
603
|
+
return { metadata };
|
|
547
604
|
}
|
|
548
605
|
export async function listRemoteFiles(ctx, prefix) {
|
|
549
|
-
const
|
|
606
|
+
const io = resolveObjectIO(ctx);
|
|
550
607
|
const files = [];
|
|
551
608
|
let continuationToken;
|
|
552
609
|
do {
|
|
553
|
-
const
|
|
554
|
-
|
|
555
|
-
Prefix: prefix,
|
|
556
|
-
ContinuationToken: continuationToken,
|
|
557
|
-
}));
|
|
558
|
-
for (const obj of response.Contents || []) {
|
|
559
|
-
// Pre-fix this guard was `!obj.Key || !obj.Size`. The `!obj.Size` test
|
|
560
|
-
// is truthy when Size === 0 (a real 0-byte object like `.gitkeep`),
|
|
561
|
-
// silently filtering legitimate placeholder files out of every pull
|
|
562
|
-
// plan. Narrow the guard to "no key" only; surface real 0-byte
|
|
563
|
-
// objects to the planner.
|
|
564
|
-
if (!obj.Key)
|
|
565
|
-
continue;
|
|
610
|
+
const page = await io.listObjects({ prefix, continuationToken });
|
|
611
|
+
for (const obj of page.objects) {
|
|
566
612
|
// Drop S3 directory-marker objects: the canonical shape is `0-byte
|
|
567
613
|
// key ending in '/'` (S3 console "Create folder", `aws s3 sync` of
|
|
568
614
|
// empty dirs, sync tools that mirror empty trees). Two downstream
|
|
@@ -572,61 +618,39 @@ export async function listRemoteFiles(ctx, prefix) {
|
|
|
572
618
|
// → EISDIR "open" after the parent mkdir creates the leaf as a
|
|
573
619
|
// directory). Filtering here eliminates both.
|
|
574
620
|
//
|
|
575
|
-
// Narrow on
|
|
621
|
+
// Narrow on size===0 (not just trailing-slash) so a hypothetical
|
|
576
622
|
// non-empty object whose key happens to end in '/' is NOT silently
|
|
577
623
|
// hidden — it stays visible and downloadFile surfaces the same
|
|
578
624
|
// EISDIR "open" error pointing at the specific key, which is the
|
|
579
625
|
// signal an operator needs to reconcile the bucket. The vault
|
|
580
626
|
// service doesn't have a code path that produces such an object,
|
|
581
|
-
// but
|
|
627
|
+
// but the listing returns whatever lives in the bucket; silent
|
|
582
628
|
// drop would be worse than loud failure for that case.
|
|
583
629
|
//
|
|
584
630
|
// Real 0-byte placeholders like `.gitkeep` never end in `/` and
|
|
585
631
|
// continue to flow through — the 5.13.0 `.gitkeep` regression
|
|
586
|
-
// remains fixed.
|
|
587
|
-
if (obj.
|
|
632
|
+
// remains fixed. (The `!key` guard now lives in the ObjectIO layer.)
|
|
633
|
+
if (obj.key.endsWith("/") && obj.size === 0)
|
|
588
634
|
continue;
|
|
589
635
|
files.push({
|
|
590
|
-
key: obj.
|
|
591
|
-
size: obj.
|
|
592
|
-
lastModified: obj.
|
|
593
|
-
etag: obj.
|
|
636
|
+
key: obj.key,
|
|
637
|
+
size: obj.size,
|
|
638
|
+
lastModified: obj.lastModified,
|
|
639
|
+
etag: obj.etag,
|
|
594
640
|
});
|
|
595
641
|
}
|
|
596
|
-
continuationToken =
|
|
642
|
+
continuationToken = page.nextContinuationToken;
|
|
597
643
|
} while (continuationToken);
|
|
598
644
|
return files;
|
|
599
645
|
}
|
|
600
646
|
export async function deleteRemoteFile(ctx, key) {
|
|
601
|
-
|
|
602
|
-
await client.send(new DeleteObjectCommand({
|
|
603
|
-
Bucket: ctx.bucketName,
|
|
604
|
-
Key: key,
|
|
605
|
-
}));
|
|
647
|
+
await resolveObjectIO(ctx).deleteObject(key);
|
|
606
648
|
}
|
|
607
649
|
/**
|
|
608
650
|
* Check if a remote key exists and return its metadata.
|
|
609
651
|
*/
|
|
610
652
|
export async function headRemoteFile(ctx, key) {
|
|
611
|
-
|
|
612
|
-
try {
|
|
613
|
-
const response = await client.send(new HeadObjectCommand({
|
|
614
|
-
Bucket: ctx.bucketName,
|
|
615
|
-
Key: key,
|
|
616
|
-
}));
|
|
617
|
-
return {
|
|
618
|
-
lastModified: response.LastModified || new Date(),
|
|
619
|
-
etag: response.ETag || "",
|
|
620
|
-
size: response.ContentLength || 0,
|
|
621
|
-
metadata: response.Metadata,
|
|
622
|
-
};
|
|
623
|
-
}
|
|
624
|
-
catch (err) {
|
|
625
|
-
if (err && typeof err === "object" && "name" in err && err.name === "NotFound") {
|
|
626
|
-
return null;
|
|
627
|
-
}
|
|
628
|
-
throw err;
|
|
629
|
-
}
|
|
653
|
+
return resolveObjectIO(ctx).headObject(key);
|
|
630
654
|
}
|
|
631
655
|
function getMimeType(filePath) {
|
|
632
656
|
const ext = path.extname(filePath).toLowerCase();
|