mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-27 07:52:03 +00:00
feat(vault): port index0/atlas, and keep it current on every mutation
index0 is the per-vault activity ledger under `_system.index0`: an append-only event stream per writer, daily checkpoints rolling those events up, and derived canonical views (children_of, counts_by_kind, recent_activity). It was the last piece carried through verbatim rather than computed — which was safe for round-tripping but meant a TypeScript client never UPDATED it. A vault written by this port went stale the moment it was touched, and a Python client reading it afterwards saw an activity log missing everything the TS client had done. Worse, `nostr restore` kept the local block verbatim, so a restore silently discarded the remote's history. Hash parity is the entire contract here: event ids, integrity hashes, checkpoint summaries and view hashes are SHA-256 over Python's canonical JSON, and the merge picks winners by comparing those hashes — one byte of encoding disagreement means the two implementations permanently disagree about which record wins. Everything routes through the existing canonicalJson (Python's ensure_ascii escaping, code-point key ordering, CPython number formatting) rather than JSON.stringify, and the tests compare whole structures via canonical JSON rather than toEqual, so they pass only if the bytes that get hashed are identical. Verified against Python fixtures covering the append chain, compaction, manifest metadata, views and a two-replica merge — including non-ASCII labels and tags, a non-numeric entry id, two writers across two days, and a managed-account scope path. Two Python quirks are replicated deliberately rather than "fixed", because diverging would change which events exist rather than merely how they hash: `str(None)` is "None" (so a JSON null in a required field is a non-empty string there), and a stored integrity_hash is preserved even when it disagrees — this normalizes, it does not verify, and silently rewriting a mismatched hash would hide the tampering the hash exists to reveal. Events are emitted by DIFFING the index at the single mutation funnel each surface goes through, rather than from each call site as Python does. That is deliberate: a call-site emitter is something a new code path can forget, and a silently incomplete ledger is worse than an absent one because it reads as "nothing happened". The event vocabulary matches Python's, so a stream from either implementation reads the same. Emission is best-effort — the atlas is derived state, and it must never fail a committed vault write. mergeIndexPayloads now defaults to merging index0 with the same deterministic rules; "preserve-current" and "reject" remain for callers that want them, and `nostr restore` no longer discards the remote's history. Also made gen_high_risk deterministic. Fernet embeds a random IV and the current time, so that fixture regenerated differently every run and would have failed the fixture-drift CI job each time — it now pins both, with a note that production must never do this. Verified end to end: the TS binary performed four mutations, and Python read the resulting atlas, normalized it without dropping a record, and compacted it into checkpoints and views. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
This commit is contained in:
1 parent
64b27d7bf9
commit
c6cb85e840
15 files changed
+3396
-31
No files matched your search
@@ -39,6 +39,7 @@ import {
|
||||
removeLink,
|
||||
restoreEntry,
|
||||
isPartitionStub,
|
||||
emitEntryEvents,
|
||||
splitSecret,
|
||||
recoverSecret,
|
||||
buildSemanticRecords,
|
||||
@@ -183,7 +184,20 @@ async function mutate<T>(
|
||||
const mnemonic = requireUnlocked(ctx);
|
||||
const result = await withVaultLock(path, async () => {
|
||||
const vault = await openVault(path, mnemonic);
|
||||
// Snapshot before mutating so the index0 differ sees what changed.
|
||||
const before = structuredClone(vault.index.entries) as Record<string, unknown>;
|
||||
const out = await fn(vault.index);
|
||||
try {
|
||||
const updated = emitEntryEvents(vault.index, {
|
||||
before,
|
||||
after: vault.index.entries as unknown as Record<string, unknown>,
|
||||
fingerprintDir: profileDir(ctx),
|
||||
now: Math.floor(ctx.now() / 1000),
|
||||
});
|
||||
(vault.index as unknown as Record<string, unknown>)["_system"] = updated["_system"];
|
||||
} catch {
|
||||
// Derived state; never fail a committed write over it.
|
||||
}
|
||||
await saveVaultHoldingLock(vault);
|
||||
return out;
|
||||
});
|
||||
|
||||
@@ -136,6 +136,17 @@ export function capabilities(): Record<string, unknown> {
|
||||
// letting the job run under rules nobody reviewed it against.
|
||||
policy_binding: "sha256 of the canonical agent_policy.json at creation",
|
||||
},
|
||||
index0: {
|
||||
// The per-vault activity ledger under _system.index0: an append-only
|
||||
// event stream per writer, daily checkpoints, and derived views.
|
||||
events: "appended automatically on every vault mutation (CLI, TUI, API)",
|
||||
event_types: ["entry_created", "entry_updated", "entry_deleted"],
|
||||
canonical_views: ["children_of", "counts_by_kind", "recent_activity"],
|
||||
// Never synced: derived from data that does not leave the machine.
|
||||
local_only_views: ["conversation_index", "hot_nodes", "semantic_neighbors"],
|
||||
merge: "deterministic; events union by id, the rest resolve by timestamp then hash",
|
||||
interop: "hashes and merge results byte-identical to the Python implementation",
|
||||
},
|
||||
audit: {
|
||||
commands: ["agent audit-verify", "agent audit-tail"],
|
||||
chain: "HMAC-SHA256(prev_sig + canonical_payload), keyed by KEY_INDEX",
|
||||
@@ -150,9 +161,6 @@ export function capabilities(): Record<string, unknown> {
|
||||
// reproducible, so a TS implementation would derive a DIFFERENT key
|
||||
// from the same seed. Refusing beats silently diverging.
|
||||
"pgp RSA keys (ed25519 is at byte parity; RSA generation is not reproducible)",
|
||||
// Python-derived state, recomputed on load. Carried through verbatim by
|
||||
// the TS port so a Python profile round-trips, but not computed here.
|
||||
"index0/atlas (preserved verbatim on read/write, never recomputed)",
|
||||
// Deliberate: the TS interactive mode follows Python's legacy v1 menu
|
||||
// tree. See docs/tui_v2_cutover_decision.md.
|
||||
"Python's v2/v3 Textual TUIs (interactive mode follows the legacy v1 menus)",
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
splitSecret,
|
||||
recoverSecret,
|
||||
isPartitionStub,
|
||||
emitEntryEvents,
|
||||
partitionStub,
|
||||
type Entry,
|
||||
buildSemanticRecords,
|
||||
@@ -324,7 +325,11 @@ async function mutateVault<T>(
|
||||
const mnemonic = await resolveMnemonic(app, opts);
|
||||
return withVaultLock(path, async () => {
|
||||
const vault = await openVault(path, mnemonic);
|
||||
// Snapshot before mutating so the index0 differ can see what changed.
|
||||
// Structured-cloned, because `fn` mutates the index in place.
|
||||
const before = structuredClone(vault.index.entries) as Record<string, unknown>;
|
||||
const result = await fn(vault);
|
||||
recordIndex0(vault, before, dirname(path));
|
||||
await saveVaultHoldingLock(vault);
|
||||
// Rolling snapshot, after the write and still under the lock, matching
|
||||
// Python's create_backup() call order. Best-effort by design: a failed
|
||||
@@ -397,6 +402,31 @@ function resolveFromRows(
|
||||
throw new Error(`no entry matches "${refOrQuery}"`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Append index0 events for whatever a mutation changed.
|
||||
*
|
||||
* Best-effort: the atlas is derived state and can be rebuilt, so a failure
|
||||
* here must never fail a committed vault write. Silence in the ledger is a
|
||||
* lesser harm than refusing to save the user's entry.
|
||||
*/
|
||||
function recordIndex0(
|
||||
vault: OpenedVault,
|
||||
before: Record<string, unknown>,
|
||||
fingerprintDir: string,
|
||||
): void {
|
||||
try {
|
||||
const updated = emitEntryEvents(vault.index, {
|
||||
before,
|
||||
after: vault.index.entries as unknown as Record<string, unknown>,
|
||||
fingerprintDir,
|
||||
now: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
(vault.index as unknown as Record<string, unknown>)["_system"] = updated["_system"];
|
||||
} catch {
|
||||
// Deliberately swallowed; see the note above.
|
||||
}
|
||||
}
|
||||
|
||||
async function openReadAccess(opts: GlobalOpts): Promise<ReadAccess> {
|
||||
const token = process.env["SEEDPASS_TOKEN"];
|
||||
const app = new AppDir(resolveAppDir(opts.appDir));
|
||||
@@ -1429,13 +1459,13 @@ export function buildProgram(io: ProgramIo = defaultIo): Command {
|
||||
await decryptPayload(indexKey, parseEncryptedFile(delta).ciphertext),
|
||||
),
|
||||
);
|
||||
// preserve-current here too: the default would refuse any
|
||||
// Python profile that carries atlas state as soon as one delta
|
||||
// exists.
|
||||
// Same collector as the final merge: a delta can just as easily
|
||||
// carry an entry that displaces one the snapshot created.
|
||||
//
|
||||
// index0 now merges properly. It used to be preserve-current
|
||||
// because the atlas could not be recomputed here, which meant a
|
||||
// delta's activity history was discarded on the way in.
|
||||
remote = mergeIndexPayloads(remote, incoming, sha256Hex(delta).slice(0, 16), {
|
||||
index0: "preserve-current",
|
||||
report,
|
||||
});
|
||||
deltaCount++;
|
||||
@@ -1464,13 +1494,10 @@ export function buildProgram(io: ProgramIo = defaultIo): Command {
|
||||
final = remote;
|
||||
mode = local === null ? "restored" : "replaced";
|
||||
} else {
|
||||
// Keep the local _system.index0 verbatim: it is Python-derived
|
||||
// atlas state recomputed on load, and refusing to merge it would
|
||||
// block every restore into a Python-created profile.
|
||||
final = mergeIndexPayloads(local, remote, "nostr-restore", {
|
||||
index0: "preserve-current",
|
||||
report,
|
||||
});
|
||||
// index0 merges both sides now. It used to keep the local block
|
||||
// verbatim, because the atlas could not be merged here — which
|
||||
// meant a restore silently discarded the remote's activity history.
|
||||
final = mergeIndexPayloads(local, remote, "nostr-restore", { report });
|
||||
mode = "merged";
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ import { atomicWrite, openVault, saveVaultHoldingLock, withVaultLock, type Opene
|
||||
import { entryMetadata, refFor } from "../refs.js";
|
||||
import { materializeSecret, type MaterializedSecret } from "../secrets.js";
|
||||
import { clipboardSink } from "../sinks.js";
|
||||
import { encodeQr, renderQrText } from "@seedpass/core";
|
||||
import { encodeQr, renderQrText, emitEntryEvents } from "@seedpass/core";
|
||||
import { createIndexBackup, type BackupResult } from "../backups.js";
|
||||
import {
|
||||
loadConfig,
|
||||
@@ -168,7 +168,20 @@ async function mutate(s: Session, fn: (vault: OpenedVault) => void): Promise<voi
|
||||
let backup: BackupResult | undefined;
|
||||
await withVaultLock(path, async () => {
|
||||
const fresh = await openVault(path, mnemonic);
|
||||
// Snapshot before mutating so the index0 differ sees what changed.
|
||||
const before = structuredClone(fresh.index.entries) as Record<string, unknown>;
|
||||
fn(fresh);
|
||||
try {
|
||||
const updated = emitEntryEvents(fresh.index, {
|
||||
before,
|
||||
after: fresh.index.entries as unknown as Record<string, unknown>,
|
||||
fingerprintDir: s.app.profileDir(s.fingerprint),
|
||||
now: Math.floor(s.clock() / 1000),
|
||||
});
|
||||
(fresh.index as unknown as Record<string, unknown>)["_system"] = updated["_system"];
|
||||
} catch {
|
||||
// Derived state; never fail a committed write over it.
|
||||
}
|
||||
await saveVaultHoldingLock(fresh);
|
||||
s.vault = fresh;
|
||||
try {
|
||||
|
||||
@@ -112,6 +112,42 @@ export {
|
||||
MAX_TOTAL_SHARES,
|
||||
type ParsedShare,
|
||||
} from "./vault/recoverySplit.js";
|
||||
export {
|
||||
normalizeIndex0,
|
||||
normalizeIndex0Event,
|
||||
normalizeIndex0Checkpoint,
|
||||
normalizeCanonicalView,
|
||||
normalizeHead,
|
||||
normalizeViewManifest,
|
||||
ensureIndex0Payload,
|
||||
deriveIndex0Context,
|
||||
makeIndex0Event,
|
||||
appendIndex0Event,
|
||||
buildDailyCheckpoint,
|
||||
rebuildIndex0Checkpoints,
|
||||
compactIndex0,
|
||||
compactIndex0Payload,
|
||||
rebuildCanonicalViewsPayload,
|
||||
buildManifestIndex0Metadata,
|
||||
listCanonicalViews,
|
||||
getCanonicalView,
|
||||
mergeSystemIndex0,
|
||||
recomputeIndex0Stats,
|
||||
// Aliased: `computeEventId` is already taken by the Nostr event module,
|
||||
// and the two hash entirely different things.
|
||||
computeEventHash as computeIndex0EventHash,
|
||||
computeEventId as computeIndex0EventId,
|
||||
computeHeadHash as computeIndex0HeadHash,
|
||||
computeCheckpointHash as computeIndex0CheckpointHash,
|
||||
computeViewHash as computeIndex0ViewHash,
|
||||
INDEX0_SCHEMA_VERSION,
|
||||
INDEX0_CANONICAL_VIEW_TYPES,
|
||||
LOCAL_ONLY_VIEW_TYPES,
|
||||
INDEX0_MAX_CHECKPOINTS_PER_WRITER,
|
||||
type Index0,
|
||||
type Index0Context,
|
||||
} from "./vault/index0.js";
|
||||
export { emitEntryEvents, type EmitOptions } from "./vault/index0Events.js";
|
||||
export { encodeQr, renderQrText, EC_LEVELS, type QrMatrix, type EcLevel } from "./util/qr.js";
|
||||
export {
|
||||
buildSemanticRecords,
|
||||
|
||||
@@ -94,7 +94,7 @@ function pythonFloatRepr(n: number): string {
|
||||
}
|
||||
|
||||
/** Code-point ordering, matching Python's sorted() on str. */
|
||||
function compareCodePoints(a: string, b: string): number {
|
||||
export function compareCodePoints(a: string, b: string): number {
|
||||
const ai = Array.from(a);
|
||||
const bi = Array.from(b);
|
||||
const len = Math.min(ai.length, bi.length);
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
*/
|
||||
|
||||
import { canonicalHash, canonicalJson } from "./canonical.js";
|
||||
import { mergeSystemIndex0 } from "../vault/index0.js";
|
||||
|
||||
export const TOMBSTONE_RETENTION_CAP = 2048;
|
||||
export const MERGE_STRATEGY = "modified_ts_hash_tombstone_v2";
|
||||
@@ -264,10 +265,12 @@ function assertIndex0Portable(raw: unknown): void {
|
||||
function ensureIndex0Payload(data: unknown, options: MergeOptions = {}): Dict {
|
||||
const out: Dict = isDict(data) ? { ...data } : {};
|
||||
const system: Dict = isDict(out["_system"]) ? { ...(out["_system"] as Dict) } : {};
|
||||
if (options.index0 !== "preserve-current") {
|
||||
if (options.index0 === "reject") {
|
||||
assertIndex0Portable(system["index0"]);
|
||||
system["index0"] = emptyIndex0();
|
||||
}
|
||||
// "merge" and "preserve-current" both keep the block; which side wins is
|
||||
// decided in mergeIndexPayloads, where both are in hand.
|
||||
out["_system"] = system;
|
||||
return out;
|
||||
}
|
||||
@@ -332,15 +335,20 @@ export interface MergeOptions {
|
||||
report?: MergeReport;
|
||||
|
||||
/**
|
||||
* What to do with `_system.index0`, Python's derived atlas state.
|
||||
* What to do with `_system.index0`, the derived atlas state.
|
||||
*
|
||||
* Default ("reject") refuses to merge populated index0 rather than drop
|
||||
* it silently. "preserve-current" keeps the current side's block verbatim
|
||||
* and ignores the incoming one — correct when merging remote state into a
|
||||
* live profile, since Python recomputes canonical views from entries on
|
||||
* load and would otherwise be blocked entirely.
|
||||
* Default ("merge") merges both sides with `mergeSystemIndex0`, the same
|
||||
* deterministic rules Python uses: events union by id, checkpoints, views
|
||||
* and heads resolve by timestamp then by their own hash. That is now the
|
||||
* right default because index0 is ported; before it was, the only safe
|
||||
* choices were to refuse or to keep one side.
|
||||
*
|
||||
* "preserve-current" keeps the current side's block verbatim and ignores
|
||||
* the incoming one. "reject" refuses to merge a populated index0 at all,
|
||||
* rather than dropping it silently — kept for callers that would rather
|
||||
* fail than have derived state change under them.
|
||||
*/
|
||||
index0?: "reject" | "preserve-current";
|
||||
index0?: "merge" | "reject" | "preserve-current";
|
||||
}
|
||||
|
||||
function describeEntry(entry: Dict): { kind: string; label: string } {
|
||||
@@ -549,8 +557,18 @@ export function mergeIndexPayloads(
|
||||
out["_sync_meta"] = meta;
|
||||
|
||||
const outSystem: Dict = isDict(out["_system"]) ? (out["_system"] as Dict) : {};
|
||||
if (options.index0 !== "preserve-current") {
|
||||
const mode = options.index0 ?? "merge";
|
||||
if (mode === "reject") {
|
||||
// Populated blocks were already refused above; anything reaching here is
|
||||
// empty, and is normalized so the output shape is stable.
|
||||
outSystem["index0"] = emptyIndex0();
|
||||
} else if (mode === "merge") {
|
||||
// Both sides' derived state, merged by the same deterministic rules
|
||||
// Python uses. Doing this from the ORIGINAL payloads rather than from
|
||||
// `out` matters: `out` has already had entries merged into it, and
|
||||
// index0 records what each writer did, not what the merge concluded.
|
||||
const incomingSystem = isDict(incoming["_system"]) ? (incoming["_system"] as Dict) : {};
|
||||
outSystem["index0"] = mergeSystemIndex0(outSystem["index0"], incomingSystem["index0"]);
|
||||
}
|
||||
out["_system"] = outSystem;
|
||||
return out;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Emit index0 events for whatever a vault mutation actually changed.
|
||||
*
|
||||
* Python emits from each call site in EntryManager, naming the operation it
|
||||
* just performed. This derives the events by comparing the index before and
|
||||
* after instead, and is wired into the single mutation funnel every surface
|
||||
* goes through (CLI, TUI, API).
|
||||
*
|
||||
* That is a deliberate difference, for one reason: a call-site emitter is
|
||||
* something a new code path can forget, and a silently incomplete activity
|
||||
* ledger is worse than an absent one — it reads as "nothing happened". A
|
||||
* funnel-level differ cannot be forgotten, because there is nowhere else to
|
||||
* write a change from. The event VOCABULARY matches Python's
|
||||
* (entry_created / entry_updated / entry_deleted), so a stream from either
|
||||
* implementation reads the same.
|
||||
*/
|
||||
|
||||
import { appendIndex0Event, type Dict } from "./index0.js";
|
||||
|
||||
function isDict(value: unknown): value is Dict {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function kindOf(entry: Dict): string {
|
||||
return String(entry["kind"] ?? entry["type"] ?? "").trim() || "entry";
|
||||
}
|
||||
|
||||
function tsOf(entry: Dict, fallback: number): number {
|
||||
const raw = Number(entry["modified_ts"] ?? 0);
|
||||
return Number.isFinite(raw) && raw > 0 ? Math.trunc(raw) : fallback;
|
||||
}
|
||||
|
||||
export interface EmitOptions {
|
||||
/** Entries as they were before the mutation. */
|
||||
before: Record<string, unknown>;
|
||||
/** Entries as they are after it. */
|
||||
after: Record<string, unknown>;
|
||||
fingerprintDir: string;
|
||||
/** Seconds since the epoch, for entries carrying no timestamp of their own. */
|
||||
now: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append one event per changed entry, returning the updated payload.
|
||||
*
|
||||
* Comparison is by canonical JSON of the whole entry, so a change to any
|
||||
* field counts — including ones no specific call site would have thought to
|
||||
* report.
|
||||
*/
|
||||
export function emitEntryEvents(payload: unknown, options: EmitOptions): Dict {
|
||||
const { before, after, fingerprintDir, now } = options;
|
||||
let out = payload as Dict;
|
||||
|
||||
const ids = new Set([...Object.keys(before), ...Object.keys(after)]);
|
||||
// Sorted so the emitted order is deterministic: two replicas performing the
|
||||
// same batch must produce the same stream.
|
||||
for (const id of [...ids].sort((a, b) => {
|
||||
const an = Number(a);
|
||||
const bn = Number(b);
|
||||
if (Number.isInteger(an) && Number.isInteger(bn)) return an - bn;
|
||||
return a < b ? -1 : a > b ? 1 : 0;
|
||||
})) {
|
||||
const oldEntry = before[id];
|
||||
const newEntry = after[id];
|
||||
const had = isDict(oldEntry);
|
||||
const has = isDict(newEntry);
|
||||
if (!had && !has) continue;
|
||||
|
||||
if (has && !had) {
|
||||
out = appendIndex0Event(out, {
|
||||
eventType: "entry_created",
|
||||
subjectType: "entry",
|
||||
subjectId: id,
|
||||
subjectKind: kindOf(newEntry),
|
||||
modifiedTs: tsOf(newEntry, now),
|
||||
fingerprintDir,
|
||||
payloadRef: { entry_id: id },
|
||||
links: Array.isArray(newEntry["links"]) ? (newEntry["links"] as unknown[]) : [],
|
||||
tags: Array.isArray(newEntry["tags"]) ? (newEntry["tags"] as unknown[]) : [],
|
||||
summary: `Created ${kindOf(newEntry)} entry`,
|
||||
source: "entry_management",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (had && !has) {
|
||||
out = appendIndex0Event(out, {
|
||||
eventType: "entry_deleted",
|
||||
subjectType: "entry",
|
||||
subjectId: id,
|
||||
subjectKind: kindOf(oldEntry),
|
||||
modifiedTs: now,
|
||||
fingerprintDir,
|
||||
payloadRef: { entry_id: id },
|
||||
summary: `Deleted ${kindOf(oldEntry)} entry`,
|
||||
source: "entry_management",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (had && has && JSON.stringify(oldEntry) !== JSON.stringify(newEntry)) {
|
||||
out = appendIndex0Event(out, {
|
||||
eventType: "entry_updated",
|
||||
subjectType: "entry",
|
||||
subjectId: id,
|
||||
subjectKind: kindOf(newEntry),
|
||||
modifiedTs: tsOf(newEntry, now),
|
||||
fingerprintDir,
|
||||
payloadRef: { entry_id: id },
|
||||
links: Array.isArray(newEntry["links"]) ? (newEntry["links"] as unknown[]) : [],
|
||||
tags: Array.isArray(newEntry["tags"]) ? (newEntry["tags"] as unknown[]) : [],
|
||||
summary: `Updated ${kindOf(newEntry)} entry`,
|
||||
source: "entry_management",
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* index0 / atlas.
|
||||
*
|
||||
* This is derived state whose whole contract is hash parity: event ids,
|
||||
* integrity hashes, checkpoint summaries and view hashes are SHA-256 over
|
||||
* Python's canonical JSON, and the merge picks winners by comparing those
|
||||
* hashes. One byte of disagreement in the encoding means the two
|
||||
* implementations permanently disagree about which record wins.
|
||||
*
|
||||
* So the load-bearing tests compare whole structures against fixtures the
|
||||
* Python implementation produced, via canonicalJson rather than toEqual —
|
||||
* an assertion that passes only if the bytes that get hashed are identical.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
appendIndex0Event,
|
||||
compactIndex0Payload,
|
||||
buildManifestIndex0Metadata,
|
||||
mergeSystemIndex0,
|
||||
deriveIndex0Context,
|
||||
listCanonicalViews,
|
||||
getCanonicalView,
|
||||
normalizeIndex0,
|
||||
normalizeIndex0Event,
|
||||
rebuildCanonicalViewsPayload,
|
||||
emitEntryEvents,
|
||||
canonicalJson,
|
||||
LOCAL_ONLY_VIEW_TYPES,
|
||||
} from "@seedpass/core";
|
||||
import { index0Fixture as fx } from "@seedpass/test-vectors";
|
||||
|
||||
const FP = "/home/user/.seedpass/C557EEC878DFD852";
|
||||
const MANAGED = "/home/user/.seedpass/C557EEC878DFD852/accounts/AABBCCDDEEFF0011";
|
||||
|
||||
/** Rebuild the fixture's event stream, event for event. */
|
||||
function buildAppended(): Record<string, unknown> {
|
||||
let payload: unknown = { schema_version: 4, entries: fx.entries };
|
||||
const specs = [
|
||||
[1700000100, "entry.created", "0", "password"],
|
||||
[1700000200, "entry.created", "1", "totp"],
|
||||
[1700086400, "entry.updated", "0", "password"],
|
||||
[1700086500, "entry.archived", "1", "totp"],
|
||||
] as const;
|
||||
specs.forEach(([ts, eventType, subjectId, subjectKind], i) => {
|
||||
payload = appendIndex0Event(payload, {
|
||||
eventType,
|
||||
subjectType: "entry",
|
||||
subjectId,
|
||||
subjectKind,
|
||||
modifiedTs: ts,
|
||||
fingerprintDir: FP,
|
||||
tags: ["auto"],
|
||||
summary: `event ${i} — ünï`,
|
||||
});
|
||||
});
|
||||
return appendIndex0Event(payload, {
|
||||
eventType: "entry.created",
|
||||
subjectType: "entry",
|
||||
subjectId: "7",
|
||||
subjectKind: "seed",
|
||||
modifiedTs: 1700090000,
|
||||
fingerprintDir: MANAGED,
|
||||
});
|
||||
}
|
||||
|
||||
describe("byte-for-byte parity with Python", () => {
|
||||
it("derives the same writer context, including managed accounts", () => {
|
||||
expect(deriveIndex0Context(FP)).toEqual(fx.context_root);
|
||||
// A managed account's scope names both the root and the child, so views
|
||||
// from a parent and its child cannot collide.
|
||||
expect(deriveIndex0Context(MANAGED)).toEqual(fx.context_managed);
|
||||
});
|
||||
|
||||
it("produces an identical event stream, hashes and heads", () => {
|
||||
// Every event id is a hash of the event, and each chains onto the
|
||||
// writer's previous head — so this passing means the canonical encoding,
|
||||
// the field set and the chaining all match.
|
||||
expect(canonicalJson(buildAppended())).toBe(canonicalJson(fx.appended));
|
||||
});
|
||||
|
||||
it("compacts to identical checkpoints and views", () => {
|
||||
const compacted = compactIndex0Payload(buildAppended(), { fingerprintDir: FP });
|
||||
expect(canonicalJson(compacted)).toBe(canonicalJson(fx.compacted));
|
||||
});
|
||||
|
||||
it("produces identical manifest metadata", () => {
|
||||
expect(
|
||||
canonicalJson(buildManifestIndex0Metadata(buildAppended(), { fingerprintDir: FP })),
|
||||
).toBe(canonicalJson(fx.manifest_meta));
|
||||
});
|
||||
|
||||
it("merges two replicas to an identical result", () => {
|
||||
const compacted = compactIndex0Payload(buildAppended(), { fingerprintDir: FP });
|
||||
const mine = (compacted as Record<string, any>)["_system"].index0;
|
||||
const theirs = (fx.other as Record<string, any>)["_system"].index0;
|
||||
expect(canonicalJson(mergeSystemIndex0(mine, theirs))).toBe(canonicalJson(fx.merged));
|
||||
});
|
||||
|
||||
it("lists and fetches views identically", () => {
|
||||
const index0 = (compactIndex0Payload(buildAppended(), { fingerprintDir: FP }) as Record<
|
||||
string,
|
||||
any
|
||||
>)["_system"].index0;
|
||||
expect(canonicalJson(listCanonicalViews(index0))).toBe(canonicalJson(fx.views));
|
||||
expect(
|
||||
canonicalJson(
|
||||
getCanonicalView(index0, {
|
||||
viewType: "counts_by_kind",
|
||||
scopePath: fx.context_root["scope_path"]!,
|
||||
}),
|
||||
),
|
||||
).toBe(canonicalJson(fx.one_view));
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalization refuses what it cannot place", () => {
|
||||
it("drops events with no usable timestamp", () => {
|
||||
// A timestamp is what orders the stream; without one the event cannot be
|
||||
// placed, and guessing would corrupt the ordering silently.
|
||||
for (const bad of [0, -1, "", "abc", null]) {
|
||||
expect(normalizeIndex0Event({ event_type: "x", subject_type: "entry", subject_id: "1", writer_id: "w", modified_ts: bad })).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("drops events missing a required field", () => {
|
||||
const base = {
|
||||
event_type: "entry_created",
|
||||
subject_type: "entry",
|
||||
subject_id: "1",
|
||||
writer_id: "writer:profile:AAAA",
|
||||
modified_ts: 1700000000,
|
||||
};
|
||||
expect(normalizeIndex0Event(base)).not.toBeNull();
|
||||
for (const field of ["event_type", "subject_type", "subject_id", "writer_id"]) {
|
||||
expect(normalizeIndex0Event({ ...base, [field]: "" })).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves a stored integrity hash even when it disagrees", () => {
|
||||
// This normalizes; it does not verify. Rewriting a mismatched hash would
|
||||
// hide exactly the tampering the hash exists to reveal.
|
||||
const event = normalizeIndex0Event({
|
||||
event_type: "entry_created",
|
||||
subject_type: "entry",
|
||||
subject_id: "1",
|
||||
writer_id: "w",
|
||||
modified_ts: 1700000000,
|
||||
integrity_hash: "deadbeef",
|
||||
})!;
|
||||
expect(event["integrity_hash"]).toBe("deadbeef");
|
||||
});
|
||||
|
||||
it("never admits a local-only view into shared state", () => {
|
||||
// These are derived from data that does not leave the machine, so
|
||||
// syncing them would publish inferences about it.
|
||||
for (const viewType of LOCAL_ONLY_VIEW_TYPES) {
|
||||
const normalized = normalizeIndex0({
|
||||
canonical_views: {
|
||||
"x:y": { view_id: "x:y", view_type: viewType, modified_ts: 1700000000 },
|
||||
},
|
||||
});
|
||||
expect(normalized.canonical_views).toEqual({});
|
||||
}
|
||||
});
|
||||
|
||||
it("survives a malformed entry id rather than failing to build views", () => {
|
||||
// A vault that will not open is indistinguishable from one that is lost,
|
||||
// so a hand-edited id degrades the ordering instead of throwing.
|
||||
const rebuilt = rebuildCanonicalViewsPayload(
|
||||
{ entries: { "2": { kind: "password", label: "b" }, "not-a-number": { kind: "password", label: "a" } } },
|
||||
{ fingerprintDir: FP },
|
||||
);
|
||||
const view = getCanonicalView((rebuilt as any)._system.index0, {
|
||||
viewType: "children_of",
|
||||
scopePath: deriveIndex0Context(FP).scope_path,
|
||||
})!;
|
||||
const ids = ((view["data"] as any).children as any[]).map((c) => c.entry_id);
|
||||
// Numeric ids first, non-numeric after — the fixture proves Python agrees.
|
||||
expect(ids).toEqual(["2", "not-a-number"]);
|
||||
});
|
||||
|
||||
it("tolerates junk in place of the whole block", () => {
|
||||
for (const junk of [null, "string", 42, []]) {
|
||||
const normalized = normalizeIndex0(junk);
|
||||
expect(normalized.events).toEqual({});
|
||||
expect(normalized.stats["event_count"]).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the differ records what a mutation changed", () => {
|
||||
const base = { schema_version: 4, entries: {} as Record<string, unknown> };
|
||||
|
||||
it("emits create, update and delete with Python's vocabulary", () => {
|
||||
const before = { "0": { kind: "password", label: "a", modified_ts: 1700000000 } };
|
||||
const after = {
|
||||
"0": { kind: "password", label: "renamed", modified_ts: 1700000500 },
|
||||
"1": { kind: "totp", label: "new", modified_ts: 1700000600 },
|
||||
};
|
||||
const out = emitEntryEvents(
|
||||
{ ...base, entries: after, _system: { index0: { events: {} } } },
|
||||
{ before, after, fingerprintDir: FP, now: 1700000700 },
|
||||
);
|
||||
const events = Object.values((out as any)._system.index0.events) as any[];
|
||||
const byType = events.map((e) => e.event_type).sort();
|
||||
expect(byType).toEqual(["entry_created", "entry_updated"]);
|
||||
|
||||
const deleted = emitEntryEvents(
|
||||
{ ...base, entries: {}, _system: { index0: { events: {} } } },
|
||||
{ before, after: {}, fingerprintDir: FP, now: 1700000700 },
|
||||
);
|
||||
const deletedEvents = Object.values((deleted as any)._system.index0.events) as any[];
|
||||
expect(deletedEvents[0].event_type).toBe("entry_deleted");
|
||||
expect(deletedEvents[0].subject_id).toBe("0");
|
||||
});
|
||||
|
||||
it("emits nothing when nothing changed", () => {
|
||||
const entries = { "0": { kind: "password", label: "a", modified_ts: 1700000000 } };
|
||||
const out = emitEntryEvents(
|
||||
{ ...base, entries, _system: { index0: { events: {} } } },
|
||||
{ before: entries, after: entries, fingerprintDir: FP, now: 1700000700 },
|
||||
);
|
||||
expect(Object.keys((out as any)._system.index0.events)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("emits in a deterministic order, so two replicas agree", () => {
|
||||
const after = {
|
||||
"10": { kind: "password", label: "j", modified_ts: 1700000010 },
|
||||
"2": { kind: "password", label: "b", modified_ts: 1700000002 },
|
||||
"1": { kind: "password", label: "a", modified_ts: 1700000001 },
|
||||
};
|
||||
const run = () =>
|
||||
canonicalJson(
|
||||
emitEntryEvents(
|
||||
{ ...base, entries: after, _system: { index0: { events: {} } } },
|
||||
{ before: {}, after, fingerprintDir: FP, now: 1700000700 },
|
||||
),
|
||||
);
|
||||
expect(run()).toBe(run());
|
||||
});
|
||||
});
|
||||
@@ -25,11 +25,11 @@
|
||||
"kdf": "pbkdf2-sha256",
|
||||
"salt_b64": "AAECAwQFBgcICQoLDA0ODw==",
|
||||
"version": 1,
|
||||
"wrapped_partition_key": "gAAAAABqhbsiuZu-EfSBrvoDDpPRJXSPP30W1kGfWZz-WMbSHKjqJn_qHWYfiwi-4xhlczaRZKTUwANB-UIAo5XkAi7MbNwBlT_ElZaSQkskX85zbTuC8sbioHEUT1kASzV6rIbbta0e"
|
||||
"wrapped_partition_key": "gAAAAABlU_EAAAECAwQFBgcICQoLDA0OD5IWjg3AYnG2ZBi9dQctSsCrYOHkdaqFp5aGi3XpMl4t2OXcBZi1glMl1L2NHKqL4XsvRw--CptMCQgvuHnFA3iNF_1d8dib4NxUmgQ0NRnd"
|
||||
},
|
||||
"factor": "second-factor-passphrase",
|
||||
"fixture_version": 1,
|
||||
"partition_file_b64": "Z0FBQUFBQnFoYnNpU2FoTjlTZDF1MUNwV1JwLVRqNTRiWlplWXRRTXZhVzdqa2FoYUVMUl84NDVmdzdoNjJucFgzUzFWbFh5aGRuOW9UcWNfY3lLMTZpSTYySGo4LXVJM0tOYW0tVmc0bHhtOGVITkd4MWw4VEJOeGk3eF9NZWloQmoxWUJkaTg3RUlZdHZnWkVobE00cHQ0NzVucnRTNlJYR0RPMnozYV9pTVl0SVRqcjE3eUdRUkVwVzBuMHlCcGJxZ2VZbHRaallmclpIdlNkTnpwczRIdF9BeWVITHQtTER3S0tPMU9ZZU41VkkwWlRKdFg5eEw3SFIycm44MnNKTzNHVUFYVTNBWFRjbEx3Ri1vLVYxY2Y3cmQxUmstTHBSX0tvekZFZUxfdmZsV05aMGJ5WXgxMHFvbFlXZ1VYbTVyUWJ4ZG9BUXdFRklLVS1YZUxOTW1FekVVcGpXUGlicFktbm52NmR3Nlk0aW9RdUFGZmZwRlpnN091dDh5NUlqOXFwVEpGeGZSdEROVUtfTzhiUVdFamZqSHo5RGF1TmV0YWkzNkwyS3FnX0lJVTducEdzZ0xJbEVvRVFfX09Va1Byb0lWaTdMNlFnSDRmYUNpb2UxVGlsU19lQWMzRS1oRlZQSHpOM1ptRG05dU16QjdUQVZycGxWcUdlNWEtRjBoNkNncUU4SVo=",
|
||||
"partition_file_b64": "Z0FBQUFBQmxVX0VBQUFFQ0F3UUZCZ2NJQ1FvTERBME9EOWZDeDVfMXBmbkNTRGc4cEk0SWk1b1FxQzNJaFNScTllWEsyT2hTWkQwUWhCX3JiUkE5RXFzTWYzS1pXT2F4OE5kTDJfZW9FNHRiVUNmZGJ1Nl9JT2ZFbHUxV243SWdqbnlDUjZac1J6ZFpVaDVITTlZb0RhVDQ3MDB5VEJON2ZUcVlRZXNPSnFsMWx2Si11eGw1VTVBLXF1MHJwVWx3cWlmWldvQnBDeDZ5QnY2RjcwbWdfT3lyNFVCWm1mYjk4SW41U19nck50dWFmV1lGSkFCdFA5eXFubU9MUHFfZ0RZNDVOVGJTX0F3ODRSX1JsRGJ0bXpuSElvNHAweWpQZC14SmlUakNzSTY0ejl5cElsTVRFWlRhemhGVEl4QTJEdlp6akdaR0VjS3R0dW9jb2RQa2FtQ1VGNEVCalhIdUh5a3hsdXpCcVZ2YXlCb2JQc2l2VlQxSGlfaVlyVjVsRjhsZVFBVnBubDZPdFNDZF9HbDdnZG1RSFhuZzdxRVVPVHprYXBKaC16NlhXTFRLZjhEQkZqZ3BpeEh5bnBWandWdDI2U29ZeXdlNVA5TVU5N3J5dk8yOHNNV1hqZnhIdktDa0hCRnJpMFlPV2FNTE9EbW8wZkpXLUJKZC05WGxKRnhHV0ZSSTR0WjA=",
|
||||
"partition_key": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=",
|
||||
"tag": "905f28def18eaac05ae6f12b2c3452744afaf626da1343d57b395b544e0519b6"
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -9,6 +9,7 @@
|
||||
"entry_secrets.json",
|
||||
"fingerprints.json",
|
||||
"high_risk.json",
|
||||
"index0.json",
|
||||
"index_keys.json",
|
||||
"kdf_metadata.json",
|
||||
"legacy_payloads.json",
|
||||
@@ -31,6 +32,6 @@
|
||||
],
|
||||
"fixture_version": 1,
|
||||
"generator": "scripts/generate_ts_port_fixtures.py",
|
||||
"python_commit": "885cbe5665498c442ad1d3bebabf5e010db77961",
|
||||
"python_commit": "64b27d7bf932120392e2b4ff0f46f32de59f26c9",
|
||||
"redaction_policy": "All fixtures derive from public BIP-39 test mnemonics (abandon.../legal.../zoo...). Secret-shaped outputs are generated test material, never from a live profile."
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import manifestJson from "../fixtures/manifest.json";
|
||||
import qrJson from "../fixtures/qr.json";
|
||||
import semanticJson from "../fixtures/semantic.json";
|
||||
import highRiskJson from "../fixtures/high_risk.json";
|
||||
import index0Json from "../fixtures/index0.json";
|
||||
import bip39Json from "../fixtures/bip39_seeds.json";
|
||||
import bip85Json from "../fixtures/bip85_entropy.json";
|
||||
import passwordsV1Json from "../fixtures/passwords_v1.json";
|
||||
@@ -344,3 +345,17 @@ export const highRiskFixture = highRiskJson as {
|
||||
entries: Record<string, Record<string, unknown>>;
|
||||
partition_file_b64: string;
|
||||
};
|
||||
|
||||
/** index0/atlas reference state, produced by the Python implementation. */
|
||||
export const index0Fixture = index0Json as {
|
||||
entries: Record<string, Record<string, unknown>>;
|
||||
context_root: Record<string, string>;
|
||||
context_managed: Record<string, string>;
|
||||
appended: Record<string, unknown>;
|
||||
compacted: Record<string, unknown>;
|
||||
manifest_meta: Record<string, unknown>;
|
||||
other: Record<string, unknown>;
|
||||
merged: Record<string, unknown>;
|
||||
views: Array<Record<string, unknown>>;
|
||||
one_view: Record<string, unknown>;
|
||||
};
|
||||
@@ -1379,6 +1379,14 @@ def gen_high_risk() -> dict:
|
||||
salt = bytes(range(16))
|
||||
partition_key = base64.urlsafe_b64encode(bytes(range(32))).decode("ascii")
|
||||
|
||||
# Fernet embeds a random IV and the current time, so `encrypt` would emit
|
||||
# different bytes on every run and the fixture-drift CI job would fail
|
||||
# each time it regenerated. `_encrypt_from_parts` pins both. Production
|
||||
# must never do this -- a repeated IV under one key breaks CBC -- which is
|
||||
# why it appears only here, in a generator producing test vectors from a
|
||||
# fixed key.
|
||||
fixed_iv = bytes(range(16))
|
||||
fixed_time = 1700000000
|
||||
wrapping_key = iso._derive_wrapping_key(factor, salt, iso.PARTITION_KDF_ITERATIONS)
|
||||
envelope = {
|
||||
"version": iso.PARTITION_ENVELOPE_VERSION,
|
||||
@@ -1386,7 +1394,7 @@ def gen_high_risk() -> dict:
|
||||
"iterations": iso.PARTITION_KDF_ITERATIONS,
|
||||
"salt_b64": base64.b64encode(salt).decode("ascii"),
|
||||
"wrapped_partition_key": Fernet(wrapping_key)
|
||||
.encrypt(partition_key.encode("utf-8"))
|
||||
._encrypt_from_parts(partition_key.encode("utf-8"), fixed_time, fixed_iv)
|
||||
.decode("utf-8"),
|
||||
}
|
||||
tag = iso._partition_key_tag(partition_key)
|
||||
@@ -1402,11 +1410,28 @@ def gen_high_risk() -> dict:
|
||||
},
|
||||
}
|
||||
|
||||
# Same reason: build the partition blob with a pinned IV and time rather
|
||||
# than calling save_partition_entries, whose Fernet output is random.
|
||||
payload = json.dumps(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"partition": "high_risk",
|
||||
"updated_at_utc": float(fixed_time),
|
||||
"entries": entries,
|
||||
},
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
file_key = hashlib.sha256(tag.encode("utf-8")).digest()
|
||||
blob = Fernet(base64.urlsafe_b64encode(file_key))._encrypt_from_parts(
|
||||
payload, fixed_time, fixed_iv
|
||||
)
|
||||
# Prove the pinned construction is what the real writer/reader accept.
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
profile = Path(tmpdir) / "PROFILE"
|
||||
profile.mkdir()
|
||||
store.save_partition_entries(profile, tag, entries)
|
||||
blob = (profile / store.PARTITION_FILENAME).read_bytes()
|
||||
(profile / store.PARTITION_FILENAME).write_bytes(blob)
|
||||
assert store.load_partition_entries(profile, tag) == entries
|
||||
|
||||
return {
|
||||
"description": "high-risk partition envelope and file, for cross-impl checks",
|
||||
@@ -1419,6 +1444,102 @@ def gen_high_risk() -> dict:
|
||||
}
|
||||
|
||||
|
||||
def gen_index0() -> dict:
|
||||
"""index0/atlas reference state for the TypeScript port.
|
||||
|
||||
Everything in index0 is hashed with SHA-256 over Python's canonical JSON,
|
||||
and the merge picks winners by comparing those hashes -- so a single byte
|
||||
of encoding disagreement makes the two implementations permanently
|
||||
disagree about which record wins. These vectors are compared whole, via
|
||||
canonical JSON, rather than field by field.
|
||||
|
||||
The inputs deliberately include non-ASCII labels and tags (ensure_ascii
|
||||
escaping), a non-numeric entry id (view ordering must degrade, not throw),
|
||||
two writers on two days (checkpoint grouping and retention), and a managed
|
||||
account (the second scope path).
|
||||
"""
|
||||
from seedpass.core import index0 as index0_mod
|
||||
|
||||
fp_dir = "/home/user/.seedpass/C557EEC878DFD852"
|
||||
managed_dir = "/home/user/.seedpass/C557EEC878DFD852/accounts/AABBCCDDEEFF0011"
|
||||
|
||||
entries = {
|
||||
"0": {"kind": "password", "label": "bank.example", "modified_ts": 1700000100,
|
||||
"tags": ["money", "daily"],
|
||||
"links": [{"target_id": "2", "relation": "depends_on", "note": "recovery"}]},
|
||||
"1": {"kind": "totp", "label": "email-2fa", "modified_ts": 1700000200,
|
||||
"archived": True},
|
||||
"2": {"kind": "ssh", "label": "prod \u2014 \u00fcn\u00efcod\u00e9 \u2713",
|
||||
"modified_ts": 1700000300, "tags": ["ops", "\u00fcn\u00ef"]},
|
||||
"10": {"kind": "document", "label": "notes", "modified_ts": 1700000400},
|
||||
"not-a-number": {"kind": "password", "label": "weird id",
|
||||
"modified_ts": 1700000500},
|
||||
}
|
||||
|
||||
payload = {"schema_version": 4, "entries": entries}
|
||||
specs = [
|
||||
(1700000100, "entry.created", "0", "password"),
|
||||
(1700000200, "entry.created", "1", "totp"),
|
||||
(1700086400, "entry.updated", "0", "password"),
|
||||
(1700086500, "entry.archived", "1", "totp"),
|
||||
]
|
||||
for i, (ts, event_type, subject_id, subject_kind) in enumerate(specs):
|
||||
payload = index0_mod.append_index0_event(
|
||||
payload,
|
||||
event_type=event_type,
|
||||
subject_type="entry",
|
||||
subject_id=subject_id,
|
||||
subject_kind=subject_kind,
|
||||
modified_ts=ts,
|
||||
fingerprint_dir=fp_dir,
|
||||
tags=["auto"],
|
||||
summary=f"event {i} \u2014 \u00fcn\u00ef",
|
||||
)
|
||||
payload = index0_mod.append_index0_event(
|
||||
payload,
|
||||
event_type="entry.created",
|
||||
subject_type="entry",
|
||||
subject_id="7",
|
||||
subject_kind="seed",
|
||||
modified_ts=1700090000,
|
||||
fingerprint_dir=managed_dir,
|
||||
)
|
||||
|
||||
compacted = index0_mod.compact_index0_payload(payload, fingerprint_dir=fp_dir)
|
||||
other = index0_mod.append_index0_event(
|
||||
{"schema_version": 4, "entries": entries},
|
||||
event_type="entry.created",
|
||||
subject_type="entry",
|
||||
subject_id="99",
|
||||
subject_kind="password",
|
||||
modified_ts=1700099999,
|
||||
fingerprint_dir=fp_dir,
|
||||
)
|
||||
scope = index0_mod.derive_index0_context(fp_dir)["scope_path"]
|
||||
|
||||
return {
|
||||
"description": "index0/atlas events, checkpoints, views and merge",
|
||||
"entries": entries,
|
||||
"context_root": index0_mod.derive_index0_context(fp_dir),
|
||||
"context_managed": index0_mod.derive_index0_context(managed_dir),
|
||||
"appended": payload,
|
||||
"compacted": compacted,
|
||||
"manifest_meta": index0_mod.build_manifest_index0_metadata(
|
||||
payload, fingerprint_dir=fp_dir
|
||||
),
|
||||
"other": other,
|
||||
"merged": index0_mod.merge_system_index0(
|
||||
compacted["_system"]["index0"], other["_system"]["index0"]
|
||||
),
|
||||
"views": index0_mod.list_canonical_views(compacted["_system"]["index0"]),
|
||||
"one_view": index0_mod.get_canonical_view(
|
||||
compacted["_system"]["index0"],
|
||||
view_type="counts_by_kind",
|
||||
scope_path=scope,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
FIXTURES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -1445,6 +1566,7 @@ def main() -> None:
|
||||
"qr.json": gen_qr(),
|
||||
"semantic.json": gen_semantic(),
|
||||
"high_risk.json": gen_high_risk(),
|
||||
"index0.json": gen_index0(),
|
||||
}
|
||||
entries_fixture, vault_fixture = gen_entries_and_vault()
|
||||
files["entries_index.json"] = entries_fixture
|
||||
|
||||
Reference in new issue
Block a user