@indigoai-us/hq-cloud 6.14.19 → 6.14.21

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
@@ -98,6 +98,7 @@ import {
98
98
  createStagedSymlink,
99
99
  replaceStagedPath,
100
100
  sweepStaleStagedFiles,
101
+ DanglingSymlinkParentError,
101
102
  } from "./s3.js";
102
103
  import {
103
104
  setObjectIOFactory,
@@ -841,6 +842,50 @@ describe("downloadFile", () => {
841
842
  expect(fs.readlinkSync(localPath)).toBe(target);
842
843
  });
843
844
 
845
+ it("skips (typed, non-fatal) when the parent directory is a DANGLING symlink", async () => {
846
+ // The vault stores directory symlinks as objects (e.g.
847
+ // companies/<co>/.obsidian -> ../../.obsidian) AND files beneath them. On a
848
+ // machine where the link target is absent, mkdir of the parent throws
849
+ // ENOENT — not EEXIST — because mkdir(2) returns EEXIST for the link and
850
+ // Node's recursive impl then stats it, which fails on a dangling link.
851
+ // Raw, that marked the WHOLE company errored/partial on every sync cycle
852
+ // (2026-07-24 dogfood box). It must be a typed per-object skip instead.
853
+ nextGetObjectResponse = {
854
+ Body: (async function* () {
855
+ yield new TextEncoder().encode("hotkeys");
856
+ })(),
857
+ Metadata: {},
858
+ };
859
+
860
+ const parent = path.join(tmpRoot, ".obsidian");
861
+ fs.symlinkSync(path.join(tmpRoot, "does-not-exist"), parent);
862
+ // Precondition: existsSync follows the link and reports absent, while
863
+ // lstat still sees it — the exact shape the fix keys on.
864
+ expect(fs.existsSync(parent)).toBe(false);
865
+ expect(fs.lstatSync(parent).isSymbolicLink()).toBe(true);
866
+
867
+ await expect(
868
+ downloadFile(makeCtx(), ".obsidian/hotkeys.json", path.join(parent, "hotkeys.json")),
869
+ ).rejects.toBeInstanceOf(DanglingSymlinkParentError);
870
+
871
+ // The link is left exactly as found — no target materialized outside the
872
+ // directory being synced, which the caller's containment guard refuses.
873
+ expect(fs.lstatSync(parent).isSymbolicLink()).toBe(true);
874
+ expect(fs.existsSync(path.join(tmpRoot, "does-not-exist"))).toBe(false);
875
+ });
876
+
877
+ it("still creates an ordinary missing parent directory", async () => {
878
+ nextGetObjectResponse = {
879
+ Body: (async function* () {
880
+ yield new TextEncoder().encode("plain");
881
+ })(),
882
+ Metadata: {},
883
+ };
884
+ const localPath = path.join(tmpRoot, "deep", "nested", "file.md");
885
+ await downloadFile(makeCtx(), "deep/nested/file.md", localPath);
886
+ expect(fs.readFileSync(localPath, "utf8")).toBe("plain");
887
+ });
888
+
844
889
  it("recovers the target from legacy metadata-only uploads (body lacks the prefix)", async () => {
845
890
  // Backward-compat: an in-flight upload from earlier in this
846
891
  // PR's lifetime stored the target in metadata (raw or base64'd)
package/src/s3.ts CHANGED
@@ -1003,6 +1003,59 @@ export interface DownloadModeWarning {
1003
1003
  fallback?: "preserved-local-mode" | "receiver-default";
1004
1004
  }
1005
1005
 
1006
+ /**
1007
+ * A download whose parent directory exists as a DANGLING symlink.
1008
+ *
1009
+ * The vault stores directory symlinks as first-class objects (e.g.
1010
+ * `companies/<co>/.obsidian -> ../../.obsidian`) AND stores files beneath them.
1011
+ * When the link's target is absent on this machine, the child write fails —
1012
+ * and it fails as ENOENT rather than EEXIST, because `mkdir(2)` returns EEXIST
1013
+ * for the link and Node's `recursive: true` implementation then stats it, which
1014
+ * fails on a dangling link. Raw, that surfaced as a hard error that marked the
1015
+ * WHOLE company `errored`/`partial` on every sync cycle (2026-07-24 dogfood box:
1016
+ * `.obsidian/hotkeys.json: ENOENT ... mkdir '.../companies/indigo/.obsidian'`).
1017
+ *
1018
+ * It is a per-object condition, not a company-level failure, so it is typed and
1019
+ * the pull loop skips the object loudly instead of failing the run. We do NOT
1020
+ * materialize the link target: the target is by definition outside the
1021
+ * directory being synced, and creating it would write through a path the
1022
+ * caller's containment guard deliberately refuses.
1023
+ */
1024
+ export class DanglingSymlinkParentError extends Error {
1025
+ readonly key: string;
1026
+ readonly dir: string;
1027
+ constructor(key: string, dir: string) {
1028
+ super(
1029
+ `download skipped: parent directory is a dangling symlink (${dir}) — ` +
1030
+ `the vault stores it as a link whose target is absent locally`,
1031
+ );
1032
+ this.name = "DanglingSymlinkParentError";
1033
+ this.key = key;
1034
+ this.dir = dir;
1035
+ }
1036
+ }
1037
+
1038
+ /**
1039
+ * Create the parent directory for a download, distinguishing the
1040
+ * dangling-symlink case from a genuine mkdir failure.
1041
+ *
1042
+ * Callers must have already established that `dir` does not resolve
1043
+ * (`fs.existsSync(dir) === false`); `existsSync` FOLLOWS symlinks, so a
1044
+ * dangling link reports absent here while `lstat` still sees the link.
1045
+ */
1046
+ function ensureDownloadParentDir(dir: string, key: string): void {
1047
+ let link: fs.Stats | null = null;
1048
+ try {
1049
+ link = fs.lstatSync(dir);
1050
+ } catch {
1051
+ // Genuinely absent — the ordinary first-write path.
1052
+ }
1053
+ if (link?.isSymbolicLink()) {
1054
+ throw new DanglingSymlinkParentError(key, dir);
1055
+ }
1056
+ fs.mkdirSync(dir, { recursive: true });
1057
+ }
1058
+
1006
1059
  export async function downloadFile(
1007
1060
  ctx: EntityContext,
1008
1061
  key: string,
@@ -1032,7 +1085,7 @@ export async function downloadFile(
1032
1085
 
1033
1086
  const dir = path.dirname(localPath);
1034
1087
  if (!fs.existsSync(dir)) {
1035
- fs.mkdirSync(dir, { recursive: true });
1088
+ ensureDownloadParentDir(dir, key);
1036
1089
  }
1037
1090
 
1038
1091
  // Symlink path: presence of SYMLINK_TARGET_META_KEY (any non-empty