mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05ee57a05a | ||
|
|
f2b1b5d874 | ||
|
|
cded4c8a21 | ||
|
|
dd7aad7001 | ||
|
|
9d1e8e25b0 | ||
|
|
bb74147a52 | ||
|
|
0633d19039 | ||
|
|
e30d8c2288 | ||
|
|
8ae60205ad | ||
|
|
87157496c5 | ||
|
|
5bc1b60d01 | ||
|
|
8d64ea2032 | ||
|
|
d3a9b94c55 | ||
|
|
297192bb63 | ||
|
|
dd6edb3585 | ||
|
|
4e8f8a94e0 | ||
|
|
5bce7f5e1a | ||
|
|
3af4b61f51 | ||
|
|
e020a7dc9c | ||
|
|
b8ce5bd2ce | ||
|
|
51b283fbe3 | ||
|
|
a95251ff5c | ||
|
|
a51b71d3dd | ||
|
|
4d8a10a2e4 | ||
|
|
dfd24d11d7 | ||
|
|
dc825cc243 | ||
|
|
ec5080f6a8 |
No files matched your search
@@ -213,6 +213,10 @@ class PluginDatabase {
|
||||
});
|
||||
}
|
||||
this.#assertSchemaLayout();
|
||||
// Legacy-map backfill is deferred until PackageStore.recover() has finished
|
||||
// (hostService seeds after package initialize). Running here would promote
|
||||
// sole map candidates before recovered manifests are visible, then skip
|
||||
// re-evaluation because bindings already exist (Codex P2 on cded4c8a).
|
||||
}
|
||||
|
||||
#assertSchemaLayout() {
|
||||
@@ -328,6 +332,20 @@ class PluginDatabase {
|
||||
`).all().map((row) => this.#mapPlugin(row));
|
||||
}
|
||||
|
||||
/**
|
||||
* Every installed package version (active and inactive), with parsed manifests.
|
||||
* Used for ownership recovery when an older version still identifies a sync
|
||||
* provider that the active manifest no longer contributes.
|
||||
*/
|
||||
listInstalledVersions() {
|
||||
return this.db.prepare(`
|
||||
SELECT plugin_id, version, manifest_json, archive_sha256,
|
||||
package_relative_path, installed_at
|
||||
FROM plugin_versions
|
||||
ORDER BY plugin_id COLLATE BINARY, installed_at DESC, version COLLATE BINARY
|
||||
`).all().map((row) => this.#mapVersion(row));
|
||||
}
|
||||
|
||||
#mapVersion(row) {
|
||||
return {
|
||||
pluginId: row.plugin_id,
|
||||
@@ -825,6 +843,181 @@ class PluginDatabase {
|
||||
return Number(result?.changes) || 0;
|
||||
}
|
||||
|
||||
/** Distinct plugin ids that own secrets with key === prefix or key LIKE prefix:%. */
|
||||
listPluginIdsWithSecretKeyPrefix(prefix) {
|
||||
const escaped = String(prefix).replace(/[%_\\]/g, (ch) => `\\${ch}`);
|
||||
return this.db.prepare(`
|
||||
SELECT DISTINCT plugin_id
|
||||
FROM plugin_secrets
|
||||
WHERE key = ? OR key LIKE ? ESCAPE '\\'
|
||||
`).all(prefix, `${escaped}:%`).map((row) => row.plugin_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Promote leftover sync-provider-map:* secret rows into the host-owned binding
|
||||
* table. Idempotent.
|
||||
*
|
||||
* Host map keys under this prefix are migration markers from an intermediate
|
||||
* build (pluginId-namespaced provider ids only). After a successful promote,
|
||||
* those map rows are deleted so constructor-time backfill cannot resurrect a
|
||||
* binding after the user disconnects/unbinds. Multiple plugins may hold map
|
||||
* rows for the same provider (parent + nested id): group by provider, never
|
||||
* overwrite an existing host binding (including empty-plugin_id unbind
|
||||
* tombstones). Among candidates, only plugins that still hold sync-credential*
|
||||
* secrets are eligible. Auto-bind only when exactly one distinct
|
||||
* credential-backed candidate exists; if several credential-backed plugins
|
||||
* map the same provider (e.g. com.example and com.example.sync both hold
|
||||
* credentials), skip entirely — longest pluginId is not a reliable owner
|
||||
* signal (the shorter parent can be the legitimate owner). Map-only leftovers
|
||||
* never invent ownership (validation only requires
|
||||
* providerId.startsWith(pluginId + ".")).
|
||||
*/
|
||||
backfillSyncProviderBindingsFromLegacySecrets() {
|
||||
const legacyPrefix = "sync-provider-map:";
|
||||
const escaped = legacyPrefix.replace(/[%_\\]/g, (ch) => `\\${ch}`);
|
||||
const legacyRows = this.db.prepare(`
|
||||
SELECT plugin_id, key
|
||||
FROM plugin_secrets
|
||||
WHERE key LIKE ? ESCAPE '\\'
|
||||
`).all(`${escaped}%`);
|
||||
/** @type {Map<string, Array<{ pluginId: string, key: string }>>} */
|
||||
const byProvider = new Map();
|
||||
for (const row of legacyRows) {
|
||||
const pluginId = row.plugin_id;
|
||||
const key = row.key;
|
||||
if (typeof pluginId !== "string" || typeof key !== "string") continue;
|
||||
if (!key.startsWith(legacyPrefix)) continue;
|
||||
const providerId = key.slice(legacyPrefix.length);
|
||||
if (!providerId || providerId.includes("\0")) continue;
|
||||
if (!providerId.startsWith(`${pluginId}.`)) {
|
||||
continue;
|
||||
}
|
||||
const list = byProvider.get(providerId) || [];
|
||||
list.push({ pluginId, key });
|
||||
byProvider.set(providerId, list);
|
||||
}
|
||||
// Credential presence is the signal for real ownership. A longer nested map
|
||||
// row without credentials must not steal bind from a parent that still owns
|
||||
// sync-credential* secrets for the provider namespace.
|
||||
const credentialOwners = new Set(
|
||||
this.listPluginIdsWithSecretKeyPrefix("sync-credential")
|
||||
.filter((id) => typeof id === "string" && id.length > 0),
|
||||
);
|
||||
// Same single-provider guard as live backfill: one shared sync-credential*
|
||||
// row must not seed bindings for every historical map provider under that
|
||||
// plugin (disconnecting a stale provider would wipe the live credentials).
|
||||
// Count both legacy map rows and installed-manifest sync providers so a
|
||||
// stale map cannot promote when the live manifest also declares another
|
||||
// provider under the same credential owner (Codex P2 on 8ae60205).
|
||||
/** @type {Map<string, Set<string>>} */
|
||||
const providersByCredentialOwner = new Map();
|
||||
for (const [providerId, candidates] of byProvider) {
|
||||
for (const c of candidates) {
|
||||
if (!credentialOwners.has(c.pluginId)) continue;
|
||||
const set = providersByCredentialOwner.get(c.pluginId) || new Set();
|
||||
set.add(providerId);
|
||||
providersByCredentialOwner.set(c.pluginId, set);
|
||||
}
|
||||
}
|
||||
try {
|
||||
const versions = typeof this.listInstalledVersions === "function"
|
||||
? this.listInstalledVersions()
|
||||
: [];
|
||||
for (const version of versions) {
|
||||
const pluginId = version?.pluginId;
|
||||
if (typeof pluginId !== "string" || !credentialOwners.has(pluginId)) continue;
|
||||
for (const provider of version.manifest?.contributes?.providers ?? []) {
|
||||
if (provider?.kind !== "sync") continue;
|
||||
if (typeof provider.id !== "string" || provider.id.length < 1) continue;
|
||||
const set = providersByCredentialOwner.get(pluginId) || new Set();
|
||||
set.add(provider.id);
|
||||
providersByCredentialOwner.set(pluginId, set);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore catalog probe failures */
|
||||
}
|
||||
let promoted = 0;
|
||||
for (const [providerId, candidates] of byProvider) {
|
||||
// Any host row means a prior decision: active bind or explicit unbind
|
||||
// tombstone (empty plugin_id). Never resurrect from leftover map secrets.
|
||||
if (this.getSyncProviderBinding(providerId)) {
|
||||
continue;
|
||||
}
|
||||
// Only credential-backed map candidates may win; map-only leftovers stay
|
||||
// unbound rather than inventing ownership from namespace length alone.
|
||||
// Multiple distinct credential-backed candidates is ambiguous (parent vs
|
||||
// nested can both hold credentials for the same provider namespace) — skip
|
||||
// entirely rather than picking longest pluginId.
|
||||
const uniqueCredentialOwners = [
|
||||
...new Set(
|
||||
candidates
|
||||
.filter((c) => credentialOwners.has(c.pluginId))
|
||||
.map((c) => c.pluginId),
|
||||
),
|
||||
];
|
||||
if (uniqueCredentialOwners.length !== 1) {
|
||||
continue;
|
||||
}
|
||||
const chosenOwner = uniqueCredentialOwners[0];
|
||||
if ((providersByCredentialOwner.get(chosenOwner)?.size ?? 0) !== 1) {
|
||||
continue;
|
||||
}
|
||||
// Constructor-time map promote runs before hostService's installed-manifest
|
||||
// conflict check. Skip when another installed package would also claim this
|
||||
// provider (live nested plugin without credentials yet) so we do not bind
|
||||
// a legacy parent that later disconnect cannot clear (Codex P2 on 5bc1b60d).
|
||||
let hasManifestConflict = false;
|
||||
try {
|
||||
const versions = typeof this.listInstalledVersions === "function"
|
||||
? this.listInstalledVersions()
|
||||
: [];
|
||||
for (const version of versions) {
|
||||
const pluginId = version?.pluginId;
|
||||
if (typeof pluginId !== "string" || pluginId === chosenOwner) continue;
|
||||
for (const provider of version.manifest?.contributes?.providers ?? []) {
|
||||
if (provider?.kind !== "sync") continue;
|
||||
if (provider.id === providerId) {
|
||||
hasManifestConflict = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hasManifestConflict) break;
|
||||
}
|
||||
} catch {
|
||||
/* ignore catalog probe failures */
|
||||
}
|
||||
if (hasManifestConflict) {
|
||||
continue;
|
||||
}
|
||||
this.upsertSyncProviderBinding(providerId, chosenOwner);
|
||||
// Consume legacy map markers for this provider so a later unbind + reopen
|
||||
// cannot re-promote from leftover secrets. Only delete namespaced map keys
|
||||
// we accepted as candidates (not arbitrary plugin secrets). Consume all
|
||||
// candidate maps (including map-only losers) once a credential-backed
|
||||
// owner is bound, so orphans cannot re-run later.
|
||||
for (const candidate of candidates) {
|
||||
this.deleteSecret(candidate.pluginId, candidate.key);
|
||||
}
|
||||
promoted += 1;
|
||||
}
|
||||
return promoted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Credential-row prefix inference is intentionally not used.
|
||||
*
|
||||
* A shorter parent plugin id (e.g. com.example with sync-credential rows) can
|
||||
* namespace-prefix a provider owned by a different/removed plugin
|
||||
* (com.example.backup.sync). Falling back to that parent would let
|
||||
* syncDeleteSecrets wipe unrelated credentials. Ownership must come from the
|
||||
* host binding table (including rows promoted by
|
||||
* backfillSyncProviderBindingsFromLegacySecrets) or a live contribution.
|
||||
*/
|
||||
inferPluginIdForSyncProvider(_providerId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
recordSecurityAudit(pluginId, event, details) {
|
||||
let detailsJson = JSON.stringify(details ?? {});
|
||||
const originalBytes = Buffer.byteLength(detailsJson);
|
||||
|
||||
@@ -474,3 +474,362 @@ test("activating a new version resets runtime quarantine without forgiving the s
|
||||
assert.equal(database.getActivePlugin(first.id).runtime.quarantinedAt, 1_000);
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("schema upgrade backfills bindings from legacy sync-provider-map secrets", (context) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-v2-backfill-"));
|
||||
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const file = path.join(root, "plugins.sqlite");
|
||||
// Bootstrap a schema-2 DB then open with PluginDatabase to migrate to v3.
|
||||
const seed = new PluginDatabase(file);
|
||||
// Force downgrade-like state: empty bindings + a legacy map secret row.
|
||||
seed.db.exec("DELETE FROM plugin_sync_provider_bindings");
|
||||
const now = Date.now();
|
||||
seed.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
"com.example",
|
||||
"sync-provider-map:com.example.sync",
|
||||
"ref-legacy-map",
|
||||
Buffer.from("sealed"),
|
||||
now,
|
||||
now,
|
||||
);
|
||||
seed.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
"com.example",
|
||||
"sync-credential",
|
||||
"ref-cred",
|
||||
Buffer.from("sealed-cred"),
|
||||
now,
|
||||
now,
|
||||
);
|
||||
seed.close();
|
||||
|
||||
const database = new PluginDatabase(file);
|
||||
// Constructor no longer promotes; hostService seeds after package recovery.
|
||||
assert.equal(database.backfillSyncProviderBindingsFromLegacySecrets(), 1);
|
||||
assert.equal(database.getSyncProviderBinding("com.example.sync")?.pluginId, "com.example");
|
||||
// Consumed map marker so later unbind + reopen cannot re-promote.
|
||||
assert.equal(
|
||||
database.getSecretByKey("com.example", "sync-provider-map:com.example.sync"),
|
||||
null,
|
||||
"promoted map markers must be deleted",
|
||||
);
|
||||
assert.ok(database.getSecretByKey("com.example", "sync-credential"));
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("legacy map backfill does not overwrite an existing host binding", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const now = Date.now();
|
||||
// Correct owner already bound.
|
||||
database.upsertSyncProviderBinding("com.example.sync.foo", "com.example.sync");
|
||||
// Stale parent leftover map secret that would steal ownership if upserted.
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
"com.example",
|
||||
"sync-provider-map:com.example.sync.foo",
|
||||
"ref-stale-parent-map",
|
||||
Buffer.from("sealed"),
|
||||
now,
|
||||
now,
|
||||
);
|
||||
const promoted = database.backfillSyncProviderBindingsFromLegacySecrets();
|
||||
assert.equal(promoted, 0, "must not promote over an existing binding");
|
||||
assert.equal(
|
||||
database.getSyncProviderBinding("com.example.sync.foo")?.pluginId,
|
||||
"com.example.sync",
|
||||
"existing binding must be preserved",
|
||||
);
|
||||
// Skipped promote leaves non-winning map rows in place (only winners are consumed).
|
||||
assert.ok(
|
||||
database.getSecretByKey("com.example", "sync-provider-map:com.example.sync.foo"),
|
||||
"non-promoted leftover map rows stay until unbind consumes them",
|
||||
);
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("legacy map backfill skips when no candidate holds sync credentials", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const now = Date.now();
|
||||
// Map-only leftovers: longest namespace must not invent ownership without
|
||||
// sync-credential* evidence (parent may be the true owner later).
|
||||
for (const [pluginId, key] of [
|
||||
["com.example", "sync-provider-map:com.example.sync.foo"],
|
||||
["com.example.sync", "sync-provider-map:com.example.sync.foo"],
|
||||
]) {
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(pluginId, key, `ref-${pluginId}`, Buffer.from("sealed"), now, now);
|
||||
}
|
||||
const promoted = database.backfillSyncProviderBindingsFromLegacySecrets();
|
||||
assert.equal(promoted, 0, "map-only candidates must not bind without credentials");
|
||||
assert.equal(database.getSyncProviderBinding("com.example.sync.foo"), null);
|
||||
// Unbound maps stay so a later credential-backed promote or live put can resolve.
|
||||
assert.ok(database.getSecretByKey("com.example", "sync-provider-map:com.example.sync.foo"));
|
||||
assert.ok(database.getSecretByKey("com.example.sync", "sync-provider-map:com.example.sync.foo"));
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("legacy map backfill binds sole credential-backed owner when maps conflict", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const now = Date.now();
|
||||
// Parent has leftover map only; nested owner still holds credentials.
|
||||
for (const [pluginId, key, ciphertext] of [
|
||||
["com.example", "sync-provider-map:com.example.sync.foo", "map-parent"],
|
||||
["com.example.sync", "sync-provider-map:com.example.sync.foo", "map-nested"],
|
||||
["com.example.sync", "sync-credential", "real-secret"],
|
||||
]) {
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(pluginId, key, `ref-${pluginId}-${key}`, Buffer.from(ciphertext), now, now);
|
||||
}
|
||||
const promoted = database.backfillSyncProviderBindingsFromLegacySecrets();
|
||||
assert.equal(promoted, 1);
|
||||
assert.equal(
|
||||
database.getSyncProviderBinding("com.example.sync.foo")?.pluginId,
|
||||
"com.example.sync",
|
||||
"sole credential-backed owner must win over map-only parent",
|
||||
);
|
||||
// All candidate map markers for the promoted provider are consumed.
|
||||
assert.equal(database.getSecretByKey("com.example", "sync-provider-map:com.example.sync.foo"), null);
|
||||
assert.equal(database.getSecretByKey("com.example.sync", "sync-provider-map:com.example.sync.foo"), null);
|
||||
assert.ok(database.getSecretByKey("com.example.sync", "sync-credential"));
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("legacy map backfill does not let longest map override credential-backed parent", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const now = Date.now();
|
||||
// True owner is the shorter parent (still has credentials). Nested plugin has
|
||||
// only a stale map row - longest map alone must not steal the binding.
|
||||
for (const [pluginId, key, ciphertext] of [
|
||||
["com.example", "sync-provider-map:com.example.sync.foo", "map-parent"],
|
||||
["com.example", "sync-credential", "parent-secret"],
|
||||
["com.example.sync", "sync-provider-map:com.example.sync.foo", "map-nested"],
|
||||
]) {
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(pluginId, key, `ref-${pluginId}-${key}`, Buffer.from(ciphertext), now, now);
|
||||
}
|
||||
const promoted = database.backfillSyncProviderBindingsFromLegacySecrets();
|
||||
assert.equal(promoted, 1);
|
||||
assert.equal(
|
||||
database.getSyncProviderBinding("com.example.sync.foo")?.pluginId,
|
||||
"com.example",
|
||||
"credential-backed parent must win over longer map-only nested id",
|
||||
);
|
||||
assert.equal(database.getSecretByKey("com.example", "sync-provider-map:com.example.sync.foo"), null);
|
||||
assert.equal(database.getSecretByKey("com.example.sync", "sync-provider-map:com.example.sync.foo"), null);
|
||||
assert.ok(database.getSecretByKey("com.example", "sync-credential"));
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("legacy map backfill skips when multiple credential-backed candidates exist", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const now = Date.now();
|
||||
// Both parent and nested hold credentials + map for the same provider.
|
||||
// Longest pluginId is not a reliable owner signal — the shorter parent may
|
||||
// be the legitimate owner — so leave unbound rather than guessing.
|
||||
for (const [pluginId, key, ciphertext] of [
|
||||
["com.example", "sync-provider-map:com.example.sync.foo", "map-parent"],
|
||||
["com.example", "sync-credential", "parent-secret"],
|
||||
["com.example.sync", "sync-provider-map:com.example.sync.foo", "map-nested"],
|
||||
["com.example.sync", "sync-credential", "nested-secret"],
|
||||
]) {
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(pluginId, key, `ref-${pluginId}-${key}`, Buffer.from(ciphertext), now, now);
|
||||
}
|
||||
const promoted = database.backfillSyncProviderBindingsFromLegacySecrets();
|
||||
assert.equal(promoted, 0, "ambiguous credential-backed candidates must not auto-bind");
|
||||
assert.equal(database.getSyncProviderBinding("com.example.sync.foo"), null);
|
||||
// Maps stay so a live put / explicit bind can resolve ownership later.
|
||||
assert.ok(database.getSecretByKey("com.example", "sync-provider-map:com.example.sync.foo"));
|
||||
assert.ok(database.getSecretByKey("com.example.sync", "sync-provider-map:com.example.sync.foo"));
|
||||
assert.ok(database.getSecretByKey("com.example", "sync-credential"));
|
||||
assert.ok(database.getSecretByKey("com.example.sync", "sync-credential"));
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("legacy map backfill still consumes maps after unbind tombstone blocks promote", (context) => {
|
||||
// Verify prior fix: explicit unbind tombstone blocks re-promote; maps may remain
|
||||
// until unbind/delete paths consume them (backfill itself skips and does not
|
||||
// delete when promote is blocked).
|
||||
const database = createDatabase(context);
|
||||
const now = Date.now();
|
||||
database.upsertSyncProviderBinding("com.example.sync.foo", "");
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
"com.example",
|
||||
"sync-provider-map:com.example.sync.foo",
|
||||
"ref-tombstone-map",
|
||||
Buffer.from("sealed"),
|
||||
now,
|
||||
now,
|
||||
);
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
"com.example",
|
||||
"sync-credential",
|
||||
"ref-cred",
|
||||
Buffer.from("secret"),
|
||||
now,
|
||||
now,
|
||||
);
|
||||
assert.equal(database.backfillSyncProviderBindingsFromLegacySecrets(), 0);
|
||||
assert.equal(
|
||||
database.getSyncProviderBinding("com.example.sync.foo")?.pluginId,
|
||||
"",
|
||||
"unbind tombstone must block credential-backed map promote",
|
||||
);
|
||||
// Map row stays; disconnect/unbind consumption is responsible for cleanup.
|
||||
assert.ok(
|
||||
database.getSecretByKey("com.example", "sync-provider-map:com.example.sync.foo"),
|
||||
"skipped promote leaves map markers for unbind to consume",
|
||||
);
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("legacy map backfill deletes promoted map markers so unbind cannot resurrect", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const now = Date.now();
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
"com.example",
|
||||
"sync-provider-map:com.example.custom",
|
||||
"ref-plugin-owned",
|
||||
Buffer.from("plugin-owned-payload"),
|
||||
now,
|
||||
now,
|
||||
);
|
||||
// Credential evidence required to promote; non-map secrets must survive map consume.
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
"com.example",
|
||||
"sync-credential",
|
||||
"ref-cred-keep",
|
||||
Buffer.from("cred"),
|
||||
now,
|
||||
now,
|
||||
);
|
||||
assert.equal(database.backfillSyncProviderBindingsFromLegacySecrets(), 1);
|
||||
assert.equal(database.getSyncProviderBinding("com.example.custom")?.pluginId, "com.example");
|
||||
assert.equal(
|
||||
database.getSecretByKey("com.example", "sync-provider-map:com.example.custom"),
|
||||
null,
|
||||
"promoted map markers must be deleted to stop post-unbind resurrection",
|
||||
);
|
||||
assert.ok(database.getSecretByKey("com.example", "sync-credential"));
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("legacy map backfill does not resurrect after explicit unbind tombstone", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const now = Date.now();
|
||||
// Pre-seed a leftover map after an explicit empty-plugin_id unbind tombstone.
|
||||
database.upsertSyncProviderBinding("com.example.sync", "");
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
"com.example",
|
||||
"sync-provider-map:com.example.sync",
|
||||
"ref-leftover-map",
|
||||
Buffer.from("sealed"),
|
||||
now,
|
||||
now,
|
||||
);
|
||||
assert.equal(database.backfillSyncProviderBindingsFromLegacySecrets(), 0);
|
||||
assert.equal(
|
||||
database.getSyncProviderBinding("com.example.sync")?.pluginId,
|
||||
"",
|
||||
"empty-plugin_id unbind tombstone must block re-promotion",
|
||||
);
|
||||
database.close();
|
||||
});
|
||||
|
||||
|
||||
test("listInstalledVersions includes inactive package manifests", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const now = Date.now();
|
||||
// Install two versions under one plugin id via raw rows if helpers are heavy.
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugins(id, enabled, active_version, installed_at, updated_at)
|
||||
VALUES (?, 1, ?, ?, ?)
|
||||
`).run("com.example", "2.0.0", now, now);
|
||||
for (const [version, providerId] of [
|
||||
["1.0.0", "com.example.legacy-sync"],
|
||||
["2.0.0", "com.example.sync"],
|
||||
]) {
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugin_versions(
|
||||
plugin_id, version, manifest_json, archive_sha256, package_relative_path, installed_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
"com.example",
|
||||
version,
|
||||
JSON.stringify({
|
||||
id: "com.example",
|
||||
version,
|
||||
contributes: { providers: [{ id: providerId, kind: "sync", label: providerId }] },
|
||||
}),
|
||||
"a".repeat(64),
|
||||
`com.example/${version}/package`,
|
||||
now,
|
||||
);
|
||||
}
|
||||
const versions = database.listInstalledVersions();
|
||||
assert.equal(versions.length, 2);
|
||||
const providerIds = new Set();
|
||||
for (const v of versions) {
|
||||
for (const p of v.manifest?.contributes?.providers ?? []) {
|
||||
if (p.kind === "sync") providerIds.add(p.id);
|
||||
}
|
||||
}
|
||||
assert.ok(providerIds.has("com.example.legacy-sync"));
|
||||
assert.ok(providerIds.has("com.example.sync"));
|
||||
// Active list only has 2.0.0
|
||||
assert.equal(database.listPlugins()[0]?.activeVersion, "2.0.0");
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("inferPluginIdForSyncProvider never guesses from credential key prefixes", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const now = Date.now();
|
||||
for (const [pluginId, key] of [
|
||||
["com.example", "sync-credential"],
|
||||
["com.example.backup", "sync-credential"],
|
||||
["com.other", "sync-credential"],
|
||||
["com.example.sync", "sync-credential"],
|
||||
]) {
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(pluginId, key, `ref-${pluginId}`, Buffer.from("x"), now, now);
|
||||
}
|
||||
// Parent prefix alone is not enough: a removed plugin may have owned
|
||||
// com.example.backup.sync while only com.example still has credentials.
|
||||
assert.equal(database.inferPluginIdForSyncProvider("com.example.backup.sync"), undefined);
|
||||
assert.equal(database.inferPluginIdForSyncProvider("com.example.sync"), undefined);
|
||||
assert.equal(database.inferPluginIdForSyncProvider("com.other.cloud"), undefined);
|
||||
assert.equal(database.inferPluginIdForSyncProvider("com.missing.sync"), undefined);
|
||||
database.close();
|
||||
});
|
||||
@@ -46,6 +46,37 @@ function getElectronProcessMetrics(app, pid) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Full installed-manifest catalog of sync providers (active + inactive versions).
|
||||
* Shared by startup and enable-time backfill so ambiguity checks always see
|
||||
* cross-plugin / nested-namespace claims.
|
||||
* @param {{ listInstalledVersions?: () => Array<{ pluginId?: string, manifest?: { contributes?: { providers?: Array<{ kind?: string, id?: string }> } } }> }} database
|
||||
* @returns {Array<{ pluginId: string, provider: { id: string } }>}
|
||||
*/
|
||||
function collectInstalledSyncProviderCatalog(database) {
|
||||
const installedSyncProviders = [];
|
||||
const seen = new Set();
|
||||
const versions = typeof database.listInstalledVersions === "function"
|
||||
? database.listInstalledVersions()
|
||||
: [];
|
||||
for (const version of versions) {
|
||||
const pluginId = version?.pluginId;
|
||||
if (typeof pluginId !== "string" || !version.manifest) continue;
|
||||
for (const provider of version.manifest.contributes?.providers ?? []) {
|
||||
if (provider?.kind !== "sync") continue;
|
||||
if (typeof provider.id !== "string" || provider.id.length < 1) continue;
|
||||
const key = `${pluginId}\0${provider.id}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
installedSyncProviders.push({
|
||||
pluginId,
|
||||
provider: { id: provider.id },
|
||||
});
|
||||
}
|
||||
}
|
||||
return installedSyncProviders;
|
||||
}
|
||||
|
||||
function createPluginHostService(options) {
|
||||
const paths = createPluginPaths(options.app.getPath("userData"));
|
||||
const appRoot = options.appRoot ?? options.app.getAppPath();
|
||||
@@ -227,7 +258,47 @@ function createPluginHostService(options) {
|
||||
} catch {
|
||||
// Hydration is best-effort; enable must still succeed.
|
||||
}
|
||||
return originalOnPluginEnabled(pluginId);
|
||||
const enabled = await originalOnPluginEnabled(pluginId);
|
||||
try {
|
||||
// Use the same installed-manifest catalog as startup so cross-plugin /
|
||||
// inactive-version ambiguity checks still apply when only this plugin
|
||||
// was just enabled (Codex P2 on d3a9b94c).
|
||||
if (typeof secretStore.backfillSyncProviderBindingsFromLiveProviders === "function") {
|
||||
secretStore.backfillSyncProviderBindingsFromLiveProviders(
|
||||
collectInstalledSyncProviderCatalog(database),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort; enable must still succeed.
|
||||
}
|
||||
return enabled;
|
||||
};
|
||||
// Live-provider seed must wait until PackageStore.recover() has restored
|
||||
// missing plugin_versions. Construction-time seed can bind a stale owner
|
||||
// against an incomplete catalog; post-recovery seed then skips it as
|
||||
// existing (Codex P2 on f2b1b5d8). syncDeleteSecrets already awaits
|
||||
// resolveManager() → package initialize, so cleanup sees the post-recovery
|
||||
// seed without racing an early incomplete bind.
|
||||
const seedSyncBindings = () => {
|
||||
if (typeof secretStore.backfillSyncProviderBindingsFromLiveProviders !== "function") return;
|
||||
secretStore.backfillSyncProviderBindingsFromLiveProviders(
|
||||
collectInstalledSyncProviderCatalog(database),
|
||||
);
|
||||
};
|
||||
// PackageStore.recover() repopulates plugin_versions; only then promote
|
||||
// legacy maps and seed live bindings (Codex P2 on dd7aad70 / f2b1b5d8).
|
||||
const originalPackageInitialize = packageStore.initialize.bind(packageStore);
|
||||
packageStore.initialize = async () => {
|
||||
const result = await originalPackageInitialize();
|
||||
try {
|
||||
if (typeof database.backfillSyncProviderBindingsFromLegacySecrets === "function") {
|
||||
database.backfillSyncProviderBindingsFromLegacySecrets();
|
||||
}
|
||||
seedSyncBindings();
|
||||
} catch {
|
||||
// Best-effort
|
||||
}
|
||||
return result;
|
||||
};
|
||||
// Startup activation goes through contributionService.initialize() → #startPlugin
|
||||
// without onPluginEnabled. Hydrate every enabled plugin before that path runs.
|
||||
@@ -241,6 +312,16 @@ function createPluginHostService(options) {
|
||||
// Best-effort; startup must continue.
|
||||
}
|
||||
}
|
||||
// Re-seed after any package catalog changes during init.
|
||||
try {
|
||||
if (typeof secretStore.backfillSyncProviderBindingsFromLiveProviders === "function") {
|
||||
secretStore.backfillSyncProviderBindingsFromLiveProviders(
|
||||
collectInstalledSyncProviderCatalog(database),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort; startup must continue.
|
||||
}
|
||||
return originalInitialize();
|
||||
};
|
||||
const terminalDataPipelineService = options.electron.MessageChannelMain
|
||||
|
||||
@@ -1082,6 +1082,13 @@ function registerPluginBridge(ipcMain, options) {
|
||||
throw new Error("Plugin sync secret storage is unavailable");
|
||||
}
|
||||
if (!extensionProviderService) throw new Error("Plugin sync Providers are unavailable");
|
||||
// Wait for package recovery + binding seed before resolving ownership so a
|
||||
// schema-2 disconnect during startup cannot miss retained bindings (Codex P2).
|
||||
try {
|
||||
await resolveManager();
|
||||
} catch {
|
||||
// Runtime disabled: continue with whatever secretStore already has.
|
||||
}
|
||||
const providerId = payload?.providerId;
|
||||
if (typeof providerId !== "string" || providerId.length < 1) {
|
||||
throw new TypeError("Sync provider ID is invalid");
|
||||
|
||||
@@ -178,16 +178,16 @@ class PluginSecretStore {
|
||||
* Durable providerId → pluginId binding so disconnect can wipe sync secrets
|
||||
* after the contribution disappears (disabled/uninstalled plugin).
|
||||
* Stored in a host-owned table, not plugin-writable secrets.
|
||||
* Overwrites any prior empty-plugin_id unbind tombstone.
|
||||
*/
|
||||
bindSyncProviderPlugin(pluginId, providerId) {
|
||||
if (typeof pluginId !== "string" || pluginId.length < 1) {
|
||||
throw new TypeError("Plugin id is invalid");
|
||||
}
|
||||
assertSecretKey(providerId);
|
||||
if (
|
||||
providerId !== pluginId
|
||||
&& !providerId.startsWith(`${pluginId}.`)
|
||||
) {
|
||||
// Contribution provider ids live under the owning plugin namespace
|
||||
// (`pluginId.`…); they are never equal to the plugin id itself.
|
||||
if (!providerId.startsWith(`${pluginId}.`)) {
|
||||
throw new TypeError("Sync provider id is outside the plugin namespace");
|
||||
}
|
||||
if (typeof this.database.upsertSyncProviderBinding !== "function") {
|
||||
@@ -198,22 +198,135 @@ class PluginSecretStore {
|
||||
|
||||
resolveSyncProviderPlugin(providerId) {
|
||||
assertSecretKey(providerId);
|
||||
if (typeof this.database.getSyncProviderBinding !== "function") return undefined;
|
||||
const row = this.database.getSyncProviderBinding(providerId);
|
||||
const pluginId = row?.pluginId;
|
||||
if (typeof pluginId !== "string" || pluginId.length < 1) return undefined;
|
||||
if (providerId !== pluginId && !providerId.startsWith(`${pluginId}.`)) {
|
||||
return undefined;
|
||||
if (typeof this.database.getSyncProviderBinding === "function") {
|
||||
const row = this.database.getSyncProviderBinding(providerId);
|
||||
const pluginId = row?.pluginId;
|
||||
if (
|
||||
typeof pluginId === "string"
|
||||
&& pluginId.length > 0
|
||||
&& providerId.startsWith(`${pluginId}.`)
|
||||
) {
|
||||
return pluginId;
|
||||
}
|
||||
}
|
||||
return pluginId;
|
||||
// Do not infer ownership from sync-credential* key prefixes: a parent
|
||||
// plugin id may namespace-prefix another plugin's provider and would cause
|
||||
// disconnect to delete the wrong secrets. Pre-binding installs recover via
|
||||
// (1) backfillSyncProviderBindingsFromLegacySecrets for intermediate map
|
||||
// rows, (2) backfillSyncProviderBindingsFromLiveProviders using host
|
||||
// contribution metadata at startup, or (3) the next successful put.
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed missing provider→plugin bindings from contribution metadata.
|
||||
*
|
||||
* Real schema-2 installs only stored plugin-scoped `sync-credential*` rows
|
||||
* (no per-provider ownership). After upgrade, seed a binding only when a
|
||||
* plugin with credentials has exactly one sync provider id across the
|
||||
* catalog: binding every historical/active provider would let disconnecting
|
||||
* a stale provider wipe the shared credential prefix used by the live one.
|
||||
* Multi-provider plugins wait for the next successful put (or map backfill).
|
||||
*
|
||||
* @param {Array<{ pluginId?: string, id?: string, provider?: { id?: string } }>} providers
|
||||
* @returns {number} number of newly written bindings
|
||||
*/
|
||||
backfillSyncProviderBindingsFromLiveProviders(providers) {
|
||||
if (!Array.isArray(providers)) return 0;
|
||||
if (typeof this.database.listPluginIdsWithSecretKeyPrefix !== "function") return 0;
|
||||
const credentialOwners = new Set(
|
||||
this.database.listPluginIdsWithSecretKeyPrefix("sync-credential")
|
||||
.filter((id) => typeof id === "string" && id.length > 0),
|
||||
);
|
||||
if (credentialOwners.size === 0) return 0;
|
||||
/** @type {Map<string, Set<string>>} pluginId -> provider ids it declares */
|
||||
const providersByPlugin = new Map();
|
||||
/** @type {Map<string, Set<string>>} providerId -> plugins claiming it */
|
||||
const pluginsByProvider = new Map();
|
||||
for (const entry of providers) {
|
||||
const pluginId = entry?.pluginId;
|
||||
const providerId = entry?.provider?.id ?? entry?.id;
|
||||
if (typeof pluginId !== "string" || pluginId.length < 1) continue;
|
||||
if (typeof providerId !== "string" || providerId.length < 1) continue;
|
||||
if (!providerId.startsWith(`${pluginId}.`)) continue;
|
||||
// Count every manifest claim for conflict detection — including live
|
||||
// plugins that have not stored credentials yet. Binding only a
|
||||
// credential-backed legacy parent would orphan credentials when a later
|
||||
// disconnect prefers the live nested owner (Codex P2 on 8d64ea20).
|
||||
const claimants = pluginsByProvider.get(providerId) || new Set();
|
||||
claimants.add(pluginId);
|
||||
pluginsByProvider.set(providerId, claimants);
|
||||
// Only credential-backed plugins are eligible to become the binding owner.
|
||||
if (!credentialOwners.has(pluginId)) continue;
|
||||
const set = providersByPlugin.get(pluginId) || new Set();
|
||||
set.add(providerId);
|
||||
providersByPlugin.set(pluginId, set);
|
||||
}
|
||||
let promoted = 0;
|
||||
for (const [pluginId, providerIds] of providersByPlugin) {
|
||||
if (providerIds.size !== 1) continue;
|
||||
const providerId = [...providerIds][0];
|
||||
// Two credential-backed plugins can both declare the same provider via
|
||||
// nested namespaces (legacy disabled parent + live child). Binding the
|
||||
// first writer would make a later missing-provider disconnect delete the
|
||||
// wrong sync-credential* rows. Skip any cross-plugin claim conflict.
|
||||
const claimants = pluginsByProvider.get(providerId);
|
||||
if (!claimants || claimants.size !== 1) continue;
|
||||
// Skip active binds and explicit-unbind tombstones alike.
|
||||
if (typeof this.database.getSyncProviderBinding === "function"
|
||||
&& this.database.getSyncProviderBinding(providerId)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
this.bindSyncProviderPlugin(pluginId, providerId);
|
||||
promoted += 1;
|
||||
} catch {
|
||||
/* invalid contribution ids stay unbound */
|
||||
}
|
||||
}
|
||||
return promoted;
|
||||
}
|
||||
|
||||
unbindSyncProviderPlugin(pluginId, providerId) {
|
||||
assertSecretKey(providerId);
|
||||
if (typeof this.database.deleteSyncProviderBinding !== "function") return;
|
||||
const existing = this.database.getSyncProviderBinding(providerId);
|
||||
if (existing && existing.pluginId !== pluginId) return;
|
||||
this.database.deleteSyncProviderBinding(providerId);
|
||||
if (typeof this.database.upsertSyncProviderBinding !== "function") return;
|
||||
const existing = typeof this.database.getSyncProviderBinding === "function"
|
||||
? this.database.getSyncProviderBinding(providerId)
|
||||
: null;
|
||||
// Do not steal another plugin's active binding. Empty plugin_id is an
|
||||
// unbind tombstone and may be refreshed idempotently.
|
||||
if (
|
||||
existing
|
||||
&& typeof existing.pluginId === "string"
|
||||
&& existing.pluginId.length > 0
|
||||
&& existing.pluginId !== pluginId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// Consume leftover intermediate-build map markers so they cannot re-seed
|
||||
// a binding if the tombstone is ever cleared. Delete under this plugin and
|
||||
// any residual map rows for the same provider across plugins.
|
||||
const mapKey = `sync-provider-map:${providerId}`;
|
||||
if (typeof this.database.deleteSecret === "function") {
|
||||
try {
|
||||
this.database.deleteSecret(pluginId, mapKey);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
if (typeof this.database.findSecretsByKey === "function" && typeof this.database.deleteSecret === "function") {
|
||||
for (const row of this.database.findSecretsByKey(mapKey) || []) {
|
||||
if (typeof row?.pluginId !== "string" || typeof row?.key !== "string") continue;
|
||||
try {
|
||||
this.database.deleteSecret(row.pluginId, row.key);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
// Tombstone with empty plugin_id so leftover sync-provider-map:* secret
|
||||
// rows cannot re-promote this provider on the next database open.
|
||||
this.database.upsertSyncProviderBinding(providerId, "");
|
||||
}
|
||||
|
||||
resolve(pluginId, secret) {
|
||||
|
||||
@@ -378,6 +378,45 @@ test("sync provider bindings live outside plugin secrets and reject namespace mi
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.example.sync"), "com.example");
|
||||
store.unbindSyncProviderPlugin("com.example", "com.example.sync");
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.example.sync"), undefined);
|
||||
// Tombstone remains so legacy map backfill cannot resurrect after disconnect.
|
||||
assert.equal(database.getSyncProviderBinding("com.example.sync")?.pluginId, "");
|
||||
// Unbind also consumes map markers (including attacker leftovers).
|
||||
assert.equal(
|
||||
database.getSecretByKey("com.attacker", "sync-provider-map:com.example.sync"),
|
||||
null,
|
||||
);
|
||||
assert.equal(database.backfillSyncProviderBindingsFromLegacySecrets(), 0);
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.example.sync"), undefined);
|
||||
// Reconnect clears the tombstone.
|
||||
store.bindSyncProviderPlugin("com.example", "com.example.sync");
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.example.sync"), "com.example");
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("unbind deletes owner map markers and blocks legacy re-promotion", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const store = new PluginSecretStore({
|
||||
database,
|
||||
safeStorage: fakeSafeStorage(),
|
||||
});
|
||||
// Credential evidence is required for map promote; simulate a real owner plus
|
||||
// an intermediate-build map row that backfill would otherwise promote.
|
||||
store.set("com.example", "sync-credential", "secret");
|
||||
store.set("com.example", "sync-provider-map:com.example.sync", "com.example");
|
||||
assert.equal(database.backfillSyncProviderBindingsFromLegacySecrets(), 1);
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.example.sync"), "com.example");
|
||||
assert.equal(
|
||||
database.getSecretByKey("com.example", "sync-provider-map:com.example.sync"),
|
||||
null,
|
||||
"promote consumes the map marker",
|
||||
);
|
||||
// User disconnects; reintroduce a map row as if something wrote it back.
|
||||
// Tombstone still blocks re-promotion even with credentials + map present.
|
||||
store.unbindSyncProviderPlugin("com.example", "com.example.sync");
|
||||
store.set("com.example", "sync-provider-map:com.example.sync", "com.example");
|
||||
assert.equal(database.backfillSyncProviderBindingsFromLegacySecrets(), 0);
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.example.sync"), undefined);
|
||||
assert.equal(database.getSyncProviderBinding("com.example.sync")?.pluginId, "");
|
||||
database.close();
|
||||
});
|
||||
|
||||
@@ -443,3 +482,92 @@ test("existing overwrite stash is preserved across retry puts and cleared by pre
|
||||
assert.equal(store.getReference("com.example", "sync-credential"), undefined);
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("resolveSyncProviderPlugin does not infer ownership from credential prefixes alone", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const store = new PluginSecretStore({
|
||||
database,
|
||||
safeStorage: fakeSafeStorage(),
|
||||
});
|
||||
store.set("com.example", "sync-credential", "pw");
|
||||
assert.equal(database.getSyncProviderBinding("com.example.sync"), null);
|
||||
// Without a binding (or live-provider backfill), credentials alone are not enough.
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.example.sync"), undefined);
|
||||
// Explicit bind still works for disconnect cleanup.
|
||||
store.bindSyncProviderPlugin("com.example", "com.example.sync");
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.example.sync"), "com.example");
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("live provider backfill seeds bindings for v2 credential-only upgrades", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const store = new PluginSecretStore({
|
||||
database,
|
||||
safeStorage: fakeSafeStorage(),
|
||||
});
|
||||
// Real schema-2 path: credentials exist, binding table empty, no map rows.
|
||||
store.set("com.example", "sync-credential", "pw");
|
||||
store.set("com.disabled", "sync-credential", "pw2");
|
||||
assert.equal(database.getSyncProviderBinding("com.example.sync"), null);
|
||||
// Host should pass installed manifests including disabled plugins.
|
||||
const promoted = store.backfillSyncProviderBindingsFromLiveProviders([
|
||||
{ pluginId: "com.example", provider: { id: "com.example.sync" } },
|
||||
{ pluginId: "com.disabled", provider: { id: "com.disabled.sync" } },
|
||||
// No credentials under this plugin — skip.
|
||||
{ pluginId: "com.other", provider: { id: "com.other.cloud" } },
|
||||
// Wrong namespace alone would be multi with sync below — still skip evil.
|
||||
]);
|
||||
assert.equal(promoted, 2);
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.example.sync"), "com.example");
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.disabled.sync"), "com.disabled");
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.other.cloud"), undefined);
|
||||
// Idempotent when binding already exists.
|
||||
assert.equal(
|
||||
store.backfillSyncProviderBindingsFromLiveProviders([
|
||||
{ pluginId: "com.example", provider: { id: "com.example.sync" } },
|
||||
]),
|
||||
0,
|
||||
);
|
||||
// Multi-provider plugins must not all bind from one shared credential row.
|
||||
store.set("com.multi", "sync-credential", "shared");
|
||||
assert.equal(
|
||||
store.backfillSyncProviderBindingsFromLiveProviders([
|
||||
{ pluginId: "com.multi", provider: { id: "com.multi.old" } },
|
||||
{ pluginId: "com.multi", provider: { id: "com.multi.new" } },
|
||||
]),
|
||||
0,
|
||||
);
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.multi.old"), undefined);
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.multi.new"), undefined);
|
||||
// Cross-plugin claim on the same providerId must not bind the first writer.
|
||||
store.set("com.example.sync", "sync-credential", "nested");
|
||||
assert.equal(
|
||||
store.backfillSyncProviderBindingsFromLiveProviders([
|
||||
{ pluginId: "com.example", provider: { id: "com.example.sync.foo" } },
|
||||
{ pluginId: "com.example.sync", provider: { id: "com.example.sync.foo" } },
|
||||
]),
|
||||
0,
|
||||
);
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.example.sync.foo"), undefined);
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("live provider backfill does not resurrect explicit unbind tombstones", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const store = new PluginSecretStore({
|
||||
database,
|
||||
safeStorage: fakeSafeStorage(),
|
||||
});
|
||||
store.set("com.example", "sync-credential", "pw");
|
||||
store.bindSyncProviderPlugin("com.example", "com.example.sync");
|
||||
store.unbindSyncProviderPlugin("com.example", "com.example.sync");
|
||||
assert.equal(database.getSyncProviderBinding("com.example.sync")?.pluginId, "");
|
||||
assert.equal(
|
||||
store.backfillSyncProviderBindingsFromLiveProviders([
|
||||
{ pluginId: "com.example", provider: { id: "com.example.sync" } },
|
||||
]),
|
||||
0,
|
||||
);
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.example.sync"), undefined);
|
||||
database.close();
|
||||
});
|
||||
Reference in new issue
Block a user