@indigoai-us/hq-cloud 6.14.46 → 6.14.48

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.
@@ -16,6 +16,7 @@ import {
16
16
  readJournal,
17
17
  writeJournal,
18
18
  updateEntry,
19
+ PrematureJournalEntryError,
19
20
  migrateToV2,
20
21
  normalizeJournalKeys,
21
22
  appendPullRecord,
@@ -208,12 +209,54 @@ describe("journal", () => {
208
209
  describe("updateEntry", () => {
209
210
  it("stamps lastSync and the per-file syncedAt", () => {
210
211
  const j: SyncJournal = { version: "1", lastSync: "", files: {} };
211
- updateEntry(j, "foo.md", "hash", 10, "up");
212
+ const abs = path.join(stateDir, "foo.md");
213
+ fs.writeFileSync(abs, "x");
214
+ updateEntry(j, "foo.md", "hash", 10, "up", abs);
212
215
  expect(j.files["foo.md"]?.hash).toBe("hash");
213
216
  expect(j.files["foo.md"]?.direction).toBe("up");
214
217
  expect(j.lastSync).not.toBe("");
215
218
  expect(j.files["foo.md"]?.syncedAt).not.toBe("");
216
219
  });
220
+
221
+ // Load-bearing once delete propagation authorizes on ETag currency alone:
222
+ // an entry whose file was never written is indistinguishable from "the
223
+ // user deleted this", and the next push would issue a remote DeleteObject
224
+ // for what was only premature bookkeeping.
225
+ it("refuses to journal a path that is not on disk", () => {
226
+ const j: SyncJournal = { version: "1", lastSync: "", files: {} };
227
+ const missing = path.join(stateDir, "never-written.md");
228
+ expect(() => updateEntry(j, "never-written.md", "hash", 10, "down", missing)).toThrow(
229
+ PrematureJournalEntryError,
230
+ );
231
+ expect(j.files).not.toHaveProperty("never-written.md");
232
+ expect(j.lastSync).toBe("");
233
+ });
234
+
235
+ it("accepts a dangling symlink — the link itself is the synced object", () => {
236
+ const j: SyncJournal = { version: "1", lastSync: "", files: {} };
237
+ const link = path.join(stateDir, "dangling-link");
238
+ fs.symlinkSync(path.join(stateDir, "no-such-target"), link);
239
+ expect(() =>
240
+ updateEntry(j, "dangling-link", "hash", 0, "down", link, { kind: "symlink" }),
241
+ ).not.toThrow();
242
+ expect(j.files["dangling-link"]?.kind).toBe("symlink");
243
+ });
244
+
245
+ // Regression: `kind` and `createdBySub` were adjacent trailing optionals
246
+ // and two call sites passed kind into the createdBySub slot, leaving
247
+ // entry.kind unset. entry.kind gates delete-intent minting, so those
248
+ // entries could never be deleted. Named options make it unrepresentable.
249
+ it("records kind and createdBySub into their own fields", () => {
250
+ const j: SyncJournal = { version: "1", lastSync: "", files: {} };
251
+ const abs = path.join(stateDir, "typed.md");
252
+ fs.writeFileSync(abs, "x");
253
+ updateEntry(j, "typed.md", "hash", 1, "down", abs, {
254
+ kind: "file",
255
+ createdBySub: "sub-123",
256
+ });
257
+ expect(j.files["typed.md"]?.kind).toBe("file");
258
+ expect(j.files["typed.md"]?.createdBySub).toBe("sub-123");
259
+ });
217
260
  });
218
261
 
219
262
  // ── Journal v2 (US-005): pulls, tombstones, GC, migration ─────────────────
package/src/journal.ts CHANGED
@@ -416,17 +416,82 @@ export function hashSymlinkTarget(target: string): string {
416
416
  * via `hq files get` are pulled — `direction:"down"` — but ride the pin union
417
417
  * in the caller's inclusion prefixSet, so they are likewise never pruned.)
418
418
  */
419
+ /**
420
+ * Thrown when a caller tries to journal a path that is not on disk.
421
+ *
422
+ * Deliberately loud rather than a silent skip: a caller reaching this has a
423
+ * real ordering bug, and swallowing it would reintroduce exactly the silent
424
+ * drift this guard exists to prevent.
425
+ */
426
+ export class PrematureJournalEntryError extends Error {
427
+ constructor(readonly relativePath: string, readonly absolutePath: string) {
428
+ super(
429
+ `refusing to journal '${relativePath}': no file at '${absolutePath}'. ` +
430
+ `A journal entry must be written only AFTER its file is on disk.`,
431
+ );
432
+ this.name = "PrematureJournalEntryError";
433
+ }
434
+ }
435
+
436
+ /**
437
+ * Optional fields of a journal entry.
438
+ *
439
+ * These are an OBJECT rather than trailing positional parameters because they
440
+ * were four adjacent optionals, two of them `string`, and callers drifted:
441
+ * two conflict-resolution sites in `cli/sync.ts` passed their `"file" |
442
+ * "symlink"` value into the `createdBySub` slot, so `entry.kind` was silently
443
+ * never set on any conflict-resolved entry. That is not cosmetic — `entry.kind`
444
+ * gates delete-intent minting (`markLocalDeleteIntent` requires it to match,
445
+ * and the watcher skips any entry without one), so a file that had ever been
446
+ * through conflict resolution could never be deleted, even with a live watcher.
447
+ * Named fields make that class of mistake unrepresentable.
448
+ */
449
+ export interface UpdateEntryOptions {
450
+ remoteEtag?: string;
451
+ mtimeMs?: number;
452
+ /** Object's `created-by-sub` S3 metadata. Download path only. */
453
+ createdBySub?: string;
454
+ kind?: "file" | "symlink";
455
+ }
456
+
457
+ /**
458
+ * Record a journal entry for a file that is CONFIRMED present on disk.
459
+ *
460
+ * `verifyAbsolutePath` is required, and the entry is written only after an
461
+ * `lstat` proves the file exists. This is a load-bearing invariant, not a
462
+ * defensive nicety: a journal entry says "this key was synced and the local
463
+ * copy is at this hash". Once delete propagation authorizes on ETag currency
464
+ * alone (no watcher-minted intent), an entry whose file was never actually
465
+ * written becomes indistinguishable from "the user deleted this file" — and
466
+ * the next push issues a remote DeleteObject for a file that was only ever
467
+ * premature bookkeeping.
468
+ *
469
+ * Every existing caller already had a `fs.lstatSync` in hand and passed values
470
+ * derived from it, so this makes an existing convention structural. The point
471
+ * is that a FUTURE caller cannot get it wrong: with the check inside this
472
+ * function there is no longer a way to add a call site that records an entry
473
+ * for a file that is not there.
474
+ *
475
+ * `lstat` (not `stat`) so a dangling symlink still counts as present — the
476
+ * link itself is the synced object, and its target may legitimately be absent.
477
+ *
478
+ * @throws PrematureJournalEntryError when nothing exists at `verifyAbsolutePath`.
479
+ */
419
480
  export function updateEntry(
420
481
  journal: SyncJournal,
421
482
  relativePath: string,
422
483
  hash: string,
423
484
  size: number,
424
485
  direction: "up" | "down",
425
- remoteEtag?: string,
426
- mtimeMs?: number,
427
- createdBySub?: string,
428
- kind?: "file" | "symlink",
486
+ verifyAbsolutePath: string,
487
+ opts: UpdateEntryOptions = {},
429
488
  ): void {
489
+ try {
490
+ fs.lstatSync(verifyAbsolutePath);
491
+ } catch {
492
+ throw new PrematureJournalEntryError(relativePath, verifyAbsolutePath);
493
+ }
494
+ const { remoteEtag, mtimeMs, createdBySub, kind } = opts;
430
495
  const entry: JournalEntry = {
431
496
  hash,
432
497
  size,
package/src/s3.test.ts CHANGED
@@ -1679,6 +1679,12 @@ describe("validateVaultUploadKey — direct-S3 key poisoning guard (incident 202
1679
1679
  ["a/../b.md", "INVALID_KEY_DOT_COMPONENT"],
1680
1680
  ["a/./b.md", "INVALID_KEY_DOT_COMPONENT"],
1681
1681
  ["bad\x00key.md", "INVALID_KEY_CONTROL_CHARS"],
1682
+ // macOS Finder custom-icon file. Named explicitly because this is the
1683
+ // spelling that reaches the validator in the wild: Finder writes one into
1684
+ // every directory it renders a custom icon for, and the push walker now
1685
+ // excludes them precisely because this throw is unavoidable.
1686
+ ["Icon\r", "INVALID_KEY_CONTROL_CHARS"],
1687
+ ["docs/Icon\r", "INVALID_KEY_CONTROL_CHARS"],
1682
1688
  ])(
1683
1689
  "validateVaultUploadKey mirrors the server rule set: %j → %s",
1684
1690
  (key, code) => {