@openparachute/vault 0.6.1 → 0.6.2-rc.1
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/README.md +6 -6
- package/package.json +1 -1
- package/src/cli.ts +90 -25
- package/src/init-summary.test.ts +125 -125
- package/src/init-summary.ts +89 -54
- package/src/init.test.ts +128 -0
- package/src/mirror-remote-guard.test.ts +269 -0
- package/src/mirror-remote-guard.ts +273 -0
- package/src/mirror-routes.test.ts +313 -0
- package/src/mirror-routes.ts +92 -6
- package/src/vault.test.ts +56 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-vault remote-clobber guard (vault#482).
|
|
3
|
+
*
|
|
4
|
+
* Per-vault mirror managers (vault#400) made every vault on a server
|
|
5
|
+
* independently linkable to a GitHub (or any git) remote. Nothing stopped
|
|
6
|
+
* TWO vaults on the same server from pointing their mirror at the SAME repo —
|
|
7
|
+
* and two mirrors pushing the same branch of the same repo fight each other:
|
|
8
|
+
* each `git push` (often `--force` for a mirror) overwrites the other vault's
|
|
9
|
+
* snapshot. The loser's backup is silently clobbered. Realistic on a
|
|
10
|
+
* family/shared box where two users share one GitHub account and both pick the
|
|
11
|
+
* obvious repo name, and more likely once the multi-user invite flow makes
|
|
12
|
+
* per-user vaults common.
|
|
13
|
+
*
|
|
14
|
+
* This module is the cheap same-server guard the issue calls for: when a vault
|
|
15
|
+
* is about to bind a remote (PAT save, OAuth repo pick, or import-then-sync),
|
|
16
|
+
* scan the OTHER vaults on this server for one that already claims the same
|
|
17
|
+
* normalized remote and refuse — naming the conflicting vault + the repo —
|
|
18
|
+
* unless the caller passes an explicit override.
|
|
19
|
+
*
|
|
20
|
+
* Cross-SERVER collisions can't be detected locally; the docs cover that half
|
|
21
|
+
* ("one repo per vault").
|
|
22
|
+
*
|
|
23
|
+
* ## What counts as a vault's "claimed remote"
|
|
24
|
+
*
|
|
25
|
+
* A vault's mirror remote lives in two places depending on the credential
|
|
26
|
+
* method:
|
|
27
|
+
* - **PAT** — the full authed `remote_url` in the per-vault credentials file.
|
|
28
|
+
* - **GitHub OAuth** — NOT in credentials (the credential carries no
|
|
29
|
+
* owner/repo). The actual remote is written onto the mirror dir's git
|
|
30
|
+
* `origin` by the select-repo flow.
|
|
31
|
+
* So we look at BOTH: the stored PAT credential, and the mirror dir's `origin`
|
|
32
|
+
* read straight from `.git/config`. Reading `.git/config` is a plain file read
|
|
33
|
+
* (no network, no git spawn) so the scan stays fast + hermetic.
|
|
34
|
+
*
|
|
35
|
+
* ## Normalization
|
|
36
|
+
*
|
|
37
|
+
* `https://github.com/x/y`, `https://github.com/x/y.git`,
|
|
38
|
+
* `git@github.com:x/y.git`, and `ssh://git@github.com/x/y` all name the same
|
|
39
|
+
* repo. We normalize to `<host-lowercased>/<owner>/<repo>` (userinfo + auth
|
|
40
|
+
* token stripped, `.git` + trailing slash removed) so equivalent URLs compare
|
|
41
|
+
* equal regardless of protocol/case/suffix.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
45
|
+
import { join } from "node:path";
|
|
46
|
+
import { homedir } from "node:os";
|
|
47
|
+
|
|
48
|
+
import { listVaults } from "./config.ts";
|
|
49
|
+
import { readCredentials } from "./mirror-credentials.ts";
|
|
50
|
+
import { readMirrorConfigForVault, resolveMirrorPath } from "./mirror-config.ts";
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Canonical identity for a git remote, used for "same repo?" comparison.
|
|
54
|
+
* Strips the auth token / userinfo, lower-cases the host, drops a trailing
|
|
55
|
+
* `.git` and trailing slashes. Returns `<host>/<path>` (e.g.
|
|
56
|
+
* `github.com/aaron/my-vault`).
|
|
57
|
+
*
|
|
58
|
+
* Recognizes the three remote shapes git accepts:
|
|
59
|
+
* - HTTPS/HTTP/ssh:// URLs (parse via `URL`).
|
|
60
|
+
* - SCP-style SSH shorthand `git@host:owner/repo(.git)`.
|
|
61
|
+
* - Anything else (local path, oddball) → a trimmed, suffix-stripped string
|
|
62
|
+
* compare, so two byte-identical local paths still match.
|
|
63
|
+
*
|
|
64
|
+
* Returns `null` for an empty/whitespace string (nothing to compare).
|
|
65
|
+
*/
|
|
66
|
+
export function normalizeRemoteIdentity(remote: string): string | null {
|
|
67
|
+
const trimmed = remote.trim();
|
|
68
|
+
if (trimmed.length === 0) return null;
|
|
69
|
+
|
|
70
|
+
// The whole identity is LOWER-CASED before returning. The host is
|
|
71
|
+
// case-insensitive by DNS, and GitHub (the primary mirror target) treats
|
|
72
|
+
// owner/repo case-insensitively too — `github.com/Aaron/Vault` and
|
|
73
|
+
// `github.com/aaron/vault` are the SAME repo, so a data-loss guard must treat
|
|
74
|
+
// them as equal (else two vaults with different-case configs still clobber).
|
|
75
|
+
// The theoretical false-positive on a path-case-SENSITIVE Git host is
|
|
76
|
+
// acceptable for a clobber guard — the operator can override.
|
|
77
|
+
|
|
78
|
+
// SCP-style SSH shorthand: `user@host:owner/repo.git`. Doesn't parse as a
|
|
79
|
+
// URL (no scheme), so detect + rewrite to the canonical `host/path` shape
|
|
80
|
+
// before the URL path. Pattern: `<user>@<host>:<path>` where the char after
|
|
81
|
+
// the colon isn't a slash (a `//` after colon would be a real URL).
|
|
82
|
+
const scp = trimmed.match(/^[A-Za-z0-9._-]+@([A-Za-z0-9.-]+):(.+)$/);
|
|
83
|
+
if (scp && !scp[2]!.startsWith("/")) {
|
|
84
|
+
const host = scp[1]!;
|
|
85
|
+
const path = stripRepoSuffix(scp[2]!);
|
|
86
|
+
return `${host}/${path}`.toLowerCase();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const url = new URL(trimmed);
|
|
91
|
+
// Host already excludes userinfo. `URL` keeps a leading slash on pathname —
|
|
92
|
+
// drop it so the join below doesn't double up.
|
|
93
|
+
const host = url.host;
|
|
94
|
+
const path = stripRepoSuffix(url.pathname.replace(/^\/+/, ""));
|
|
95
|
+
const identity = path.length === 0 ? host : `${host}/${path}`;
|
|
96
|
+
return identity.toLowerCase();
|
|
97
|
+
} catch {
|
|
98
|
+
// Non-URL, non-SCP (local path, etc.) — fall back to a trimmed,
|
|
99
|
+
// suffix-stripped string compare so identical local paths match.
|
|
100
|
+
return stripRepoSuffix(trimmed).toLowerCase();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Strip a trailing `.git` and any trailing slashes from a remote path. */
|
|
105
|
+
function stripRepoSuffix(path: string): string {
|
|
106
|
+
return path.replace(/\/+$/, "").replace(/\.git$/i, "").replace(/\/+$/, "");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Two remotes name the same repo iff their normalized identities match. */
|
|
110
|
+
export function sameRemoteIdentity(a: string, b: string): boolean {
|
|
111
|
+
const na = normalizeRemoteIdentity(a);
|
|
112
|
+
const nb = normalizeRemoteIdentity(b);
|
|
113
|
+
if (na === null || nb === null) return false;
|
|
114
|
+
return na === nb;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// Per-vault claimed-remote resolution
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
/** The vault home root — `<configDir>/vault`. Re-reads PARACHUTE_HOME per call. */
|
|
122
|
+
function vaultHomeRoot(): string {
|
|
123
|
+
const root = process.env.PARACHUTE_HOME ?? join(homedir(), ".parachute");
|
|
124
|
+
return join(root, "vault");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The mirror dir for a vault, derived from its persisted mirror config the
|
|
129
|
+
* same way `MirrorManager.start()` does (`resolveMirrorPath(vaultDataDir,
|
|
130
|
+
* config)`). Returns null when the vault has no enabled mirror config or the
|
|
131
|
+
* path can't be resolved (e.g. external location without external_path).
|
|
132
|
+
*
|
|
133
|
+
* Re-derives the vault data dir from PARACHUTE_HOME rather than importing
|
|
134
|
+
* `config.vaultDir` — keeping the dependency surface narrow + matching the
|
|
135
|
+
* path resolution `mirror-credentials.ts` uses.
|
|
136
|
+
*/
|
|
137
|
+
function vaultMirrorDir(vaultName: string): string | null {
|
|
138
|
+
const config = readMirrorConfigForVault(vaultName);
|
|
139
|
+
if (!config) return null;
|
|
140
|
+
const vaultDataDir = join(vaultHomeRoot(), "data", vaultName);
|
|
141
|
+
return resolveMirrorPath(vaultDataDir, config);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Read a vault's mirror dir `origin` URL straight from `.git/config` — no git
|
|
146
|
+
* spawn, no network. Returns null when there's no mirror dir, no `.git/config`,
|
|
147
|
+
* or no `[remote "origin"]` url line.
|
|
148
|
+
*
|
|
149
|
+
* `.git/config` is INI-ish; the origin url lives under a `[remote "origin"]`
|
|
150
|
+
* section header as `url = <value>`. We do a minimal section-aware scan rather
|
|
151
|
+
* than pull in a git-config parser — the file shape is stable + simple.
|
|
152
|
+
*/
|
|
153
|
+
function readOriginFromGitConfig(mirrorDir: string): string | null {
|
|
154
|
+
const gitConfigPath = join(mirrorDir, ".git", "config");
|
|
155
|
+
if (!existsSync(gitConfigPath)) return null;
|
|
156
|
+
let raw: string;
|
|
157
|
+
try {
|
|
158
|
+
raw = readFileSync(gitConfigPath, "utf8");
|
|
159
|
+
} catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
let inOrigin = false;
|
|
163
|
+
for (const line of raw.split("\n")) {
|
|
164
|
+
const section = line.match(/^\s*\[(.+?)\]\s*$/);
|
|
165
|
+
if (section) {
|
|
166
|
+
// Section headers: `[remote "origin"]` or `[core]` etc. Normalize the
|
|
167
|
+
// inner text + match the remote-origin shape (quoting can vary).
|
|
168
|
+
const header = section[1]!.trim().toLowerCase().replace(/\s+/g, " ");
|
|
169
|
+
inOrigin = header === 'remote "origin"';
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (!inOrigin) continue;
|
|
173
|
+
const url = line.match(/^\s*url\s*=\s*(.+?)\s*$/);
|
|
174
|
+
if (url) return url[1]!;
|
|
175
|
+
}
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The remote a vault currently claims, normalized, or null when it claims
|
|
181
|
+
* none. Looks at both credential-backed (PAT) and mirror-dir (`origin`)
|
|
182
|
+
* sources, so it covers OAuth-selected repos (which live only on `origin`)
|
|
183
|
+
* as well as PAT remotes.
|
|
184
|
+
*
|
|
185
|
+
* Returns the FIRST resolvable normalized remote it finds. `origin` is
|
|
186
|
+
* authoritative when set (it's what actually gets pushed); the stored PAT
|
|
187
|
+
* `remote_url` is the fallback for a vault whose mirror dir hasn't been
|
|
188
|
+
* bootstrapped yet.
|
|
189
|
+
*/
|
|
190
|
+
export function claimedRemoteOf(vaultName: string): string | null {
|
|
191
|
+
// 1. The live `origin` on the mirror dir — authoritative, covers OAuth.
|
|
192
|
+
const mirrorDir = vaultMirrorDir(vaultName);
|
|
193
|
+
if (mirrorDir) {
|
|
194
|
+
const origin = readOriginFromGitConfig(mirrorDir);
|
|
195
|
+
if (origin) {
|
|
196
|
+
const norm = normalizeRemoteIdentity(origin);
|
|
197
|
+
if (norm) return norm;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// 2. The stored PAT remote_url — fallback for a not-yet-bootstrapped mirror.
|
|
201
|
+
const creds = readCredentials(vaultName);
|
|
202
|
+
if (creds?.active_method === "pat" && creds.pat?.remote_url) {
|
|
203
|
+
const norm = normalizeRemoteIdentity(creds.pat.remote_url);
|
|
204
|
+
if (norm) return norm;
|
|
205
|
+
}
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** A detected cross-vault remote collision. */
|
|
210
|
+
export interface RemoteConflict {
|
|
211
|
+
/** The other vault on this server that already targets the same repo. */
|
|
212
|
+
conflictingVault: string;
|
|
213
|
+
/** The normalized repo identity both vaults point at (`host/owner/repo`). */
|
|
214
|
+
remoteIdentity: string;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Scan every OTHER vault on this server for one that already claims the same
|
|
219
|
+
* remote as `candidateRemote`. Returns the first conflict found, or null when
|
|
220
|
+
* the repo is unclaimed.
|
|
221
|
+
*
|
|
222
|
+
* The current vault is excluded — re-pointing a vault at its OWN existing
|
|
223
|
+
* remote (token rotation, re-running select-repo with the same repo, re-import)
|
|
224
|
+
* is legitimate + idempotent and must never trip the guard.
|
|
225
|
+
*
|
|
226
|
+
* Fail-open by design: any per-vault read error is swallowed (skip that vault)
|
|
227
|
+
* rather than blocking a legitimate bind on an unrelated vault's broken state.
|
|
228
|
+
* The guard's job is to catch the obvious double-target, not to be a hard
|
|
229
|
+
* gate that a corrupt sibling vault can wedge.
|
|
230
|
+
*/
|
|
231
|
+
export function findConflictingVault(
|
|
232
|
+
currentVaultName: string,
|
|
233
|
+
candidateRemote: string,
|
|
234
|
+
): RemoteConflict | null {
|
|
235
|
+
const target = normalizeRemoteIdentity(candidateRemote);
|
|
236
|
+
if (target === null) return null;
|
|
237
|
+
|
|
238
|
+
let vaults: string[];
|
|
239
|
+
try {
|
|
240
|
+
vaults = listVaults();
|
|
241
|
+
} catch {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
for (const other of vaults) {
|
|
246
|
+
if (other === currentVaultName) continue;
|
|
247
|
+
let claimed: string | null;
|
|
248
|
+
try {
|
|
249
|
+
claimed = claimedRemoteOf(other);
|
|
250
|
+
} catch {
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (claimed !== null && claimed === target) {
|
|
254
|
+
return { conflictingVault: other, remoteIdentity: target };
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Build the operator-facing error message for a refused bind. Names the
|
|
262
|
+
* conflicting vault + the repo, and tells the operator how to proceed.
|
|
263
|
+
* Centralized so the three call sites (PAT, select-repo, import-sync) surface
|
|
264
|
+
* identical wording.
|
|
265
|
+
*/
|
|
266
|
+
export function remoteConflictMessage(conflict: RemoteConflict): string {
|
|
267
|
+
return (
|
|
268
|
+
`Vault "${conflict.conflictingVault}" on this server already backs up to ${conflict.remoteIdentity}. ` +
|
|
269
|
+
`Two vaults pushing to the same repo overwrite each other's backups (silent data loss). ` +
|
|
270
|
+
`Pick a different repo for this vault, or disconnect the other vault's backup first. ` +
|
|
271
|
+
`If you're sure (e.g. you just moved the repo between vaults), pass override=true to proceed anyway.`
|
|
272
|
+
);
|
|
273
|
+
}
|
|
@@ -2395,6 +2395,108 @@ describe("handleMirrorImport — auto-enable sync (vault#416)", () => {
|
|
|
2395
2395
|
expect(creds?.pat?.remote_url).toContain("OTHER-repo.git");
|
|
2396
2396
|
});
|
|
2397
2397
|
|
|
2398
|
+
test("vault#482: import-sync to a repo a SIBLING vault already backs up → import succeeds but sync declined + conflict warning", async () => {
|
|
2399
|
+
home = tmp("import-sync-cross-vault-");
|
|
2400
|
+
await bootstrapVault(home); // creates vault "default"
|
|
2401
|
+
fixture = await buildExportFixture();
|
|
2402
|
+
manager = makeSyncManager(home);
|
|
2403
|
+
|
|
2404
|
+
// Sibling vault "family" already syncs to the repo we're importing.
|
|
2405
|
+
const sibDataDir = path.join(home, "vault", "data", "family");
|
|
2406
|
+
fs.mkdirSync(sibDataDir, { recursive: true });
|
|
2407
|
+
fs.writeFileSync(path.join(sibDataDir, "vault.yaml"), "name: family\n");
|
|
2408
|
+
writeMirrorConfigForVault("family", {
|
|
2409
|
+
...defaultMirrorConfig(),
|
|
2410
|
+
enabled: true,
|
|
2411
|
+
location: "internal",
|
|
2412
|
+
});
|
|
2413
|
+
const sibGitDir = path.join(sibDataDir, "mirror", ".git");
|
|
2414
|
+
fs.mkdirSync(sibGitDir, { recursive: true });
|
|
2415
|
+
fs.writeFileSync(
|
|
2416
|
+
path.join(sibGitDir, "config"),
|
|
2417
|
+
'[remote "origin"]\n\turl = https://github.com/aaron/my-vault.git\n',
|
|
2418
|
+
);
|
|
2419
|
+
|
|
2420
|
+
const req = new Request("http://x/import", {
|
|
2421
|
+
method: "POST",
|
|
2422
|
+
body: JSON.stringify({
|
|
2423
|
+
remote_url: "https://github.com/aaron/my-vault.git",
|
|
2424
|
+
mode: "merge",
|
|
2425
|
+
credentials: { kind: "pat", token: "ghp_import_token_abc" },
|
|
2426
|
+
enable_sync: true,
|
|
2427
|
+
}),
|
|
2428
|
+
});
|
|
2429
|
+
const res = await handleMirrorImport(
|
|
2430
|
+
req,
|
|
2431
|
+
"default",
|
|
2432
|
+
spawnCloneSuccess(fixture),
|
|
2433
|
+
undefined,
|
|
2434
|
+
manager,
|
|
2435
|
+
);
|
|
2436
|
+
expect(res.status).toBe(200);
|
|
2437
|
+
const body = (await res.json()) as {
|
|
2438
|
+
notes_imported: number;
|
|
2439
|
+
sync_enabled: boolean;
|
|
2440
|
+
sync_warning?: string;
|
|
2441
|
+
};
|
|
2442
|
+
// Import itself never fails on the conflict.
|
|
2443
|
+
expect(body.notes_imported).toBe(2);
|
|
2444
|
+
// ...but sync is declined + the warning names the sibling vault + repo.
|
|
2445
|
+
expect(body.sync_enabled).toBe(false);
|
|
2446
|
+
expect(body.sync_warning).toContain("family");
|
|
2447
|
+
expect(body.sync_warning).toContain("github.com/aaron/my-vault");
|
|
2448
|
+
|
|
2449
|
+
// No sync config / credential got persisted for "default".
|
|
2450
|
+
expect(readMirrorConfigForVault("default")).toBeUndefined();
|
|
2451
|
+
expect(readCredentials("default")).toBeNull();
|
|
2452
|
+
});
|
|
2453
|
+
|
|
2454
|
+
test("vault#482: import-sync with override=true binds anyway despite a sibling on the same repo", async () => {
|
|
2455
|
+
home = tmp("import-sync-cross-override-");
|
|
2456
|
+
await bootstrapVault(home);
|
|
2457
|
+
fixture = await buildExportFixture();
|
|
2458
|
+
manager = makeSyncManager(home);
|
|
2459
|
+
|
|
2460
|
+
const sibDataDir = path.join(home, "vault", "data", "family");
|
|
2461
|
+
fs.mkdirSync(sibDataDir, { recursive: true });
|
|
2462
|
+
fs.writeFileSync(path.join(sibDataDir, "vault.yaml"), "name: family\n");
|
|
2463
|
+
writeMirrorConfigForVault("family", {
|
|
2464
|
+
...defaultMirrorConfig(),
|
|
2465
|
+
enabled: true,
|
|
2466
|
+
location: "internal",
|
|
2467
|
+
});
|
|
2468
|
+
const sibGitDir = path.join(sibDataDir, "mirror", ".git");
|
|
2469
|
+
fs.mkdirSync(sibGitDir, { recursive: true });
|
|
2470
|
+
fs.writeFileSync(
|
|
2471
|
+
path.join(sibGitDir, "config"),
|
|
2472
|
+
'[remote "origin"]\n\turl = https://github.com/aaron/my-vault.git\n',
|
|
2473
|
+
);
|
|
2474
|
+
|
|
2475
|
+
const req = new Request("http://x/import", {
|
|
2476
|
+
method: "POST",
|
|
2477
|
+
body: JSON.stringify({
|
|
2478
|
+
remote_url: "https://github.com/aaron/my-vault.git",
|
|
2479
|
+
mode: "merge",
|
|
2480
|
+
credentials: { kind: "pat", token: "ghp_import_token_abc" },
|
|
2481
|
+
enable_sync: true,
|
|
2482
|
+
override: true,
|
|
2483
|
+
}),
|
|
2484
|
+
});
|
|
2485
|
+
const res = await handleMirrorImport(
|
|
2486
|
+
req,
|
|
2487
|
+
"default",
|
|
2488
|
+
spawnCloneSuccess(fixture),
|
|
2489
|
+
undefined,
|
|
2490
|
+
manager,
|
|
2491
|
+
);
|
|
2492
|
+
expect(res.status).toBe(200);
|
|
2493
|
+
const body = (await res.json()) as { sync_enabled: boolean };
|
|
2494
|
+
expect(body.sync_enabled).toBe(true);
|
|
2495
|
+
// Sync config + credential persisted for "default" despite the sibling.
|
|
2496
|
+
expect(readMirrorConfigForVault("default")?.auto_push).toBe(true);
|
|
2497
|
+
expect(readCredentials("default")?.pat?.token).toBe("ghp_import_token_abc");
|
|
2498
|
+
});
|
|
2499
|
+
|
|
2398
2500
|
test("existing mirror to the SAME remote → no-op success (sync_enabled true)", async () => {
|
|
2399
2501
|
home = tmp("import-sync-same-");
|
|
2400
2502
|
await bootstrapVault(home);
|
|
@@ -2555,3 +2657,214 @@ describe("handleMirrorImport — auto-enable sync (vault#416)", () => {
|
|
|
2555
2657
|
});
|
|
2556
2658
|
});
|
|
2557
2659
|
|
|
2660
|
+
// ---------------------------------------------------------------------------
|
|
2661
|
+
// vault#482 — cross-vault remote-clobber guard at the ROUTE level.
|
|
2662
|
+
//
|
|
2663
|
+
// Two vaults on one server pointing their mirror at the same repo force-push
|
|
2664
|
+
// over each other's backups (silent data loss). The guard refuses a bind
|
|
2665
|
+
// (PAT save / OAuth repo pick / import-then-sync) when a SIBLING vault already
|
|
2666
|
+
// claims the same normalized remote, unless `override: true`. Re-binding a
|
|
2667
|
+
// vault to its OWN remote is always allowed (no false positive).
|
|
2668
|
+
// ---------------------------------------------------------------------------
|
|
2669
|
+
|
|
2670
|
+
/**
|
|
2671
|
+
* Seed a sibling vault on disk under the active PARACHUTE_HOME so
|
|
2672
|
+
* `listVaults()` sees it, with an enabled internal mirror whose `origin`
|
|
2673
|
+
* points at `originUrl`. No git spawn — we write `.git/config` directly,
|
|
2674
|
+
* exactly what the guard reads.
|
|
2675
|
+
*/
|
|
2676
|
+
function seedSiblingVault(name: string, originUrl: string): void {
|
|
2677
|
+
const home = process.env.PARACHUTE_HOME!;
|
|
2678
|
+
const dataDir = path.join(home, "vault", "data", name);
|
|
2679
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
2680
|
+
fs.writeFileSync(path.join(dataDir, "vault.yaml"), `name: ${name}\n`);
|
|
2681
|
+
writeMirrorConfigForVault(name, {
|
|
2682
|
+
...defaultMirrorConfig(),
|
|
2683
|
+
enabled: true,
|
|
2684
|
+
location: "internal",
|
|
2685
|
+
});
|
|
2686
|
+
const mirrorGitDir = path.join(dataDir, "mirror", ".git");
|
|
2687
|
+
fs.mkdirSync(mirrorGitDir, { recursive: true });
|
|
2688
|
+
fs.writeFileSync(
|
|
2689
|
+
path.join(mirrorGitDir, "config"),
|
|
2690
|
+
`[remote "origin"]\n\turl = ${originUrl}\n`,
|
|
2691
|
+
);
|
|
2692
|
+
}
|
|
2693
|
+
|
|
2694
|
+
describe("cross-vault remote-clobber guard (vault#482)", () => {
|
|
2695
|
+
let home: string;
|
|
2696
|
+
afterEach(() => {
|
|
2697
|
+
if (home) fs.rmSync(home, { recursive: true, force: true });
|
|
2698
|
+
});
|
|
2699
|
+
|
|
2700
|
+
test("PAT: a second vault targeting a sibling's repo is refused with 409 + helpful error", async () => {
|
|
2701
|
+
home = tmp("guard-pat-refuse-");
|
|
2702
|
+
const { manager } = makeManager(home); // vault "default"
|
|
2703
|
+
// Sibling vault "family" already backs up to aaron/shared.
|
|
2704
|
+
seedSiblingVault("family", "https://github.com/aaron/shared.git");
|
|
2705
|
+
|
|
2706
|
+
const res = await handleAuthPat(
|
|
2707
|
+
new Request("http://x/pat", {
|
|
2708
|
+
method: "POST",
|
|
2709
|
+
body: JSON.stringify({
|
|
2710
|
+
token: "ghp_newtoken1234567890",
|
|
2711
|
+
remote_url: "https://github.com/aaron/shared.git",
|
|
2712
|
+
}),
|
|
2713
|
+
}),
|
|
2714
|
+
manager,
|
|
2715
|
+
// Probe stub — the conflict guard runs AFTER the probe, so the probe
|
|
2716
|
+
// must pass for us to reach (and assert on) the guard.
|
|
2717
|
+
async () => ({ ok: true }),
|
|
2718
|
+
);
|
|
2719
|
+
expect(res.status).toBe(409);
|
|
2720
|
+
const body = (await res.json()) as {
|
|
2721
|
+
error_type: string;
|
|
2722
|
+
conflicting_vault: string;
|
|
2723
|
+
remote: string;
|
|
2724
|
+
message: string;
|
|
2725
|
+
};
|
|
2726
|
+
expect(body.error_type).toBe("remote_conflict");
|
|
2727
|
+
expect(body.conflicting_vault).toBe("family");
|
|
2728
|
+
expect(body.remote).toBe("github.com/aaron/shared");
|
|
2729
|
+
// Names the vault + repo + tells the operator how to proceed.
|
|
2730
|
+
expect(body.message).toContain("family");
|
|
2731
|
+
expect(body.message).toContain("github.com/aaron/shared");
|
|
2732
|
+
expect(body.message).toContain("override");
|
|
2733
|
+
// Nothing got persisted for the refused vault.
|
|
2734
|
+
expect(readCredentials("default")).toBeNull();
|
|
2735
|
+
});
|
|
2736
|
+
|
|
2737
|
+
test("PAT: URL-normalization — sibling stored as scp-shorthand, candidate https → recognized as same repo", async () => {
|
|
2738
|
+
home = tmp("guard-pat-norm-");
|
|
2739
|
+
const { manager } = makeManager(home);
|
|
2740
|
+
// Sibling stored its origin as SSH scp-shorthand.
|
|
2741
|
+
seedSiblingVault("family", "git@github.com:aaron/shared.git");
|
|
2742
|
+
|
|
2743
|
+
const res = await handleAuthPat(
|
|
2744
|
+
new Request("http://x/pat", {
|
|
2745
|
+
method: "POST",
|
|
2746
|
+
body: JSON.stringify({
|
|
2747
|
+
token: "ghp_newtoken1234567890",
|
|
2748
|
+
// ...candidate arrives as HTTPS without .git.
|
|
2749
|
+
remote_url: "https://github.com/aaron/shared",
|
|
2750
|
+
}),
|
|
2751
|
+
}),
|
|
2752
|
+
manager,
|
|
2753
|
+
async () => ({ ok: true }),
|
|
2754
|
+
);
|
|
2755
|
+
expect(res.status).toBe(409);
|
|
2756
|
+
const body = (await res.json()) as { conflicting_vault: string };
|
|
2757
|
+
expect(body.conflicting_vault).toBe("family");
|
|
2758
|
+
});
|
|
2759
|
+
|
|
2760
|
+
test("PAT: re-pointing the SAME vault at its OWN remote is allowed (no false positive)", async () => {
|
|
2761
|
+
home = tmp("guard-pat-self-");
|
|
2762
|
+
const { manager } = makeManager(home); // vault "default"
|
|
2763
|
+
// "default" already targets aaron/mine (its own origin) + a sibling
|
|
2764
|
+
// targets a DIFFERENT repo — neither should block a self re-bind.
|
|
2765
|
+
seedSiblingVault("default", "https://github.com/aaron/mine.git");
|
|
2766
|
+
seedSiblingVault("family", "https://github.com/aaron/theirs.git");
|
|
2767
|
+
|
|
2768
|
+
const res = await handleAuthPat(
|
|
2769
|
+
new Request("http://x/pat", {
|
|
2770
|
+
method: "POST",
|
|
2771
|
+
body: JSON.stringify({
|
|
2772
|
+
token: "ghp_rotated1234567890",
|
|
2773
|
+
// Same repo "default" already points at — token rotation.
|
|
2774
|
+
remote_url: "https://github.com/aaron/mine.git",
|
|
2775
|
+
}),
|
|
2776
|
+
}),
|
|
2777
|
+
manager,
|
|
2778
|
+
async () => ({ ok: true }),
|
|
2779
|
+
);
|
|
2780
|
+
expect(res.status).toBe(200);
|
|
2781
|
+
const creds = readCredentials("default");
|
|
2782
|
+
expect(creds?.active_method).toBe("pat");
|
|
2783
|
+
expect(creds?.pat?.remote_url).toContain("github.com/aaron/mine.git");
|
|
2784
|
+
});
|
|
2785
|
+
|
|
2786
|
+
test("PAT: override=true bypasses the guard and saves anyway", async () => {
|
|
2787
|
+
home = tmp("guard-pat-override-");
|
|
2788
|
+
const { manager } = makeManager(home);
|
|
2789
|
+
seedSiblingVault("family", "https://github.com/aaron/shared.git");
|
|
2790
|
+
|
|
2791
|
+
const res = await handleAuthPat(
|
|
2792
|
+
new Request("http://x/pat", {
|
|
2793
|
+
method: "POST",
|
|
2794
|
+
body: JSON.stringify({
|
|
2795
|
+
token: "ghp_newtoken1234567890",
|
|
2796
|
+
remote_url: "https://github.com/aaron/shared.git",
|
|
2797
|
+
override: true,
|
|
2798
|
+
}),
|
|
2799
|
+
}),
|
|
2800
|
+
manager,
|
|
2801
|
+
async () => ({ ok: true }),
|
|
2802
|
+
);
|
|
2803
|
+
expect(res.status).toBe(200);
|
|
2804
|
+
expect(readCredentials("default")?.active_method).toBe("pat");
|
|
2805
|
+
});
|
|
2806
|
+
|
|
2807
|
+
test("select-repo: picking a sibling's repo is refused with 409", async () => {
|
|
2808
|
+
home = tmp("guard-selectrepo-refuse-");
|
|
2809
|
+
const { manager } = makeManager(home);
|
|
2810
|
+
seedSiblingVault("family", "https://github.com/aaron/shared.git");
|
|
2811
|
+
// "default" has an OAuth credential (select-repo requires one).
|
|
2812
|
+
writeCredentials("default", {
|
|
2813
|
+
active_method: "github_oauth",
|
|
2814
|
+
github_oauth: {
|
|
2815
|
+
access_token: "gho_fake1234567890abcd",
|
|
2816
|
+
scope: "repo",
|
|
2817
|
+
authorized_at: "2026-05-28T03:14:15.000Z",
|
|
2818
|
+
user_login: "aaron",
|
|
2819
|
+
user_id: 1,
|
|
2820
|
+
},
|
|
2821
|
+
pat: null,
|
|
2822
|
+
});
|
|
2823
|
+
|
|
2824
|
+
const res = await handleAuthGithubSelectRepo(
|
|
2825
|
+
new Request("http://x/select", {
|
|
2826
|
+
method: "POST",
|
|
2827
|
+
body: JSON.stringify({ owner: "aaron", name: "shared" }),
|
|
2828
|
+
}),
|
|
2829
|
+
manager,
|
|
2830
|
+
);
|
|
2831
|
+
expect(res.status).toBe(409);
|
|
2832
|
+
const body = (await res.json()) as {
|
|
2833
|
+
error_type: string;
|
|
2834
|
+
conflicting_vault: string;
|
|
2835
|
+
};
|
|
2836
|
+
expect(body.error_type).toBe("remote_conflict");
|
|
2837
|
+
expect(body.conflicting_vault).toBe("family");
|
|
2838
|
+
});
|
|
2839
|
+
|
|
2840
|
+
test("select-repo: override=true bypasses the guard", async () => {
|
|
2841
|
+
home = tmp("guard-selectrepo-override-");
|
|
2842
|
+
const { manager } = makeManager(home);
|
|
2843
|
+
seedSiblingVault("family", "https://github.com/aaron/shared.git");
|
|
2844
|
+
writeCredentials("default", {
|
|
2845
|
+
active_method: "github_oauth",
|
|
2846
|
+
github_oauth: {
|
|
2847
|
+
access_token: "gho_fake1234567890abcd",
|
|
2848
|
+
scope: "repo",
|
|
2849
|
+
authorized_at: "2026-05-28T03:14:15.000Z",
|
|
2850
|
+
user_login: "aaron",
|
|
2851
|
+
user_id: 1,
|
|
2852
|
+
},
|
|
2853
|
+
pat: null,
|
|
2854
|
+
});
|
|
2855
|
+
const res = await handleAuthGithubSelectRepo(
|
|
2856
|
+
new Request("http://x/select", {
|
|
2857
|
+
method: "POST",
|
|
2858
|
+
body: JSON.stringify({ owner: "aaron", name: "shared", override: true }),
|
|
2859
|
+
}),
|
|
2860
|
+
manager,
|
|
2861
|
+
);
|
|
2862
|
+
// 200 (or any non-409) — the guard didn't block it. (mirror_path is
|
|
2863
|
+
// unresolved since the manager never started, so it stores creds + returns
|
|
2864
|
+
// ok without applying to a remote.)
|
|
2865
|
+
expect(res.status).not.toBe(409);
|
|
2866
|
+
const body = (await res.json()) as { ok?: boolean };
|
|
2867
|
+
expect(body.ok).toBe(true);
|
|
2868
|
+
});
|
|
2869
|
+
});
|
|
2870
|
+
|