@indigoai-us/hq-cloud 5.33.0 → 5.35.0

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
@@ -115,12 +115,21 @@ describe("uploadFile", () => {
115
115
  fs.writeFileSync(tmpFile, "hello");
116
116
  });
117
117
 
118
- it("omits Metadata when no author is provided (back-compat)", async () => {
118
+ it("omits author Metadata fields when no author is provided (back-compat)", async () => {
119
+ // Pre-Bug-#5: this test asserted Metadata was undefined entirely. Now
120
+ // \`hq-mode\` is stamped on every upload to preserve source-side
121
+ // permissions, so the assertion is narrower: author fields stay absent
122
+ // when no author is passed, but \`hq-mode\` is present.
119
123
  await uploadFile(makeCtx(), tmpFile, "attribution-test.md");
120
124
 
121
125
  const put = sentCommands.find((c) => c.name === "PutObjectCommand");
122
126
  expect(put).toBeDefined();
123
- expect(put!.input.Metadata).toBeUndefined();
127
+ const meta = put!.input.Metadata as Record<string, string> | undefined;
128
+ expect(meta?.["created-by"]).toBeUndefined();
129
+ expect(meta?.["created-by-sub"]).toBeUndefined();
130
+ expect(meta?.["created-at"]).toBeUndefined();
131
+ // \`hq-mode\` IS present — that's the new Bug #5 contract.
132
+ expect(meta?.["hq-mode"]).toMatch(/^[0-7]{3}$/);
124
133
  });
125
134
 
126
135
  it("stamps created-by + created-by-sub + created-at when author is provided", async () => {
@@ -179,6 +188,37 @@ describe("uploadFile", () => {
179
188
  expect(Date.now() - stamped).toBeLessThan(60 * 1000);
180
189
  });
181
190
 
191
+ it("stamps source file mode as hq-mode metadata (Bug #5 — preserve permissions)", async () => {
192
+ // Bug #5 (broader than originally reported): every uploaded file's mode
193
+ // collapsed to the receiver's umask default (0644 on the verification
194
+ // hosts). 0755 scripts arrived non-executable, breaking every shell-tool
195
+ // workflow. Fix: stamp the source-side \`mode & 0o777\` into S3 user
196
+ // metadata as \`hq-mode\` (octal string), then chmod on download.
197
+ fs.chmodSync(tmpFile, 0o755);
198
+
199
+ await uploadFile(makeCtx(), tmpFile, "exec.sh");
200
+
201
+ const put = sentCommands.find((c) => c.name === "PutObjectCommand");
202
+ expect(put).toBeDefined();
203
+ const meta = put!.input.Metadata as Record<string, string> | undefined;
204
+ expect(meta?.["hq-mode"]).toBe("755");
205
+ });
206
+
207
+ it("stamps various modes correctly (0600, 0640, 0700, 0750)", async () => {
208
+ // Verification report V5: all four modes collapsed to 0644 receiver-side
209
+ // because the upload carried no mode at all. Pin each mode round-trips
210
+ // through the metadata header in canonical octal-string form (no leading
211
+ // zero — the parser uses parseInt(..., 8)).
212
+ for (const mode of [0o600, 0o640, 0o700, 0o750]) {
213
+ sentCommands.length = 0;
214
+ fs.chmodSync(tmpFile, mode);
215
+ await uploadFile(makeCtx(), tmpFile, "f.bin");
216
+ const put = sentCommands.find((c) => c.name === "PutObjectCommand");
217
+ const meta = put!.input.Metadata as Record<string, string> | undefined;
218
+ expect(meta?.["hq-mode"]).toBe(mode.toString(8));
219
+ }
220
+ });
221
+
182
222
  it("elides non-ASCII or empty author fields rather than throwing", async () => {
183
223
  // S3 user-defined metadata must be ASCII-only and total ≤ 2KB. Partial
184
224
  // attribution beats hard failure — values that fail the printable check
@@ -630,6 +670,106 @@ describe("downloadFile", () => {
630
670
  expect(fs.readlinkSync(localPath)).toBe("fresh-target.md");
631
671
  });
632
672
 
673
+ it("applies hq-mode metadata via chmod after byte write (Bug #5 — preserve permissions)", async () => {
674
+ // Round-trip pair to the s3.upload test: source-side mode lives in
675
+ // \`Metadata['hq-mode']\` as an octal string; the receiver must chmod
676
+ // the file to that exact mode after writing the bytes. Pre-fix the
677
+ // receiver took the umask default and 0755 scripts arrived 0644.
678
+ nextGetObjectResponse = {
679
+ Body: (async function* () {
680
+ yield new Uint8Array([35, 33, 47, 98, 105, 110]); // "#!/bin"
681
+ })(),
682
+ Metadata: { "hq-mode": "755" },
683
+ };
684
+
685
+ const localPath = path.join(tmpRoot, "exec.sh");
686
+ await downloadFile(makeCtx(), "exec.sh", localPath);
687
+
688
+ // mask to permission bits — the upper bits encode file-type (S_IFREG)
689
+ // and are not preserved on chmod.
690
+ const stat = fs.statSync(localPath);
691
+ expect((stat.mode & 0o777).toString(8)).toBe("755");
692
+ });
693
+
694
+ it("rounds-trips multiple modes (0600/0640/0700/0750/0755) via hq-mode", async () => {
695
+ // Verification report V5 multi-mode pin: every mode collapsed to 0644
696
+ // because the receiver had no mode signal at all. With \`hq-mode\` in
697
+ // metadata, all five modes must arrive at their exact source value.
698
+ for (const octal of ["600", "640", "700", "750", "755"]) {
699
+ nextGetObjectResponse = {
700
+ Body: (async function* () {
701
+ yield new Uint8Array([97]); // "a"
702
+ })(),
703
+ Metadata: { "hq-mode": octal },
704
+ };
705
+ const localPath = path.join(tmpRoot, `mode-${octal}.bin`);
706
+ await downloadFile(makeCtx(), `mode-${octal}.bin`, localPath);
707
+ expect((fs.statSync(localPath).mode & 0o777).toString(8)).toBe(octal);
708
+ }
709
+ });
710
+
711
+ it("rejects malformed hq-mode metadata via strict-octal regex (Codex P2)", async () => {
712
+ // Codex review on PR #24 caught: parseInt(modeOctal, 8) accepts
713
+ // partial-prefix garbage — "755junk" parses to 0o755 instead of
714
+ // NaN — so tampered or malformed metadata could still chmod the
715
+ // local file. Strict regex MUST reject anything that isn't pure
716
+ // octal digits before parseInt sees it; the file then arrives at
717
+ // umask default like the legacy back-compat path.
718
+ const malformed = [
719
+ "755junk", // trailing garbage — parseInt parses 0o755 pre-fix
720
+ "0x755", // hex-looking prefix
721
+ "8", // out-of-octal-range digit
722
+ "9", // ditto
723
+ "-755", // signed
724
+ "abc", // non-numeric
725
+ "", // empty
726
+ "7777", // mode > 0o777 after parse
727
+ "12345", // too long (more than 4 octal digits)
728
+ " 755 ", // whitespace
729
+ ];
730
+ for (const bad of malformed) {
731
+ nextGetObjectResponse = {
732
+ Body: (async function* () {
733
+ yield new Uint8Array([116]); // "t"
734
+ })(),
735
+ Metadata: { "hq-mode": bad },
736
+ };
737
+ const localPath = path.join(tmpRoot, `bad-${malformed.indexOf(bad)}.bin`);
738
+ await downloadFile(makeCtx(), `bad-${malformed.indexOf(bad)}.bin`, localPath);
739
+ // Mode MUST be the umask default (whatever the test process inherits),
740
+ // NOT a partial-parse of the malformed string. Specifically, even if
741
+ // the malformed string would partial-parse to 0755, the file must NOT
742
+ // arrive at 0755 — it must take the umask default. We can't pin the
743
+ // exact default value (test runner umask varies), but we can pin that
744
+ // a partial-parse value (0o755) does NOT match for "755junk" cases.
745
+ const mode = (fs.statSync(localPath).mode & 0o777);
746
+ // Heuristic: if the parsed mode would have been 0o755, fail loudly.
747
+ // (umask default on most CI runners is 0o644 or 0o664, never 0o755.)
748
+ if (bad.startsWith("755") || bad === "0x755") {
749
+ expect(mode).not.toBe(0o755);
750
+ }
751
+ }
752
+ });
753
+
754
+ it("downloads with default umask permissions when hq-mode metadata is absent (back-compat)", async () => {
755
+ // Legacy uploads from pre-fix engines have no \`hq-mode\` metadata.
756
+ // The receiver must NOT crash and must NOT change the mode — let
757
+ // the OS default apply, mirroring the pre-fix behavior.
758
+ nextGetObjectResponse = {
759
+ Body: (async function* () {
760
+ yield new Uint8Array([108, 101, 103, 97, 99, 121]); // "legacy"
761
+ })(),
762
+ Metadata: {},
763
+ };
764
+
765
+ const localPath = path.join(tmpRoot, "legacy.bin");
766
+ await expect(
767
+ downloadFile(makeCtx(), "legacy.bin", localPath),
768
+ ).resolves.toBeDefined();
769
+ // No assertion on mode — receiver default is whatever umask set.
770
+ expect(fs.readFileSync(localPath, "utf-8")).toBe("legacy");
771
+ });
772
+
633
773
  it("returns the object's user-metadata (including created-by) for a regular file", async () => {
634
774
  nextGetObjectResponse = {
635
775
  Body: (async function* () {
package/src/s3.ts CHANGED
@@ -160,6 +160,28 @@ export function decodeSymlinkMetadataValue(value: string): string {
160
160
  */
161
161
  export const SYMLINK_BODY_PREFIX = "hq-symlink:";
162
162
 
163
+ /**
164
+ * S3 user-metadata key carrying the source-side file mode (permission bits
165
+ * only — \`mode & 0o777\`) as an octal string ("755", "640", etc.). On
166
+ * download, downloadFile parses this with \`parseInt(value, 8)\` and chmods
167
+ * the file to the exact source mode after the byte write.
168
+ *
169
+ * Bug #5 in the 5.33.0 deep-test was originally reported as "exec bit lost
170
+ * on sync" but the verification report broadened it: ALL modes (0600 / 0640
171
+ * / 0700 / 0750 / 0755) collapsed to the receiver's umask default (0644)
172
+ * because no mode signal crossed the wire at all. Stamping the mode in
173
+ * metadata is the smallest schema change that preserves the full
174
+ * permission bitfield without a per-host umask negotiation.
175
+ *
176
+ * Symlinks: skipped at upload time (symlink mode is OS-controlled
177
+ * lrwxrwxrwx) and skipped on download (\`fs.chmodSync\` follows symlinks
178
+ * and would mutate the target's mode instead).
179
+ *
180
+ * Back-compat: legacy uploads have no \`hq-mode\` header — the receiver
181
+ * leaves the umask default in place, matching pre-fix behavior.
182
+ */
183
+ export const FILE_MODE_META_KEY = "hq-mode";
184
+
163
185
  /**
164
186
  * Encode/decode the symlink wire body. Kept as exported helpers so the
165
187
  * format is centrally defined and tests can probe both sides without
@@ -178,6 +200,20 @@ export async function uploadFile(
178
200
  const client = buildClient(ctx);
179
201
  const body = fs.readFileSync(localPath);
180
202
 
203
+ // Capture source-side file mode (permission bits only) for Bug #5 — see
204
+ // FILE_MODE_META_KEY doc. Best-effort: lstat failure (raced rm, EPERM)
205
+ // falls through to "no mode header" and the receiver keeps its umask
206
+ // default — same as the legacy back-compat path.
207
+ let modeOctal: string | undefined;
208
+ try {
209
+ const lstat = fs.lstatSync(localPath);
210
+ if (!lstat.isSymbolicLink()) {
211
+ modeOctal = (lstat.mode & 0o777).toString(8);
212
+ }
213
+ } catch {
214
+ // Leave modeOctal undefined; receiver applies its umask default.
215
+ }
216
+
181
217
  // Preserve the original `created-at` across re-uploads when the object
182
218
  // already exists with author metadata — same convention the hq-console
183
219
  // upload route uses, so the NEW-pill ageing window doesn't reset on every
@@ -198,7 +234,10 @@ export async function uploadFile(
198
234
  }
199
235
  }
200
236
 
201
- const Metadata = author ? buildAuthorMetadata(author, createdAt) : undefined;
237
+ const Metadata: Record<string, string> = {
238
+ ...(author ? buildAuthorMetadata(author, createdAt) : {}),
239
+ ...(modeOctal ? { [FILE_MODE_META_KEY]: modeOctal } : {}),
240
+ };
202
241
 
203
242
  const response = await client.send(
204
243
  new PutObjectCommand({
@@ -206,7 +245,7 @@ export async function uploadFile(
206
245
  Key: key,
207
246
  Body: body,
208
247
  ContentType: getMimeType(key),
209
- ...(Metadata && Object.keys(Metadata).length > 0 ? { Metadata } : {}),
248
+ ...(Object.keys(Metadata).length > 0 ? { Metadata } : {}),
210
249
  }),
211
250
  );
212
251
 
@@ -404,6 +443,36 @@ export async function downloadFile(
404
443
  chunks.push(Buffer.from(chunk));
405
444
  }
406
445
  fs.writeFileSync(localPath, Buffer.concat(chunks));
446
+
447
+ // Bug #5 — apply source-side mode after the byte write. See
448
+ // FILE_MODE_META_KEY for the metadata contract. Parses defensively:
449
+ // a malformed value falls through with no chmod so the umask default
450
+ // applies, matching the legacy back-compat path. fs.chmodSync
451
+ // follows symlinks — that's fine here because we're on the regular-
452
+ // file branch (the symlink branch above already returned).
453
+ //
454
+ // Codex P2 (PR #24 round 3): strict octal-only regex BEFORE parseInt.
455
+ // parseInt(modeOctal, 8) accepts partial-prefix garbage — "755junk"
456
+ // parses to 0o755 instead of NaN — so tampered or malformed metadata
457
+ // could still change local permissions unexpectedly. The regex
458
+ // requires 1–4 pure octal digits (`[0-7]{1,4}$`), which matches what
459
+ // the upload side stamps (`(mode & 0o777).toString(8)` → at most
460
+ // three digits, all 0–7) and rejects everything else.
461
+ const modeOctal = response.Metadata?.[FILE_MODE_META_KEY];
462
+ if (typeof modeOctal === "string" && /^[0-7]{1,4}$/.test(modeOctal)) {
463
+ const parsed = parseInt(modeOctal, 8);
464
+ if (Number.isFinite(parsed) && parsed >= 0 && parsed <= 0o777) {
465
+ try {
466
+ fs.chmodSync(localPath, parsed);
467
+ } catch {
468
+ // chmod failure (read-only FS, EPERM) is non-fatal — the file
469
+ // is materialized, just with the umask default. Surface via
470
+ // S3-side metadata being present but the file not matching;
471
+ // a future operator-side audit can reconcile.
472
+ }
473
+ }
474
+ }
475
+
407
476
  return { metadata: response.Metadata };
408
477
  }
409
478