aethel 0.3.6 → 0.3.8
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/CHANGELOG.md +8 -0
- package/LICENSE +1 -1
- package/package.json +1 -1
- package/src/cli.js +126 -9
- package/src/core/auth.js +4 -3
- package/src/core/config.js +21 -2
- package/src/core/drive-api.js +44 -2
- package/src/core/repository.js +116 -6
- package/src/core/snapshot.js +47 -1
- package/src/core/sync.js +30 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.8 (2026-04-06)
|
|
4
|
+
|
|
5
|
+
- Fix Drive upload checksum test stub
|
|
6
|
+
|
|
7
|
+
## 0.3.7 (2026-04-05)
|
|
8
|
+
|
|
9
|
+
- Fix orphan checker not recognizing My Drive root — all files under synced folders were silently dropped
|
|
10
|
+
|
|
3
11
|
## 0.3.6 (2026-04-05)
|
|
4
12
|
|
|
5
13
|
- Optimize status and saveSnapshot performance: parallelize loadState, skip redundant fetches, increase hash concurrency
|
package/LICENSE
CHANGED
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -60,12 +60,36 @@ async function openRepo(options, { requireWorkspace = true, silent = false } = {
|
|
|
60
60
|
return repo;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
function fmtMs(ms) {
|
|
64
|
+
return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`;
|
|
65
|
+
}
|
|
66
|
+
|
|
63
67
|
async function loadStateWithProgress(repo, opts) {
|
|
64
68
|
const spinner = createSpinner("Loading workspace state...");
|
|
65
69
|
try {
|
|
66
|
-
const state = await repo.loadState(
|
|
67
|
-
|
|
68
|
-
|
|
70
|
+
const state = await repo.loadState({
|
|
71
|
+
...opts,
|
|
72
|
+
onPhase(phase, ms) {
|
|
73
|
+
if (phase === "local") spinner.update(`Scanned local files (${fmtMs(ms)}), waiting for remote...`);
|
|
74
|
+
else if (phase === "remote") spinner.update(`Fetched remote state (${fmtMs(ms)}), computing diff...`);
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
const { timings, diff } = state;
|
|
78
|
+
const n = diff.changes.length;
|
|
79
|
+
|
|
80
|
+
const parts = [
|
|
81
|
+
`${timings.localFiles} local`,
|
|
82
|
+
`${timings.remoteFiles} remote`,
|
|
83
|
+
];
|
|
84
|
+
const times = [
|
|
85
|
+
`scan ${fmtMs(timings.localMs)}`,
|
|
86
|
+
timings.remoteCached ? `remote cache hit` : `fetch ${fmtMs(timings.remoteMs)}`,
|
|
87
|
+
`diff ${fmtMs(timings.diffMs)}`,
|
|
88
|
+
`total ${fmtMs(timings.totalMs)}`,
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
const summary = n ? `${n} change(s)` : "up to date";
|
|
92
|
+
spinner.succeed(`${summary} (${parts.join(", ")}) [${times.join(" | ")}]`);
|
|
69
93
|
return state;
|
|
70
94
|
} catch (err) {
|
|
71
95
|
spinner.fail("Failed to load workspace state");
|
|
@@ -73,6 +97,15 @@ async function loadStateWithProgress(repo, opts) {
|
|
|
73
97
|
}
|
|
74
98
|
}
|
|
75
99
|
|
|
100
|
+
function assertInsideRoot(root, targetPath) {
|
|
101
|
+
const abs = path.resolve(root, targetPath);
|
|
102
|
+
const resolvedRoot = path.resolve(root);
|
|
103
|
+
if (!abs.startsWith(resolvedRoot + path.sep) && abs !== resolvedRoot) {
|
|
104
|
+
throw new Error(`Path traversal blocked: '${targetPath}' resolves outside workspace`);
|
|
105
|
+
}
|
|
106
|
+
return abs;
|
|
107
|
+
}
|
|
108
|
+
|
|
76
109
|
function matchesPattern(targetPath, pattern) {
|
|
77
110
|
if (targetPath === pattern) {
|
|
78
111
|
return true;
|
|
@@ -408,11 +441,16 @@ async function handleCommit(options, { repo: existingRepo, snapshotHint } = {})
|
|
|
408
441
|
}
|
|
409
442
|
}
|
|
410
443
|
|
|
444
|
+
const snapshotStart = Date.now();
|
|
411
445
|
const spinner = createSpinner("Saving snapshot...");
|
|
412
446
|
// snapshotHint lets callers (pull/push) pass pre-loaded state
|
|
413
447
|
// so saveSnapshot skips redundant API calls / fs scans.
|
|
414
448
|
await repo.saveSnapshot(message, snapshotHint);
|
|
415
|
-
|
|
449
|
+
const skipped = [];
|
|
450
|
+
if (snapshotHint?.remote) skipped.push("remote reused");
|
|
451
|
+
if (snapshotHint?.local) skipped.push("local reused");
|
|
452
|
+
const hint = skipped.length ? ` (${skipped.join(", ")})` : "";
|
|
453
|
+
spinner.succeed(`Snapshot saved in ${fmtMs(Date.now() - snapshotStart)}${hint}`);
|
|
416
454
|
}
|
|
417
455
|
|
|
418
456
|
function handleLog(options) {
|
|
@@ -436,10 +474,11 @@ async function handleFetch(options) {
|
|
|
436
474
|
const repo = await openRepo(options);
|
|
437
475
|
|
|
438
476
|
repo.invalidateRemoteCache();
|
|
477
|
+
const fetchStart = Date.now();
|
|
439
478
|
const spinner = createSpinner("Fetching remote file list...");
|
|
440
479
|
const remoteState = await repo.getRemoteState({ useCache: false });
|
|
441
480
|
const remote = remoteState.files;
|
|
442
|
-
spinner.succeed(`Found ${remote.length} file(s) on Drive`);
|
|
481
|
+
spinner.succeed(`Found ${remote.length} file(s) on Drive [${fmtMs(Date.now() - fetchStart)}]`);
|
|
443
482
|
|
|
444
483
|
const snapshot = repo.getSnapshot();
|
|
445
484
|
if (snapshot) {
|
|
@@ -753,7 +792,7 @@ async function handleRestore(paths, options) {
|
|
|
753
792
|
continue;
|
|
754
793
|
}
|
|
755
794
|
|
|
756
|
-
const localDest =
|
|
795
|
+
const localDest = assertInsideRoot(root, entry.localPath || entry.path);
|
|
757
796
|
const spinner = createSpinner(`Restoring ${targetPath}...`);
|
|
758
797
|
|
|
759
798
|
try {
|
|
@@ -777,7 +816,7 @@ async function handleRm(paths, options) {
|
|
|
777
816
|
const root = repo.root;
|
|
778
817
|
|
|
779
818
|
for (const targetPath of paths) {
|
|
780
|
-
const localAbs =
|
|
819
|
+
const localAbs = assertInsideRoot(root, targetPath);
|
|
781
820
|
if (fs.existsSync(localAbs)) {
|
|
782
821
|
await fs.promises.rm(localAbs, { recursive: true });
|
|
783
822
|
console.log(` Deleted locally: ${targetPath}`);
|
|
@@ -803,8 +842,8 @@ async function handleRm(paths, options) {
|
|
|
803
842
|
async function handleMv(source, dest, options) {
|
|
804
843
|
const root = requireRoot();
|
|
805
844
|
|
|
806
|
-
const srcAbs =
|
|
807
|
-
const destAbs =
|
|
845
|
+
const srcAbs = assertInsideRoot(root, source);
|
|
846
|
+
const destAbs = assertInsideRoot(root, dest);
|
|
808
847
|
|
|
809
848
|
if (!fs.existsSync(srcAbs)) {
|
|
810
849
|
console.log(`Source not found: ${source}`);
|
|
@@ -818,6 +857,77 @@ async function handleMv(source, dest, options) {
|
|
|
818
857
|
console.log(" Run 'aethel status' to see the resulting changes (old path deleted, new path added).");
|
|
819
858
|
}
|
|
820
859
|
|
|
860
|
+
async function handleVerify(options) {
|
|
861
|
+
const checkRemote = Boolean(options.remote);
|
|
862
|
+
const repo = checkRemote
|
|
863
|
+
? await openRepo(options)
|
|
864
|
+
: (() => { const root = requireRoot(); return new Repository(root); })();
|
|
865
|
+
|
|
866
|
+
const snapshot = repo.getSnapshot();
|
|
867
|
+
if (!snapshot) {
|
|
868
|
+
console.log("No snapshot to verify. Run 'aethel commit' first.");
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
const localCount = Object.keys(snapshot.localFiles || {}).filter(
|
|
873
|
+
(k) => !snapshot.localFiles[k].isFolder
|
|
874
|
+
).length;
|
|
875
|
+
const remoteCount = checkRemote ? Object.keys(snapshot.files || {}).length : 0;
|
|
876
|
+
const total = localCount + remoteCount;
|
|
877
|
+
|
|
878
|
+
const bar = createProgressBar("Verifying", total);
|
|
879
|
+
const result = await repo.verify({
|
|
880
|
+
checkRemote,
|
|
881
|
+
onProgress(done) { bar.update(done); },
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
// Snapshot integrity
|
|
885
|
+
if (result.snapshot.valid) {
|
|
886
|
+
bar.done(`Verification complete`);
|
|
887
|
+
console.log(`\n Snapshot: ✔ ${result.snapshot.reason}`);
|
|
888
|
+
} else {
|
|
889
|
+
bar.done(`Verification found issues`);
|
|
890
|
+
console.log(`\n Snapshot: ✖ ${result.snapshot.reason}`);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// Local issues
|
|
894
|
+
if (result.local.length) {
|
|
895
|
+
console.log(`\n Local issues (${result.local.length}):`);
|
|
896
|
+
for (const e of result.local) {
|
|
897
|
+
if (e.status === "missing") {
|
|
898
|
+
console.log(` ✖ ${e.path} — file missing`);
|
|
899
|
+
} else if (e.status === "modified") {
|
|
900
|
+
console.log(` ✖ ${e.path} — md5 mismatch (expected ${e.expected.slice(0, 8)}, got ${e.actual.slice(0, 8)})`);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
} else {
|
|
904
|
+
console.log(` Local files: ✔ ${localCount} file(s) verified`);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// Remote issues
|
|
908
|
+
if (checkRemote) {
|
|
909
|
+
if (result.remote.length) {
|
|
910
|
+
console.log(`\n Remote issues (${result.remote.length}):`);
|
|
911
|
+
for (const e of result.remote) {
|
|
912
|
+
if (e.status === "deleted_remote") {
|
|
913
|
+
console.log(` ✖ ${e.path} — deleted on Drive`);
|
|
914
|
+
} else if (e.status === "modified_remote") {
|
|
915
|
+
console.log(` ✖ ${e.path} — md5 mismatch (expected ${e.expected.slice(0, 8)}, got ${e.actual.slice(0, 8)})`);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
} else {
|
|
919
|
+
console.log(` Remote files: ✔ ${remoteCount} file(s) verified`);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
if (result.ok) {
|
|
924
|
+
console.log("\n✔ All integrity checks passed.");
|
|
925
|
+
} else {
|
|
926
|
+
console.log("\n✖ Integrity issues detected. Run 'aethel status' to review.");
|
|
927
|
+
process.exitCode = 1;
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
821
931
|
async function handleTui(options) {
|
|
822
932
|
const repo = await openRepo(options, { requireWorkspace: false, silent: true });
|
|
823
933
|
const cliArgs = [];
|
|
@@ -1050,6 +1160,13 @@ async function main() {
|
|
|
1050
1160
|
.argument("<dest>", "Destination path (relative to workspace)")
|
|
1051
1161
|
.action((source, dest, options) => handleMv(source, dest, options));
|
|
1052
1162
|
|
|
1163
|
+
addAuthOptions(
|
|
1164
|
+
program
|
|
1165
|
+
.command("verify")
|
|
1166
|
+
.description("Verify file integrity against last snapshot")
|
|
1167
|
+
.option("--remote", "Also verify remote files on Drive (requires network)")
|
|
1168
|
+
).action(handleVerify);
|
|
1169
|
+
|
|
1053
1170
|
addAuthOptions(
|
|
1054
1171
|
program
|
|
1055
1172
|
.command("tui")
|
package/src/core/auth.js
CHANGED
|
@@ -40,8 +40,9 @@ export async function persistCredentials(sourcePath) {
|
|
|
40
40
|
const resolved = path.resolve(sourcePath);
|
|
41
41
|
if (resolved === dest) return;
|
|
42
42
|
if (fsSyncFallback.existsSync(dest)) return;
|
|
43
|
-
await fs.mkdir(CONFIG_DIR, { recursive: true });
|
|
43
|
+
await fs.mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
44
44
|
await fs.copyFile(resolved, dest);
|
|
45
|
+
await fs.chmod(dest, 0o600);
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
export function resolveCredentialsPath(customPath) {
|
|
@@ -109,8 +110,8 @@ function createOAuthClient(config, redirectUri) {
|
|
|
109
110
|
}
|
|
110
111
|
|
|
111
112
|
async function persistToken(tokenPath, credentials) {
|
|
112
|
-
await fs.mkdir(path.dirname(path.resolve(tokenPath)), { recursive: true });
|
|
113
|
-
await fs.writeFile(tokenPath, JSON.stringify(credentials, null, 2) + "\n");
|
|
113
|
+
await fs.mkdir(path.dirname(path.resolve(tokenPath)), { recursive: true, mode: 0o700 });
|
|
114
|
+
await fs.writeFile(tokenPath, JSON.stringify(credentials, null, 2) + "\n", { mode: 0o600 });
|
|
114
115
|
}
|
|
115
116
|
|
|
116
117
|
function attachTokenPersistence(client, tokenPath) {
|
package/src/core/config.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* .aethel/ directory management, configuration, and state persistence.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import crypto from "node:crypto";
|
|
5
6
|
import fs from "node:fs";
|
|
6
7
|
import path from "node:path";
|
|
7
8
|
|
|
@@ -97,10 +98,28 @@ export function latestSnapshotPath(root) {
|
|
|
97
98
|
return path.join(dot(root), SNAPSHOTS_DIR, LATEST_SNAPSHOT);
|
|
98
99
|
}
|
|
99
100
|
|
|
100
|
-
export function readLatestSnapshot(root) {
|
|
101
|
+
export function readLatestSnapshot(root, { verify = false } = {}) {
|
|
101
102
|
const p = latestSnapshotPath(root);
|
|
102
103
|
if (!fs.existsSync(p)) return null;
|
|
103
|
-
|
|
104
|
+
const snapshot = JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
105
|
+
|
|
106
|
+
if (verify && snapshot._checksum) {
|
|
107
|
+
const canonical = JSON.stringify({
|
|
108
|
+
timestamp: snapshot.timestamp,
|
|
109
|
+
message: snapshot.message,
|
|
110
|
+
files: snapshot.files,
|
|
111
|
+
localFiles: snapshot.localFiles,
|
|
112
|
+
});
|
|
113
|
+
const actual = crypto.createHash("sha256").update(canonical).digest("hex");
|
|
114
|
+
if (actual !== snapshot._checksum) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
`Snapshot integrity check failed: checksum mismatch. ` +
|
|
117
|
+
`The snapshot file may have been tampered with.`
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return snapshot;
|
|
104
123
|
}
|
|
105
124
|
|
|
106
125
|
export function writeSnapshot(root, snapshot) {
|
package/src/core/drive-api.js
CHANGED
|
@@ -111,7 +111,12 @@ export function isWorkspaceType(mime) {
|
|
|
111
111
|
}
|
|
112
112
|
|
|
113
113
|
function escapeDriveQueryValue(value) {
|
|
114
|
-
|
|
114
|
+
// Google Drive API query strings use single-quoted values.
|
|
115
|
+
// Escape backslashes first, then single quotes.
|
|
116
|
+
return value
|
|
117
|
+
.replace(/\\/g, "\\\\")
|
|
118
|
+
.replace(/'/g, "\\'")
|
|
119
|
+
.replace(/"/g, '\\"');
|
|
115
120
|
}
|
|
116
121
|
|
|
117
122
|
export function iconForMime(mime) {
|
|
@@ -226,6 +231,15 @@ async function fetchAllItems(drive, { fields, includeSharedDrives = false } = {}
|
|
|
226
231
|
const files = [];
|
|
227
232
|
let pageToken = null;
|
|
228
233
|
|
|
234
|
+
// Fetch the real My Drive root folder metadata in parallel with
|
|
235
|
+
// the main list. Google Drive API returns actual folder IDs in
|
|
236
|
+
// `parents` (e.g. "0AJ…"), NOT the alias "root". Without this,
|
|
237
|
+
// the orphan checker can't recognise the root and marks every
|
|
238
|
+
// top-level folder as orphaned — silently dropping all files.
|
|
239
|
+
const rootPromise = drive.files
|
|
240
|
+
.get({ fileId: "root", fields: "id,name,mimeType,parents,createdTime" })
|
|
241
|
+
.catch(() => null);
|
|
242
|
+
|
|
229
243
|
const listOpts = {
|
|
230
244
|
q: "trashed = false",
|
|
231
245
|
fields: allFields,
|
|
@@ -252,6 +266,12 @@ async function fetchAllItems(drive, { fields, includeSharedDrives = false } = {}
|
|
|
252
266
|
pageToken = response.data.nextPageToken;
|
|
253
267
|
} while (pageToken);
|
|
254
268
|
|
|
269
|
+
// Add the real root folder so the orphan checker can walk up to it.
|
|
270
|
+
const rootRes = await rootPromise;
|
|
271
|
+
if (rootRes?.data?.id) {
|
|
272
|
+
folders.set(rootRes.data.id, rootRes.data);
|
|
273
|
+
}
|
|
274
|
+
|
|
255
275
|
return { folders, files };
|
|
256
276
|
}
|
|
257
277
|
|
|
@@ -489,14 +509,36 @@ export async function downloadFile(drive, fileMeta, localPath) {
|
|
|
489
509
|
{ responseType: "stream" }
|
|
490
510
|
);
|
|
491
511
|
await pipeline(response.data, fs.createWriteStream(targetPath));
|
|
512
|
+
// Exported files have no md5Checksum from Drive — skip verification
|
|
492
513
|
return;
|
|
493
514
|
}
|
|
494
515
|
|
|
516
|
+
// Stream to disk while computing MD5 in parallel
|
|
517
|
+
const { createHash } = await import("node:crypto");
|
|
518
|
+
const md5 = createHash("md5");
|
|
519
|
+
const writeStream = fs.createWriteStream(localPath);
|
|
495
520
|
const response = await drive.files.get(
|
|
496
521
|
{ fileId: fileMeta.id, alt: "media", supportsAllDrives: true },
|
|
497
522
|
{ responseType: "stream" }
|
|
498
523
|
);
|
|
499
|
-
|
|
524
|
+
|
|
525
|
+
// Tee: pipe to both disk and hasher
|
|
526
|
+
response.data.on("data", (chunk) => md5.update(chunk));
|
|
527
|
+
await pipeline(response.data, writeStream);
|
|
528
|
+
|
|
529
|
+
// Verify integrity if Drive provided an md5
|
|
530
|
+
const expectedMd5 = fileMeta.md5Checksum;
|
|
531
|
+
if (expectedMd5) {
|
|
532
|
+
const actualMd5 = md5.digest("hex");
|
|
533
|
+
if (actualMd5 !== expectedMd5) {
|
|
534
|
+
// Remove corrupt file
|
|
535
|
+
fs.unlinkSync(localPath);
|
|
536
|
+
throw new Error(
|
|
537
|
+
`Integrity check failed for ${fileMeta.name}: ` +
|
|
538
|
+
`expected md5 ${expectedMd5}, got ${actualMd5}`
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
500
542
|
}
|
|
501
543
|
|
|
502
544
|
export async function uploadFile(
|
package/src/core/repository.js
CHANGED
|
@@ -36,7 +36,13 @@ import {
|
|
|
36
36
|
readRemoteCache,
|
|
37
37
|
writeRemoteCache,
|
|
38
38
|
} from "./remote-cache.js";
|
|
39
|
-
import {
|
|
39
|
+
import {
|
|
40
|
+
buildSnapshot,
|
|
41
|
+
hashFile,
|
|
42
|
+
md5Local,
|
|
43
|
+
scanLocal,
|
|
44
|
+
verifySnapshotChecksum,
|
|
45
|
+
} from "./snapshot.js";
|
|
40
46
|
import {
|
|
41
47
|
stageChange,
|
|
42
48
|
stageChanges,
|
|
@@ -109,25 +115,48 @@ export class Repository {
|
|
|
109
115
|
* Load full workspace state in parallel, replacing the old
|
|
110
116
|
* loadWorkspaceState() helper from cli.js.
|
|
111
117
|
*/
|
|
112
|
-
async loadState({ useCache = true } = {}) {
|
|
118
|
+
async loadState({ useCache = true, onPhase } = {}) {
|
|
113
119
|
const config = this.getConfig();
|
|
120
|
+
const t0 = Date.now();
|
|
114
121
|
|
|
115
122
|
// Run all three in parallel — remote fetch is the slowest, overlap it
|
|
116
123
|
// with local scan and snapshot read.
|
|
124
|
+
const timings = {};
|
|
125
|
+
|
|
117
126
|
const [local, snapshot, remoteState] = await Promise.all([
|
|
118
|
-
scanLocal(this._root)
|
|
119
|
-
|
|
120
|
-
|
|
127
|
+
scanLocal(this._root).then((r) => {
|
|
128
|
+
timings.localMs = Date.now() - t0;
|
|
129
|
+
onPhase?.("local", timings.localMs);
|
|
130
|
+
return r;
|
|
131
|
+
}),
|
|
132
|
+
Promise.resolve(readLatestSnapshot(this._root)).then((r) => {
|
|
133
|
+
timings.snapshotMs = Date.now() - t0;
|
|
134
|
+
return r;
|
|
135
|
+
}),
|
|
136
|
+
this._loadRemoteState({ useCache }).then((r) => {
|
|
137
|
+
timings.remoteMs = Date.now() - t0;
|
|
138
|
+
timings.remoteCached = useCache && timings.remoteMs < 100;
|
|
139
|
+
onPhase?.("remote", timings.remoteMs);
|
|
140
|
+
return r;
|
|
141
|
+
}),
|
|
121
142
|
]);
|
|
122
143
|
const remote = remoteState.files;
|
|
123
144
|
|
|
145
|
+
const diffStart = Date.now();
|
|
146
|
+
const diff = computeDiff(snapshot, remote, local, { root: this._root });
|
|
147
|
+
timings.diffMs = Date.now() - diffStart;
|
|
148
|
+
timings.totalMs = Date.now() - t0;
|
|
149
|
+
timings.localFiles = Object.keys(local).length;
|
|
150
|
+
timings.remoteFiles = remote.length;
|
|
151
|
+
|
|
124
152
|
return {
|
|
125
153
|
config,
|
|
126
154
|
remote,
|
|
127
155
|
remoteState,
|
|
128
156
|
local,
|
|
129
157
|
snapshot,
|
|
130
|
-
diff
|
|
158
|
+
diff,
|
|
159
|
+
timings,
|
|
131
160
|
};
|
|
132
161
|
}
|
|
133
162
|
|
|
@@ -306,6 +335,87 @@ export class Repository {
|
|
|
306
335
|
return JSON.parse(fs.readFileSync(path.join(historyPath, match), "utf-8"));
|
|
307
336
|
}
|
|
308
337
|
|
|
338
|
+
// ── Integrity verification ──────────────────────────────────────────
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Full integrity verification of the workspace.
|
|
342
|
+
* Checks: snapshot checksum, local files vs snapshot md5, remote vs snapshot md5.
|
|
343
|
+
*
|
|
344
|
+
* @param {object} [options]
|
|
345
|
+
* @param {boolean} [options.checkRemote=false] Also verify remote checksums (requires connect)
|
|
346
|
+
* @param {function} [options.onProgress] (done, total, path, status) callback
|
|
347
|
+
* @returns {{ ok: boolean, snapshot: object, local: object[], remote: object[] }}
|
|
348
|
+
*/
|
|
349
|
+
async verify({ checkRemote = false, onProgress } = {}) {
|
|
350
|
+
const snapshot = readLatestSnapshot(this._root, { verify: true });
|
|
351
|
+
const result = { ok: true, snapshot: { valid: true }, local: [], remote: [] };
|
|
352
|
+
|
|
353
|
+
if (!snapshot) {
|
|
354
|
+
return { ok: true, snapshot: { valid: true, reason: "no snapshot yet" }, local: [], remote: [] };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// 1. Snapshot integrity
|
|
358
|
+
const snapshotCheck = verifySnapshotChecksum(snapshot);
|
|
359
|
+
result.snapshot = snapshotCheck;
|
|
360
|
+
if (!snapshotCheck.valid) result.ok = false;
|
|
361
|
+
|
|
362
|
+
// 2. Local file integrity vs snapshot
|
|
363
|
+
const localFiles = snapshot.localFiles || {};
|
|
364
|
+
const entries = Object.entries(localFiles).filter(([, meta]) => !meta.isFolder);
|
|
365
|
+
const total = entries.length + (checkRemote ? Object.keys(snapshot.files || {}).length : 0);
|
|
366
|
+
let done = 0;
|
|
367
|
+
|
|
368
|
+
for (const [relativePath, meta] of entries) {
|
|
369
|
+
const absPath = path.join(this._root, ...relativePath.split("/"));
|
|
370
|
+
const entry = { path: relativePath, status: "ok" };
|
|
371
|
+
|
|
372
|
+
if (!fs.existsSync(absPath)) {
|
|
373
|
+
entry.status = "missing";
|
|
374
|
+
result.ok = false;
|
|
375
|
+
} else if (meta.md5) {
|
|
376
|
+
const actual = await md5Local(absPath);
|
|
377
|
+
if (actual !== meta.md5) {
|
|
378
|
+
entry.status = "modified";
|
|
379
|
+
entry.expected = meta.md5;
|
|
380
|
+
entry.actual = actual;
|
|
381
|
+
result.ok = false;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (entry.status !== "ok") result.local.push(entry);
|
|
386
|
+
done++;
|
|
387
|
+
onProgress?.(done, total, relativePath, entry.status);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// 3. Remote integrity vs snapshot (optional, requires API call)
|
|
391
|
+
if (checkRemote) {
|
|
392
|
+
const remoteState = await this._loadRemoteState({ useCache: false });
|
|
393
|
+
const remoteById = new Map(remoteState.files.map((f) => [f.id, f]));
|
|
394
|
+
|
|
395
|
+
for (const [fileId, snapEntry] of Object.entries(snapshot.files || {})) {
|
|
396
|
+
if (snapEntry.isFolder) { done++; continue; }
|
|
397
|
+
const entry = { path: snapEntry.path || snapEntry.localPath, status: "ok" };
|
|
398
|
+
const remote = remoteById.get(fileId);
|
|
399
|
+
|
|
400
|
+
if (!remote) {
|
|
401
|
+
entry.status = "deleted_remote";
|
|
402
|
+
result.ok = false;
|
|
403
|
+
} else if (snapEntry.md5Checksum && remote.md5Checksum && snapEntry.md5Checksum !== remote.md5Checksum) {
|
|
404
|
+
entry.status = "modified_remote";
|
|
405
|
+
entry.expected = snapEntry.md5Checksum;
|
|
406
|
+
entry.actual = remote.md5Checksum;
|
|
407
|
+
result.ok = false;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (entry.status !== "ok") result.remote.push(entry);
|
|
411
|
+
done++;
|
|
412
|
+
onProgress?.(done, total, entry.path, entry.status);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return result;
|
|
417
|
+
}
|
|
418
|
+
|
|
309
419
|
// ── Private helpers ─────────────────────────────────────────────────
|
|
310
420
|
|
|
311
421
|
async _loadRemoteState({ useCache = true } = {}) {
|
package/src/core/snapshot.js
CHANGED
|
@@ -17,6 +17,48 @@ export async function md5Local(filePath) {
|
|
|
17
17
|
});
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Stream-hash a file with the given algorithm (default sha256).
|
|
22
|
+
* Returns hex digest.
|
|
23
|
+
*/
|
|
24
|
+
export async function hashFile(filePath, algorithm = "sha256") {
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
const hash = crypto.createHash(algorithm);
|
|
27
|
+
const stream = fs.createReadStream(filePath);
|
|
28
|
+
|
|
29
|
+
stream.on("data", (chunk) => hash.update(chunk));
|
|
30
|
+
stream.on("error", reject);
|
|
31
|
+
stream.on("end", () => resolve(hash.digest("hex")));
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Compute a SHA-256 integrity checksum over the snapshot's data fields.
|
|
37
|
+
* The checksum covers files + localFiles + message + timestamp, but NOT
|
|
38
|
+
* the checksum field itself, so it can be verified after reading.
|
|
39
|
+
*/
|
|
40
|
+
export function computeSnapshotChecksum(snapshot) {
|
|
41
|
+
const canonical = JSON.stringify({
|
|
42
|
+
timestamp: snapshot.timestamp,
|
|
43
|
+
message: snapshot.message,
|
|
44
|
+
files: snapshot.files,
|
|
45
|
+
localFiles: snapshot.localFiles,
|
|
46
|
+
});
|
|
47
|
+
return crypto.createHash("sha256").update(canonical).digest("hex");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Verify a snapshot's embedded checksum. Returns true if valid or
|
|
52
|
+
* if the snapshot has no checksum (pre-integrity snapshots).
|
|
53
|
+
*/
|
|
54
|
+
export function verifySnapshotChecksum(snapshot) {
|
|
55
|
+
if (!snapshot?._checksum) return { valid: true, reason: "no checksum (legacy snapshot)" };
|
|
56
|
+
const expected = snapshot._checksum;
|
|
57
|
+
const actual = computeSnapshotChecksum(snapshot);
|
|
58
|
+
if (actual === expected) return { valid: true, reason: "checksum valid" };
|
|
59
|
+
return { valid: false, reason: `checksum mismatch: expected ${expected.slice(0, 12)}…, got ${actual.slice(0, 12)}…` };
|
|
60
|
+
}
|
|
61
|
+
|
|
20
62
|
// ── Hash cache ───────────────────────────────────────────────────────
|
|
21
63
|
|
|
22
64
|
function hashCachePath(root) {
|
|
@@ -227,10 +269,14 @@ export function buildSnapshot(remoteFiles, localFiles, message = "") {
|
|
|
227
269
|
};
|
|
228
270
|
}
|
|
229
271
|
|
|
230
|
-
|
|
272
|
+
const snapshot = {
|
|
231
273
|
timestamp: new Date().toISOString(),
|
|
232
274
|
message,
|
|
233
275
|
files,
|
|
234
276
|
localFiles: { ...localFiles },
|
|
235
277
|
};
|
|
278
|
+
|
|
279
|
+
// Embed integrity checksum
|
|
280
|
+
snapshot._checksum = computeSnapshotChecksum(snapshot);
|
|
281
|
+
return snapshot;
|
|
236
282
|
}
|
package/src/core/sync.js
CHANGED
|
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { readConfig, readIndex, writeIndex } from "./config.js";
|
|
4
4
|
import { downloadFile, ensureFolder, trashFile, uploadFile } from "./drive-api.js";
|
|
5
|
+
import { md5Local } from "./snapshot.js";
|
|
5
6
|
|
|
6
7
|
function readPositiveIntEnv(name, fallback) {
|
|
7
8
|
const rawValue = Number.parseInt(process.env[name] || "", 10);
|
|
@@ -11,7 +12,12 @@ function readPositiveIntEnv(name, fallback) {
|
|
|
11
12
|
const CONCURRENCY = readPositiveIntEnv("AETHEL_DRIVE_CONCURRENCY", 10);
|
|
12
13
|
|
|
13
14
|
function toLocalAbsolutePath(root, relativePath) {
|
|
14
|
-
|
|
15
|
+
const abs = path.resolve(root, ...relativePath.split("/"));
|
|
16
|
+
const resolvedRoot = path.resolve(root);
|
|
17
|
+
if (!abs.startsWith(resolvedRoot + path.sep) && abs !== resolvedRoot) {
|
|
18
|
+
throw new Error(`Path traversal blocked: ${relativePath} resolves outside workspace`);
|
|
19
|
+
}
|
|
20
|
+
return abs;
|
|
15
21
|
}
|
|
16
22
|
|
|
17
23
|
export class CommitResult {
|
|
@@ -102,10 +108,22 @@ async function uploadStagedFile(drive, entry, root, driveFolderId) {
|
|
|
102
108
|
parentId = await ensureFolder(drive, parentPath, driveFolderId);
|
|
103
109
|
}
|
|
104
110
|
|
|
105
|
-
await uploadFile(drive, localAbsolutePath, remotePath, {
|
|
111
|
+
const uploadResult = await uploadFile(drive, localAbsolutePath, remotePath, {
|
|
106
112
|
parentId,
|
|
107
113
|
existingId: entry.fileId || null,
|
|
108
114
|
});
|
|
115
|
+
|
|
116
|
+
// Verify: Drive-returned md5 must match the local file we just uploaded.
|
|
117
|
+
// Google Workspace files (Docs, Sheets, etc.) don't have md5 — skip them.
|
|
118
|
+
if (uploadResult?.md5Checksum) {
|
|
119
|
+
const localMd5 = await md5Local(localAbsolutePath);
|
|
120
|
+
if (localMd5 !== uploadResult.md5Checksum) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`Upload integrity check failed for ${remotePath}: ` +
|
|
123
|
+
`local md5 ${localMd5}, Drive returned ${uploadResult.md5Checksum}`
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
109
127
|
}
|
|
110
128
|
|
|
111
129
|
async function deleteLocalFile(entry, root) {
|
|
@@ -194,6 +212,7 @@ export async function executeStaged(drive, root, progress) {
|
|
|
194
212
|
// Remote operations (download, upload, delete_remote) share a concurrency pool.
|
|
195
213
|
const localDeletes = [];
|
|
196
214
|
const remoteOps = [];
|
|
215
|
+
const failedPaths = new Set();
|
|
197
216
|
|
|
198
217
|
for (const [i, entry] of staged.entries()) {
|
|
199
218
|
if (entry.action === "delete_local") {
|
|
@@ -210,6 +229,7 @@ export async function executeStaged(drive, root, progress) {
|
|
|
210
229
|
await deleteLocalFile(entry, root);
|
|
211
230
|
result.deletedLocal++;
|
|
212
231
|
} catch (err) {
|
|
232
|
+
failedPaths.add(entry.path);
|
|
213
233
|
result.errors.push(`delete_local ${entry.path}: ${err.message}`);
|
|
214
234
|
}
|
|
215
235
|
})
|
|
@@ -242,13 +262,20 @@ export async function executeStaged(drive, root, progress) {
|
|
|
242
262
|
completed++;
|
|
243
263
|
const op = remoteOps[idx];
|
|
244
264
|
if (err) {
|
|
265
|
+
failedPaths.add(op.entry.path);
|
|
245
266
|
result.errors.push(`${op.entry.action} ${op.entry.path}: ${err.message}`);
|
|
246
267
|
}
|
|
247
268
|
progress?.(completed - 1, staged.length, op.entry.action, path.posix.basename(op.entry.path || ""));
|
|
248
269
|
});
|
|
249
270
|
|
|
250
271
|
progress?.(staged.length, staged.length, "done", "");
|
|
251
|
-
|
|
272
|
+
|
|
273
|
+
// Only clear succeeded entries — keep failed ones staged for retry
|
|
274
|
+
if (failedPaths.size > 0) {
|
|
275
|
+
index.staged = staged.filter((e) => failedPaths.has(e.path));
|
|
276
|
+
} else {
|
|
277
|
+
index.staged = [];
|
|
278
|
+
}
|
|
252
279
|
writeIndex(root, index);
|
|
253
280
|
|
|
254
281
|
return result;
|