@indigoai-us/hq-cloud 6.14.10 → 6.14.12

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/src/s3.test.ts CHANGED
@@ -95,6 +95,7 @@ import {
95
95
  FILE_BTIME_META_KEY,
96
96
  classifyVaultKey,
97
97
  validateVaultUploadKey,
98
+ replaceStagedPath,
98
99
  } from "./s3.js";
99
100
  import {
100
101
  setObjectIOFactory,
@@ -918,6 +919,96 @@ describe("downloadFile", () => {
918
919
  expect(fs.readFileSync(localPath, "utf-8")).toBe("existing local copy");
919
920
  });
920
921
 
922
+ it("replaces an existing directory link without changing its target", async () => {
923
+ const targetDir = path.join(tmpRoot, "generated-skill-source");
924
+ const localPath = path.join(tmpRoot, "generated-skill-link");
925
+ fs.mkdirSync(targetDir);
926
+ fs.writeFileSync(path.join(targetDir, "sentinel.txt"), "source-owned");
927
+ fs.symlinkSync(targetDir, localPath, "dir");
928
+
929
+ nextGetObjectResponse = {
930
+ Body: (async function* () {
931
+ yield Buffer.from("remote replacement");
932
+ })(),
933
+ Metadata: {},
934
+ };
935
+
936
+ await downloadFile(makeCtx(), "generated-skill-link", localPath);
937
+
938
+ expect(fs.lstatSync(localPath).isSymbolicLink()).toBe(false);
939
+ expect(fs.readFileSync(localPath, "utf8")).toBe("remote replacement");
940
+ expect(fs.readFileSync(path.join(targetDir, "sentinel.txt"), "utf8")).toBe(
941
+ "source-owned",
942
+ );
943
+ });
944
+
945
+ it("restores an existing directory link when staged replacement fails", () => {
946
+ const targetDir = path.join(tmpRoot, "rollback-source");
947
+ const localPath = path.join(tmpRoot, "rollback-link");
948
+ const stagedPath = path.join(tmpRoot, ".hq-tmp-rollback-test");
949
+ fs.mkdirSync(targetDir);
950
+ fs.symlinkSync(targetDir, localPath, "dir");
951
+ fs.writeFileSync(stagedPath, "staged replacement");
952
+
953
+ let renameCalls = 0;
954
+ expect(() =>
955
+ replaceStagedPath(stagedPath, localPath, {
956
+ lstat: (p) => fs.lstatSync(p),
957
+ rename: (from, to) => {
958
+ renameCalls++;
959
+ if (renameCalls === 2) {
960
+ throw Object.assign(new Error("simulated Windows replacement failure"), {
961
+ code: "EPERM",
962
+ });
963
+ }
964
+ fs.renameSync(from, to);
965
+ },
966
+ remove: (p) => fs.unlinkSync(p),
967
+ }),
968
+ ).toThrow("simulated Windows replacement failure");
969
+
970
+ expect(renameCalls).toBe(3);
971
+ expect(fs.lstatSync(localPath).isSymbolicLink()).toBe(true);
972
+ expect(fs.readlinkSync(localPath)).toBe(targetDir);
973
+ expect(fs.readFileSync(stagedPath, "utf8")).toBe("staged replacement");
974
+ expect(
975
+ fs.readdirSync(tmpRoot).filter((name) => name.startsWith(".hq-backup-")),
976
+ ).toEqual([]);
977
+ });
978
+
979
+ it("restores an existing directory link when backup cleanup fails", () => {
980
+ const targetDir = path.join(tmpRoot, "cleanup-rollback-source");
981
+ const localPath = path.join(tmpRoot, "cleanup-rollback-link");
982
+ const stagedPath = path.join(tmpRoot, ".hq-tmp-cleanup-rollback-test");
983
+ fs.mkdirSync(targetDir);
984
+ fs.symlinkSync(targetDir, localPath, "dir");
985
+ fs.writeFileSync(stagedPath, "staged replacement");
986
+
987
+ let renameCalls = 0;
988
+ expect(() =>
989
+ replaceStagedPath(stagedPath, localPath, {
990
+ lstat: (p) => fs.lstatSync(p),
991
+ rename: (from, to) => {
992
+ renameCalls++;
993
+ fs.renameSync(from, to);
994
+ },
995
+ remove: () => {
996
+ throw Object.assign(new Error("simulated backup cleanup failure"), {
997
+ code: "EPERM",
998
+ });
999
+ },
1000
+ }),
1001
+ ).toThrow("simulated backup cleanup failure");
1002
+
1003
+ expect(renameCalls).toBe(4);
1004
+ expect(fs.lstatSync(localPath).isSymbolicLink()).toBe(true);
1005
+ expect(fs.readlinkSync(localPath)).toBe(targetDir);
1006
+ expect(fs.readFileSync(stagedPath, "utf8")).toBe("staged replacement");
1007
+ expect(
1008
+ fs.readdirSync(tmpRoot).filter((name) => name.startsWith(".hq-backup-")),
1009
+ ).toEqual([]);
1010
+ });
1011
+
921
1012
  it("R-F11: downloads to a near component-limit basename via a bounded temp name", async () => {
922
1013
  nextGetObjectResponse = {
923
1014
  Body: (async function* () {
package/src/s3.ts CHANGED
@@ -253,6 +253,89 @@ function removeTempPath(tempPath: string): void {
253
253
  }
254
254
  }
255
255
 
256
+ export interface ReplaceStagedPathOps {
257
+ lstat(path: string): fs.Stats;
258
+ rename(from: string, to: string): void;
259
+ remove(path: string): void;
260
+ }
261
+
262
+ const DEFAULT_REPLACE_STAGED_PATH_OPS: ReplaceStagedPathOps = {
263
+ lstat: (p) => fs.lstatSync(p),
264
+ rename: (from, to) => fs.renameSync(from, to),
265
+ remove: (p) => fs.unlinkSync(p),
266
+ };
267
+
268
+ function replacementBackupPath(localPath: string): string {
269
+ const dir = path.dirname(localPath);
270
+ const random = crypto.randomBytes(10).toString("hex");
271
+ return path.join(dir, `.hq-backup-${random}`);
272
+ }
273
+
274
+ /**
275
+ * Install a fully-staged download over its destination.
276
+ *
277
+ * A direct rename is atomic and remains the fast path for absent destinations
278
+ * and ordinary files. Windows cannot rename over an existing directory link,
279
+ * though, because MoveFileEx treats that link as an existing directory. For a
280
+ * symlink/junction destination, move the old link to a sibling backup first,
281
+ * install the staged entry, then remove the backup. Any failure after the
282
+ * backup move restores the exact prior link before rethrowing.
283
+ *
284
+ * The tiny operations seam keeps the Windows failure/rollback contract
285
+ * deterministic in ESM tests without spying on non-configurable fs exports.
286
+ */
287
+ export function replaceStagedPath(
288
+ stagedPath: string,
289
+ localPath: string,
290
+ ops: ReplaceStagedPathOps = DEFAULT_REPLACE_STAGED_PATH_OPS,
291
+ ): void {
292
+ let existing: fs.Stats;
293
+ try {
294
+ existing = ops.lstat(localPath);
295
+ } catch (err) {
296
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
297
+ ops.rename(stagedPath, localPath);
298
+ return;
299
+ }
300
+ throw err;
301
+ }
302
+
303
+ if (!existing.isSymbolicLink()) {
304
+ ops.rename(stagedPath, localPath);
305
+ return;
306
+ }
307
+
308
+ const backupPath = replacementBackupPath(localPath);
309
+ ops.rename(localPath, backupPath);
310
+ let stagedInstalled = false;
311
+ try {
312
+ ops.rename(stagedPath, localPath);
313
+ stagedInstalled = true;
314
+ ops.remove(backupPath);
315
+ } catch (primaryError) {
316
+ const rollbackErrors: unknown[] = [];
317
+ if (stagedInstalled) {
318
+ try {
319
+ ops.rename(localPath, stagedPath);
320
+ } catch (rollbackError) {
321
+ rollbackErrors.push(rollbackError);
322
+ }
323
+ }
324
+ try {
325
+ ops.rename(backupPath, localPath);
326
+ } catch (rollbackError) {
327
+ rollbackErrors.push(rollbackError);
328
+ }
329
+ if (rollbackErrors.length > 0) {
330
+ throw new AggregateError(
331
+ [primaryError, ...rollbackErrors],
332
+ "Staged path replacement failed and the previous directory link could not be fully restored",
333
+ );
334
+ }
335
+ throw primaryError;
336
+ }
337
+ }
338
+
256
339
  async function getObjectStream(
257
340
  io: ObjectIO,
258
341
  key: string,
@@ -904,7 +987,7 @@ export async function downloadFile(
904
987
  try {
905
988
  fs.symlinkSync(symlinkTarget, tempPath);
906
989
  options.beforeReplace?.();
907
- fs.renameSync(tempPath, localPath);
990
+ replaceStagedPath(tempPath, localPath);
908
991
  } catch (err) {
909
992
  removeTempPath(tempPath);
910
993
  throw err;
@@ -1014,7 +1097,7 @@ export async function downloadFile(
1014
1097
  }
1015
1098
 
1016
1099
  options.beforeReplace?.();
1017
- fs.renameSync(tempPath, localPath);
1100
+ replaceStagedPath(tempPath, localPath);
1018
1101
  tempReady = false;
1019
1102
  } finally {
1020
1103
  if (tempReady) removeTempPath(tempPath);