@indigoai-us/hq-cloud 5.33.0 → 5.34.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.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