fix: separate legacy recovery from versioned keyring state
Keep a fully validated v1 primary authoritative over differing legacy recovery snapshots, recover missing or unusable v1 primaries from the best valid legacy candidate, and migrate the next mutation to v2 generation one. Rebase prepared removals onto the current serialized keyring state so intervening creates survive, generations remain monotonic, and already absent targets complete idempotently. Restrict canonical same-generation conflict blocking to true v2 documents and add separate legacy and v2 regression coverage without exposing key material.
This commit is contained in:
@@ -239,6 +239,7 @@ function createOnlineBackupKeyring({
|
||||
]);
|
||||
return {
|
||||
source,
|
||||
version: source.document.version,
|
||||
generation: source.document.generation,
|
||||
payload: canonicalPayload(source.document),
|
||||
entries,
|
||||
@@ -261,6 +262,16 @@ function createOnlineBackupKeyring({
|
||||
return selectEquivalentState(matches);
|
||||
}
|
||||
|
||||
function selectLegacyState(states) {
|
||||
const primary = states.find(state => state.source.path === filePath);
|
||||
if (primary) return primary;
|
||||
return [...states].sort((left, right) =>
|
||||
right.source.modified - left.source.modified
|
||||
|| candidatePriority(left.source.path) - candidatePriority(right.source.path)
|
||||
|| left.source.path.localeCompare(right.source.path)
|
||||
)[0];
|
||||
}
|
||||
|
||||
async function readState() {
|
||||
const paths = [filePath, ...await recoveryCandidates()];
|
||||
const states = [];
|
||||
@@ -270,7 +281,13 @@ function createOnlineBackupKeyring({
|
||||
if (candidate.status === 'missing') continue;
|
||||
observedCandidate = true;
|
||||
if (candidate.status !== 'valid') continue;
|
||||
states.push(inspectSource({ ...candidate, recovered: candidatePath !== filePath }));
|
||||
let modified = 0;
|
||||
if (candidate.document.version === 1) {
|
||||
try {
|
||||
modified = (await fsImpl.stat(candidatePath)).mtimeMs;
|
||||
} catch {}
|
||||
}
|
||||
states.push(inspectSource({ ...candidate, modified, recovered: candidatePath !== filePath }));
|
||||
}
|
||||
if (states.length === 0) {
|
||||
if (observedCandidate) throw issueError(KEYRING_ERROR_CODES.structure);
|
||||
@@ -283,12 +300,24 @@ function createOnlineBackupKeyring({
|
||||
recovered: false
|
||||
});
|
||||
}
|
||||
const highestObservedGeneration = Math.max(...states.map(state => state.generation));
|
||||
selectGeneration(states, highestObservedGeneration);
|
||||
const validStates = states.filter(state => !firstBlockingIssue(state));
|
||||
if (validStates.length === 0) return selectGeneration(states, highestObservedGeneration);
|
||||
const highestValidGeneration = Math.max(...validStates.map(state => state.generation));
|
||||
return selectGeneration(validStates, highestValidGeneration);
|
||||
const v2States = states.filter(state => state.version === 2);
|
||||
if (v2States.length > 0) {
|
||||
const highestObservedGeneration = Math.max(...v2States.map(state => state.generation));
|
||||
selectGeneration(v2States, highestObservedGeneration);
|
||||
const validV2States = v2States.filter(state => !firstBlockingIssue(state));
|
||||
if (validV2States.length > 0) {
|
||||
const highestValidGeneration = Math.max(...validV2States.map(state => state.generation));
|
||||
return selectGeneration(validV2States, highestValidGeneration);
|
||||
}
|
||||
}
|
||||
const legacyStates = states.filter(state => state.version === 1);
|
||||
const validLegacyStates = legacyStates.filter(state => !firstBlockingIssue(state));
|
||||
if (validLegacyStates.length > 0) return selectLegacyState(validLegacyStates);
|
||||
if (v2States.length > 0) {
|
||||
const highestObservedGeneration = Math.max(...v2States.map(state => state.generation));
|
||||
return selectGeneration(v2States, highestObservedGeneration);
|
||||
}
|
||||
return selectLegacyState(legacyStates);
|
||||
}
|
||||
|
||||
function firstBlockingIssue(state) {
|
||||
@@ -468,18 +497,24 @@ function createOnlineBackupKeyring({
|
||||
const entry = state.entries.find(current => current.id === id);
|
||||
if (!entry) return null;
|
||||
const plan = Object.freeze({ id: entry.id, key: entry.key });
|
||||
removalPlans.set(plan, {
|
||||
generation: state.generation,
|
||||
entries: state.entries.filter(current => current.id !== id)
|
||||
});
|
||||
removalPlans.set(plan, true);
|
||||
return plan;
|
||||
}
|
||||
|
||||
function commitRemove(plan) {
|
||||
return serialize(async () => {
|
||||
const prepared = removalPlans.get(plan);
|
||||
if (!prepared) throw issueError(KEYRING_ERROR_CODES.plan);
|
||||
await writeEntries(prepared.entries, nextGeneration(prepared.generation));
|
||||
if (!removalPlans.has(plan)) throw issueError(KEYRING_ERROR_CODES.plan);
|
||||
const state = await readState();
|
||||
const blockingIssue = firstBlockingIssue(state);
|
||||
if (blockingIssue) throw issueError(blockingIssue);
|
||||
if (!state.entries.some(current => current.id === plan.id)) {
|
||||
removalPlans.delete(plan);
|
||||
return false;
|
||||
}
|
||||
await writeEntries(
|
||||
state.entries.filter(current => current.id !== plan.id),
|
||||
nextGeneration(state.generation)
|
||||
);
|
||||
removalPlans.delete(plan);
|
||||
return true;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user