@indigoai-us/hq-cloud 6.14.5 → 6.14.7
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/dist/cli/reindex.d.ts.map +1 -1
- package/dist/cli/reindex.js +5 -3
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/rescue-core.d.ts +6 -0
- package/dist/cli/rescue-core.d.ts.map +1 -1
- package/dist/cli/rescue-core.js +55 -6
- package/dist/cli/rescue-core.js.map +1 -1
- package/dist/cli/rescue.test.js +24 -0
- package/dist/cli/rescue.test.js.map +1 -1
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +27 -21
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +47 -0
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +28 -6
- package/dist/cli/sync.js.map +1 -1
- package/dist/local-path-codec.d.ts +19 -0
- package/dist/local-path-codec.d.ts.map +1 -0
- package/dist/local-path-codec.js +69 -0
- package/dist/local-path-codec.js.map +1 -0
- package/dist/s3.d.ts +8 -0
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +52 -22
- package/dist/s3.js.map +1 -1
- package/dist/scope-shrink.d.ts.map +1 -1
- package/dist/scope-shrink.js +4 -3
- package/dist/scope-shrink.js.map +1 -1
- package/package.json +1 -1
- package/scripts/presign-transport-e2e.mjs +57 -10
- package/scripts/vault-rebaseline.sh +49 -1
- package/scripts/vault-rescue.sh +42 -1
- package/src/cli/reindex.ts +5 -3
- package/src/cli/rescue-core.ts +57 -10
- package/src/cli/rescue.test.ts +34 -0
- package/src/cli/share.test.ts +54 -0
- package/src/cli/share.ts +28 -21
- package/src/cli/sync.ts +33 -7
- package/src/local-path-codec.ts +95 -0
- package/src/s3.ts +61 -12
- package/src/scope-shrink.ts +4 -3
package/src/cli/share.test.ts
CHANGED
|
@@ -595,6 +595,60 @@ describe("share", () => {
|
|
|
595
595
|
});
|
|
596
596
|
});
|
|
597
597
|
|
|
598
|
+
it("cloud-authoritative: never pushes a stale local ontology/.last-run over the server value (one-sided-change clobber guard)", async () => {
|
|
599
|
+
// Regression for the gardener watermark clobber. A second HQ root sharing this
|
|
600
|
+
// machine's sync journal can leave the local mirror holding a STALE value A while
|
|
601
|
+
// the shared journal has already been advanced to the gardener's value B (etag EB).
|
|
602
|
+
// On this root's push leg that reads as localChanged (A != journal B) &&
|
|
603
|
+
// !remoteChanged (remote etag EB == journal remoteEtag EB). Previously that
|
|
604
|
+
// one-sided combination slipped past the CONFLICT-scoped isCloudAuthoritative
|
|
605
|
+
// fence and PUT the stale local value back over the server's — rewinding it. The
|
|
606
|
+
// fence now runs at the TOP of processUploadItem, so any ontology/ (server-owned)
|
|
607
|
+
// path is skipped before HEAD/upload regardless of the divergence shape.
|
|
608
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
609
|
+
fs.mkdirSync(path.join(companyRoot, "ontology"), { recursive: true });
|
|
610
|
+
const testFile = path.join(companyRoot, "ontology", ".last-run");
|
|
611
|
+
fs.writeFileSync(testFile, "1782275168000"); // stale value A
|
|
612
|
+
|
|
613
|
+
// The journal is already advanced to the gardener's value B (a sibling HQ root's
|
|
614
|
+
// pull stamped the shared journal), so localChanged=true. The fence skips the file
|
|
615
|
+
// BEFORE any HEAD/upload, so we deliberately queue NO headRemoteFile response and
|
|
616
|
+
// assert it is never consulted below — the skip is cheap, and a leftover
|
|
617
|
+
// mockResolvedValueOnce would otherwise leak into a later test's HEAD queue.
|
|
618
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
619
|
+
fs.writeFileSync(
|
|
620
|
+
journalPath,
|
|
621
|
+
JSON.stringify({
|
|
622
|
+
version: "1",
|
|
623
|
+
lastSync: new Date().toISOString(),
|
|
624
|
+
files: {
|
|
625
|
+
"ontology/.last-run": {
|
|
626
|
+
hash: "advanced-value-B-hash", // != hash(A) → localChanged=true
|
|
627
|
+
size: 13,
|
|
628
|
+
syncedAt: new Date().toISOString(),
|
|
629
|
+
direction: "up",
|
|
630
|
+
remoteEtag: "eb-advanced-etag", // == remote → remoteChanged=false
|
|
631
|
+
},
|
|
632
|
+
},
|
|
633
|
+
}),
|
|
634
|
+
);
|
|
635
|
+
|
|
636
|
+
const result = await share({
|
|
637
|
+
paths: [testFile],
|
|
638
|
+
company: "acme",
|
|
639
|
+
vaultConfig: mockConfig,
|
|
640
|
+
hqRoot: tmpDir,
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
// Server-owned file: push is skipped BEFORE HEAD, the stale local value is NEVER
|
|
644
|
+
// uploaded (the pull leg refreshes local from cloud instead), and it is not a conflict.
|
|
645
|
+
expect(uploadFile).not.toHaveBeenCalled();
|
|
646
|
+
expect(headRemoteFile).not.toHaveBeenCalled();
|
|
647
|
+
expect(result.filesUploaded).toBe(0);
|
|
648
|
+
expect(result.filesSkipped).toBeGreaterThanOrEqual(1);
|
|
649
|
+
expect(result.conflictPaths).toEqual([]);
|
|
650
|
+
});
|
|
651
|
+
|
|
598
652
|
it("first-time-upload-with-cloud-collision: emits conflict + writes mirror under --on-conflict keep (Bug #7)", async () => {
|
|
599
653
|
// Bug #7 (data-loss class) from the 5.33.0 deep test: when a file has
|
|
600
654
|
// NO prior journal entry (fresh upload from this machine) but the
|
package/src/cli/share.ts
CHANGED
|
@@ -11,6 +11,7 @@ import * as path from "path";
|
|
|
11
11
|
import type { EntityContext, VaultServiceConfig, SyncJournal } from "../types.js";
|
|
12
12
|
import { resolveEntityContext, isExpiringSoon, refreshEntityContext } from "../context.js";
|
|
13
13
|
import { createSyncProgressRecorder } from "../sync-progress.js";
|
|
14
|
+
import { localPathForVaultKey, vaultKeyForLocalPath } from "../local-path-codec.js";
|
|
14
15
|
import {
|
|
15
16
|
uploadFile,
|
|
16
17
|
uploadSymlink,
|
|
@@ -788,7 +789,7 @@ function wrapFilterWithScope(
|
|
|
788
789
|
): (absPath: string, isDir?: boolean) => boolean {
|
|
789
790
|
return (absPath: string, isDir?: boolean) => {
|
|
790
791
|
if (!underlying(absPath, isDir)) return false;
|
|
791
|
-
const rel =
|
|
792
|
+
const rel = vaultKeyForLocalPath(syncRoot, absPath);
|
|
792
793
|
if (rel === "" || rel.startsWith("..")) return true; // root / outside — defer
|
|
793
794
|
if (isDir) {
|
|
794
795
|
if (isDirInScope(rel, prefixSet)) return true;
|
|
@@ -819,7 +820,7 @@ export function wrapFilterWithIgnoreVisibility(
|
|
|
819
820
|
if (allowed) return true;
|
|
820
821
|
|
|
821
822
|
onAnyExcluded?.();
|
|
822
|
-
const rel =
|
|
823
|
+
const rel = vaultKeyForLocalPath(hqRoot, absPath);
|
|
823
824
|
if (rel === "" || rel.startsWith("..")) return false;
|
|
824
825
|
if (!isExpectedIgnore(rel)) onIgnoreExcluded(rel);
|
|
825
826
|
return false;
|
|
@@ -1232,6 +1233,19 @@ async function executeUploads(
|
|
|
1232
1233
|
if (aborted) return;
|
|
1233
1234
|
const { absolutePath, relativePath, localHash } = item;
|
|
1234
1235
|
|
|
1236
|
+
// Cloud-authoritative paths (server-regenerated: company-brief.md, board.json,
|
|
1237
|
+
// ontology/ signals/ sources/) are PULL-WINS and server-owned. The push leg must
|
|
1238
|
+
// NEVER upload the local copy over them — not just on a two-sided conflict, but on
|
|
1239
|
+
// ANY divergence, including a one-sided local change. A stale local mirror (e.g. a
|
|
1240
|
+
// second HQ root that shares this machine's sync journal) would otherwise read as
|
|
1241
|
+
// `localChanged && !remoteChanged`, slip past the conflict-scoped guard below, and
|
|
1242
|
+
// rewind the server's value — the `.last-run` watermark clobber. Skip before HEAD.
|
|
1243
|
+
if (isCloudAuthoritative(relativePath)) {
|
|
1244
|
+
run.emit({ type: "reconciled", path: relativePath, direction: "push" });
|
|
1245
|
+
counters.filesSkipped++;
|
|
1246
|
+
return;
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1235
1249
|
if (run.vaultConfig && isExpiringSoon(run.ctx.expiresAt)) {
|
|
1236
1250
|
run.ctx = await refreshEntityContext(run.companyRef, run.vaultConfig);
|
|
1237
1251
|
}
|
|
@@ -1286,16 +1300,9 @@ async function executeUploads(
|
|
|
1286
1300
|
}
|
|
1287
1301
|
|
|
1288
1302
|
if ((localChanged && remoteChanged) || isFreshCollision) {
|
|
1289
|
-
// Cloud-authoritative paths
|
|
1290
|
-
//
|
|
1291
|
-
//
|
|
1292
|
-
// mirror — the pull leg overwrites local from cloud. Declining here is
|
|
1293
|
-
// what stops the per-sync conflict-mirror loop on these files.
|
|
1294
|
-
if (isCloudAuthoritative(relativePath)) {
|
|
1295
|
-
run.emit({ type: "reconciled", path: relativePath, direction: "push" });
|
|
1296
|
-
counters.filesSkipped++;
|
|
1297
|
-
return;
|
|
1298
|
-
}
|
|
1303
|
+
// Cloud-authoritative paths are already skipped at the top of
|
|
1304
|
+
// processUploadItem (before HEAD), so anything reaching here is a genuine
|
|
1305
|
+
// conflict on a client-owned file.
|
|
1299
1306
|
conflictPaths.push(relativePath);
|
|
1300
1307
|
|
|
1301
1308
|
const resolution = await resolveConflictSerialized({
|
|
@@ -1450,13 +1457,13 @@ async function writePushConflictMirror(
|
|
|
1450
1457
|
try {
|
|
1451
1458
|
const detectedAt = new Date().toISOString();
|
|
1452
1459
|
const machineId = readShortMachineId(run.hqRoot);
|
|
1453
|
-
const originalRelative =
|
|
1460
|
+
const originalRelative = vaultKeyForLocalPath(run.hqRoot, item.absolutePath);
|
|
1454
1461
|
const conflictRelative = buildConflictPath(
|
|
1455
1462
|
originalRelative,
|
|
1456
1463
|
detectedAt,
|
|
1457
1464
|
machineId,
|
|
1458
1465
|
);
|
|
1459
|
-
const conflictAbs =
|
|
1466
|
+
const conflictAbs = localPathForVaultKey(run.hqRoot, conflictRelative);
|
|
1460
1467
|
if (!isMaterializationPathStillContained(run.syncRoot, conflictAbs)) {
|
|
1461
1468
|
run.emit({
|
|
1462
1469
|
type: "error",
|
|
@@ -1781,7 +1788,7 @@ function collectFiles(
|
|
|
1781
1788
|
console.error(` Warning: ${p} is outside company folder, skipping.`);
|
|
1782
1789
|
continue;
|
|
1783
1790
|
}
|
|
1784
|
-
const relativePath =
|
|
1791
|
+
const relativePath = vaultKeyForLocalPath(syncRoot, absolutePath);
|
|
1785
1792
|
// Probe the filter with both isDir hints — we don't know whether
|
|
1786
1793
|
// the link's target is a file or a directory without
|
|
1787
1794
|
// stat-following the link, which we explicitly avoid (it would
|
|
@@ -1811,7 +1818,7 @@ function collectFiles(
|
|
|
1811
1818
|
if (!filter(absolutePath, true)) continue;
|
|
1812
1819
|
results.push(...walkDir(absolutePath, syncRoot, filter));
|
|
1813
1820
|
} else if (lstat.isFile()) {
|
|
1814
|
-
const relativePath =
|
|
1821
|
+
const relativePath = vaultKeyForLocalPath(syncRoot, absolutePath);
|
|
1815
1822
|
if (filter(absolutePath)) {
|
|
1816
1823
|
results.push({ kind: "file", absolutePath, relativePath });
|
|
1817
1824
|
}
|
|
@@ -1861,7 +1868,7 @@ function walkDir(
|
|
|
1861
1868
|
results.push({
|
|
1862
1869
|
kind: "symlink",
|
|
1863
1870
|
absolutePath,
|
|
1864
|
-
relativePath:
|
|
1871
|
+
relativePath: vaultKeyForLocalPath(syncRoot, absolutePath),
|
|
1865
1872
|
target: fs.readlinkSync(absolutePath),
|
|
1866
1873
|
});
|
|
1867
1874
|
continue;
|
|
@@ -1877,7 +1884,7 @@ function walkDir(
|
|
|
1877
1884
|
results.push({
|
|
1878
1885
|
kind: "file",
|
|
1879
1886
|
absolutePath,
|
|
1880
|
-
relativePath:
|
|
1887
|
+
relativePath: vaultKeyForLocalPath(syncRoot, absolutePath),
|
|
1881
1888
|
});
|
|
1882
1889
|
}
|
|
1883
1890
|
}
|
|
@@ -1996,11 +2003,11 @@ function resolveDeleteScopeRoots(
|
|
|
1996
2003
|
if (!isWithin(syncRoot, absolutePath)) continue;
|
|
1997
2004
|
const stat = fs.statSync(absolutePath);
|
|
1998
2005
|
if (!stat.isDirectory()) continue;
|
|
1999
|
-
const rel =
|
|
2006
|
+
const rel = vaultKeyForLocalPath(syncRoot, absolutePath);
|
|
2000
2007
|
if (rel === "" || rel === ".") {
|
|
2001
2008
|
return [""];
|
|
2002
2009
|
}
|
|
2003
|
-
prefixes.add(rel
|
|
2010
|
+
prefixes.add(rel);
|
|
2004
2011
|
}
|
|
2005
2012
|
return Array.from(prefixes);
|
|
2006
2013
|
}
|
|
@@ -2238,7 +2245,7 @@ async function computeDeletePlan(
|
|
|
2238
2245
|
);
|
|
2239
2246
|
if (!inScope) continue;
|
|
2240
2247
|
inScopeJournalEntries++;
|
|
2241
|
-
const localPath =
|
|
2248
|
+
const localPath = localPathForVaultKey(syncRoot, relativeKey);
|
|
2242
2249
|
let presentLocally = true;
|
|
2243
2250
|
try {
|
|
2244
2251
|
fs.lstatSync(localPath);
|
package/src/cli/sync.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
} from "../telemetry-events.js";
|
|
20
20
|
import { resolveEntityContext, isExpiringSoon, refreshEntityContext } from "../context.js";
|
|
21
21
|
import { createSyncProgressRecorder } from "../sync-progress.js";
|
|
22
|
+
import { localPathForVaultKey, vaultKeyForLocalPath } from "../local-path-codec.js";
|
|
22
23
|
import {
|
|
23
24
|
downloadFile,
|
|
24
25
|
listRemoteFiles,
|
|
@@ -26,7 +27,7 @@ import {
|
|
|
26
27
|
primeObjectTransport,
|
|
27
28
|
toPosixKey,
|
|
28
29
|
} from "../s3.js";
|
|
29
|
-
import type { RemoteFile } from "../s3.js";
|
|
30
|
+
import type { DownloadModeWarning, RemoteFile } from "../s3.js";
|
|
30
31
|
import {
|
|
31
32
|
readJournal,
|
|
32
33
|
writeJournal,
|
|
@@ -971,6 +972,30 @@ function reportInvalidScopedKeys(run: PullRunContext, plan: PullPlan): void {
|
|
|
971
972
|
}, { claims: run.options.telemetryClaims });
|
|
972
973
|
}
|
|
973
974
|
|
|
975
|
+
/**
|
|
976
|
+
* A legacy object without hq-mode can otherwise downgrade a hook to the
|
|
977
|
+
* receiver umask with no durable signal. downloadFile already prints a local
|
|
978
|
+
* warning for every caller; the sync path also records a path-free telemetry
|
|
979
|
+
* event so operators can find vaults that still need the one-time backfill.
|
|
980
|
+
*/
|
|
981
|
+
function reportModeWarnings(
|
|
982
|
+
run: PullRunContext,
|
|
983
|
+
warnings: DownloadModeWarning[] | undefined,
|
|
984
|
+
): void {
|
|
985
|
+
for (const warning of warnings ?? []) {
|
|
986
|
+
void emitCloudTelemetry(new VaultClient(run.vaultConfig), {
|
|
987
|
+
eventName: "sync_mode_guardrail_warning",
|
|
988
|
+
source: "hq-sync",
|
|
989
|
+
companyUid: run.ctx.uid,
|
|
990
|
+
properties: {
|
|
991
|
+
leg: "pull",
|
|
992
|
+
reason: warning.reason,
|
|
993
|
+
fallback: warning.fallback ?? null,
|
|
994
|
+
},
|
|
995
|
+
}, { claims: run.options.telemetryClaims });
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
974
999
|
function createPullCounters(): PullCounters {
|
|
975
1000
|
return {
|
|
976
1001
|
filesDownloaded: 0,
|
|
@@ -1220,7 +1245,7 @@ async function executeConflictItem(
|
|
|
1220
1245
|
// the REMOTE hash/etag) and skip the conflict path entirely. This removes both
|
|
1221
1246
|
// the `.conflict-` mirror loop AND the journal false-stamp (remote etag over
|
|
1222
1247
|
// divergent local content) that the keep path produced for these files.
|
|
1223
|
-
if (isCloudAuthoritative(
|
|
1248
|
+
if (isCloudAuthoritative(vaultKeyForLocalPath(run.hqRoot, localPath))) {
|
|
1224
1249
|
downloadItems.push({ action: "download", remoteFile, localPath, isNew: false });
|
|
1225
1250
|
run.emit({ type: "reconciled", path: remoteFile.key, direction: "pull" });
|
|
1226
1251
|
return null;
|
|
@@ -1230,14 +1255,14 @@ async function executeConflictItem(
|
|
|
1230
1255
|
|
|
1231
1256
|
const detectedAt = new Date().toISOString();
|
|
1232
1257
|
const machineId = readShortMachineId(run.hqRoot);
|
|
1233
|
-
const originalRelative =
|
|
1258
|
+
const originalRelative = vaultKeyForLocalPath(run.hqRoot, localPath);
|
|
1234
1259
|
const conflictRelative = buildConflictPath(
|
|
1235
1260
|
originalRelative,
|
|
1236
1261
|
detectedAt,
|
|
1237
1262
|
machineId,
|
|
1238
1263
|
);
|
|
1239
|
-
const conflictAbs =
|
|
1240
|
-
const conflictKey =
|
|
1264
|
+
const conflictAbs = localPathForVaultKey(run.hqRoot, conflictRelative);
|
|
1265
|
+
const conflictKey = vaultKeyForLocalPath(run.companyRoot, conflictAbs);
|
|
1241
1266
|
|
|
1242
1267
|
if (!isDownloadWritePathStillContained(run.companyRoot, conflictKey, conflictAbs)) {
|
|
1243
1268
|
counters.filesSkipped++;
|
|
@@ -1459,11 +1484,12 @@ async function downloadOne(
|
|
|
1459
1484
|
}
|
|
1460
1485
|
|
|
1461
1486
|
try {
|
|
1462
|
-
const { metadata, contentHash, contentSize } = await downloadFile(
|
|
1487
|
+
const { metadata, contentHash, contentSize, modeWarnings } = await downloadFile(
|
|
1463
1488
|
run.ctx,
|
|
1464
1489
|
remoteFile.key,
|
|
1465
1490
|
localPath,
|
|
1466
1491
|
);
|
|
1492
|
+
reportModeWarnings(run, modeWarnings);
|
|
1467
1493
|
const author = metadata?.["created-by"] ?? null;
|
|
1468
1494
|
const createdBySub = metadata?.["created-by-sub"];
|
|
1469
1495
|
|
|
@@ -1803,7 +1829,7 @@ function resolveContainedVaultPath(root: string, key: string): string | null {
|
|
|
1803
1829
|
if (isMalformedVaultKey(key) || hasTraversalSegment(key)) return null;
|
|
1804
1830
|
|
|
1805
1831
|
const resolvedRoot = path.resolve(root);
|
|
1806
|
-
const resolvedLocal =
|
|
1832
|
+
const resolvedLocal = localPathForVaultKey(resolvedRoot, key);
|
|
1807
1833
|
if (!isPathWithin(resolvedRoot, resolvedLocal)) return null;
|
|
1808
1834
|
|
|
1809
1835
|
let realRoot: string;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reversible local filename adapter for canonical vault keys on Win32.
|
|
3
|
+
*
|
|
4
|
+
* Vault keys are POSIX identifiers and may contain ':' (for example
|
|
5
|
+
* `factory:slack`). Win32 cannot materialize those strings as filename
|
|
6
|
+
* segments, so keep the key canonical in S3 and journals while encoding only
|
|
7
|
+
* the local segment that reaches the filesystem.
|
|
8
|
+
*/
|
|
9
|
+
import * as path from "path";
|
|
10
|
+
|
|
11
|
+
const WIN32_RESERVED_CHARS = /[<>:"/\\|?*]/;
|
|
12
|
+
const WIN32_DEVICE_NAME = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i;
|
|
13
|
+
|
|
14
|
+
function isWin32ReservedCharacter(char: string): boolean {
|
|
15
|
+
return char.charCodeAt(0) <= 0x1f || WIN32_RESERVED_CHARS.test(char);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function percentEscape(char: string): string {
|
|
19
|
+
return `%${char.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Encode one canonical vault-key segment for a Win32 filename. Percent is
|
|
24
|
+
* escaped before every other character so decoding cannot collide with a
|
|
25
|
+
* literal `%3A` name. The optional flag makes platform-specific behavior
|
|
26
|
+
* directly testable without mutating `process.platform`.
|
|
27
|
+
*/
|
|
28
|
+
export function encodeLocalVaultSegment(
|
|
29
|
+
segment: string,
|
|
30
|
+
win32: boolean = process.platform === "win32",
|
|
31
|
+
): string {
|
|
32
|
+
if (!win32) return segment;
|
|
33
|
+
|
|
34
|
+
let encoded = "";
|
|
35
|
+
for (let index = 0; index < segment.length; index++) {
|
|
36
|
+
const char = segment[index]!;
|
|
37
|
+
const isTrailingDotOrSpace =
|
|
38
|
+
index === segment.length - 1 && (char === "." || char === " ");
|
|
39
|
+
const isDotSegment = (segment === "." || segment === "..") && index === 0;
|
|
40
|
+
const isReservedDeviceFirstChar = WIN32_DEVICE_NAME.test(segment) && index === 0;
|
|
41
|
+
if (
|
|
42
|
+
char === "%" ||
|
|
43
|
+
isWin32ReservedCharacter(char) ||
|
|
44
|
+
isTrailingDotOrSpace ||
|
|
45
|
+
isDotSegment ||
|
|
46
|
+
isReservedDeviceFirstChar
|
|
47
|
+
) {
|
|
48
|
+
encoded += percentEscape(char);
|
|
49
|
+
} else {
|
|
50
|
+
encoded += char;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return encoded;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Decode one local filename segment back to its canonical vault-key form. */
|
|
57
|
+
export function decodeLocalVaultSegment(
|
|
58
|
+
segment: string,
|
|
59
|
+
win32: boolean = process.platform === "win32",
|
|
60
|
+
): string {
|
|
61
|
+
if (!win32) return segment;
|
|
62
|
+
return segment.replace(/%([0-9a-f]{2})/gi, (_whole, hex: string) =>
|
|
63
|
+
String.fromCharCode(Number.parseInt(hex, 16)),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Materialize a canonical slash-delimited vault key beneath a local root. */
|
|
68
|
+
export function localPathForVaultKey(
|
|
69
|
+
root: string,
|
|
70
|
+
key: string,
|
|
71
|
+
win32: boolean = process.platform === "win32",
|
|
72
|
+
): string {
|
|
73
|
+
return path.join(
|
|
74
|
+
root,
|
|
75
|
+
...key.split("/").map((segment) => encodeLocalVaultSegment(segment, win32)),
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Turn a local, root-relative path back into its canonical vault key. This
|
|
81
|
+
* recognizes legacy percent-encoded Windows materializations as well as the
|
|
82
|
+
* current codec, so they converge on one remote/journal key instead of
|
|
83
|
+
* creating duplicates.
|
|
84
|
+
*/
|
|
85
|
+
export function vaultKeyForLocalPath(
|
|
86
|
+
root: string,
|
|
87
|
+
localPath: string,
|
|
88
|
+
win32: boolean = process.platform === "win32",
|
|
89
|
+
): string {
|
|
90
|
+
return path
|
|
91
|
+
.relative(root, localPath)
|
|
92
|
+
.split(path.sep)
|
|
93
|
+
.map((segment) => decodeLocalVaultSegment(segment, win32))
|
|
94
|
+
.join("/");
|
|
95
|
+
}
|
package/src/s3.ts
CHANGED
|
@@ -764,6 +764,13 @@ export async function uploadSymlink(
|
|
|
764
764
|
* return it to callers — e.g. the pull loop reads `created-by` to
|
|
765
765
|
* attribute downloaded files to their author with zero extra network.
|
|
766
766
|
*/
|
|
767
|
+
export interface DownloadModeWarning {
|
|
768
|
+
/** The mode guardrail that could not be applied exactly. */
|
|
769
|
+
reason: "missing-hq-mode" | "invalid-hq-mode" | "chmod-failed";
|
|
770
|
+
/** Legacy objects can retain a local file's known-good permission bits. */
|
|
771
|
+
fallback?: "preserved-local-mode" | "receiver-default";
|
|
772
|
+
}
|
|
773
|
+
|
|
767
774
|
export async function downloadFile(
|
|
768
775
|
ctx: EntityContext,
|
|
769
776
|
key: string,
|
|
@@ -772,6 +779,8 @@ export async function downloadFile(
|
|
|
772
779
|
metadata?: Record<string, string>;
|
|
773
780
|
contentHash?: string;
|
|
774
781
|
contentSize?: number;
|
|
782
|
+
/** Non-fatal mode guardrail warnings for caller telemetry. */
|
|
783
|
+
modeWarnings?: DownloadModeWarning[];
|
|
775
784
|
}> {
|
|
776
785
|
const io = resolveObjectIO(ctx);
|
|
777
786
|
|
|
@@ -867,15 +876,25 @@ export async function downloadFile(
|
|
|
867
876
|
return { metadata };
|
|
868
877
|
}
|
|
869
878
|
const tempPath = downloadTempPath(localPath);
|
|
879
|
+
// A legacy object has no hq-mode. If it is replacing a regular local file,
|
|
880
|
+
// that local mode is the only trustworthy signal we have — retaining it is
|
|
881
|
+
// strictly safer than replacing (for example) a 0755 hook with the receiver
|
|
882
|
+
// umask's usual 0644. Never inspect a symlink or infer intent from content.
|
|
883
|
+
let existingLocalMode: number | undefined;
|
|
884
|
+
try {
|
|
885
|
+
const existing = fs.lstatSync(localPath);
|
|
886
|
+
if (existing.isFile()) existingLocalMode = existing.mode & 0o777;
|
|
887
|
+
} catch {
|
|
888
|
+
// Fresh destination / inaccessible prior path: no local fallback.
|
|
889
|
+
}
|
|
870
890
|
let tempReady = true;
|
|
871
891
|
let streamed: { hash: string; size: number };
|
|
892
|
+
const modeWarnings: DownloadModeWarning[] = [];
|
|
872
893
|
try {
|
|
873
894
|
streamed = await streamRegularFileToTemp(tempPath, initialChunks, iterator);
|
|
874
895
|
|
|
875
896
|
// Bug #5 — apply source-side mode after the byte write. See
|
|
876
|
-
// FILE_MODE_META_KEY for the metadata contract.
|
|
877
|
-
// a malformed value falls through with no chmod so the umask default
|
|
878
|
-
// applies, matching the legacy back-compat path. The staged path is a
|
|
897
|
+
// FILE_MODE_META_KEY for the metadata contract. The staged path is a
|
|
879
898
|
// regular file, then it is atomically renamed over the destination.
|
|
880
899
|
//
|
|
881
900
|
// Codex P2 (PR #24 round 3): strict octal-only regex BEFORE parseInt.
|
|
@@ -886,17 +905,33 @@ export async function downloadFile(
|
|
|
886
905
|
// the upload side stamps (`(mode & 0o777).toString(8)` → at most
|
|
887
906
|
// three digits, all 0–7) and rejects everything else.
|
|
888
907
|
const modeOctal = metadata?.[FILE_MODE_META_KEY];
|
|
908
|
+
let modeToApply: number | undefined;
|
|
889
909
|
if (typeof modeOctal === "string" && /^[0-7]{1,4}$/.test(modeOctal)) {
|
|
890
910
|
const parsed = parseInt(modeOctal, 8);
|
|
891
911
|
if (Number.isFinite(parsed) && parsed >= 0 && parsed <= 0o777) {
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
912
|
+
modeToApply = parsed;
|
|
913
|
+
}
|
|
914
|
+
} else if (modeOctal === undefined) {
|
|
915
|
+
modeToApply = existingLocalMode;
|
|
916
|
+
modeWarnings.push({
|
|
917
|
+
reason: "missing-hq-mode",
|
|
918
|
+
fallback:
|
|
919
|
+
existingLocalMode === undefined
|
|
920
|
+
? "receiver-default"
|
|
921
|
+
: "preserved-local-mode",
|
|
922
|
+
});
|
|
923
|
+
} else {
|
|
924
|
+
modeWarnings.push({ reason: "invalid-hq-mode", fallback: "receiver-default" });
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
if (modeToApply !== undefined) {
|
|
928
|
+
try {
|
|
929
|
+
fs.chmodSync(tempPath, modeToApply);
|
|
930
|
+
} catch {
|
|
931
|
+
// chmod failure (read-only FS, EPERM) is non-fatal — the file is
|
|
932
|
+
// materialized, but callers must receive a telemetry signal instead
|
|
933
|
+
// of silently believing the guardrail held.
|
|
934
|
+
modeWarnings.push({ reason: "chmod-failed" });
|
|
900
935
|
}
|
|
901
936
|
}
|
|
902
937
|
|
|
@@ -953,7 +988,21 @@ export async function downloadFile(
|
|
|
953
988
|
// distinct creation time, so a future receiver upgrade picks it up
|
|
954
989
|
// automatically without a server-side data migration.
|
|
955
990
|
|
|
956
|
-
|
|
991
|
+
if (modeWarnings.length > 0) {
|
|
992
|
+
for (const warning of modeWarnings) {
|
|
993
|
+
console.warn(
|
|
994
|
+
`[hq-sync] mode warning for ${key}: ${warning.reason}` +
|
|
995
|
+
(warning.fallback ? ` (${warning.fallback})` : ""),
|
|
996
|
+
);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
return {
|
|
1001
|
+
metadata,
|
|
1002
|
+
contentHash: streamed.hash,
|
|
1003
|
+
contentSize: streamed.size,
|
|
1004
|
+
...(modeWarnings.length > 0 ? { modeWarnings } : {}),
|
|
1005
|
+
};
|
|
957
1006
|
}
|
|
958
1007
|
|
|
959
1008
|
export interface RemoteFile {
|
package/src/scope-shrink.ts
CHANGED
|
@@ -40,6 +40,7 @@ import type {
|
|
|
40
40
|
SyncJournal,
|
|
41
41
|
} from "./types.js";
|
|
42
42
|
import { hashFile, tombstoneEntry } from "./journal.js";
|
|
43
|
+
import { localPathForVaultKey } from "./local-path-codec.js";
|
|
43
44
|
import {
|
|
44
45
|
isCoveredByAny,
|
|
45
46
|
type ScopePrefixInput,
|
|
@@ -168,7 +169,7 @@ function classifyOrphan(
|
|
|
168
169
|
entry: JournalEntry,
|
|
169
170
|
hqRoot: string,
|
|
170
171
|
): OrphanClassification {
|
|
171
|
-
const absPath =
|
|
172
|
+
const absPath = localPathForVaultKey(hqRoot, relPath);
|
|
172
173
|
let stat: fs.Stats;
|
|
173
174
|
try {
|
|
174
175
|
stat = fs.lstatSync(absPath);
|
|
@@ -436,9 +437,9 @@ export function applyScopeShrink(
|
|
|
436
437
|
const dirtyKeptPaths: string[] = [];
|
|
437
438
|
|
|
438
439
|
for (const orphan of plan.clean) {
|
|
439
|
-
const absPath =
|
|
440
|
+
const absPath = localPathForVaultKey(hqRoot, orphan.path);
|
|
440
441
|
if (quarantining) {
|
|
441
|
-
const destAbs =
|
|
442
|
+
const destAbs = localPathForVaultKey(input.quarantineRoot!, orphan.path);
|
|
442
443
|
quarantineOrphan(absPath, destAbs);
|
|
443
444
|
tombstoneEntry(journal, orphan.path, reason);
|
|
444
445
|
cleanQuarantined++;
|