mirror of
https://github.com/grub-basket/SP.git
synced 2026-07-22 07:46:27 +00:00
718 lines
39 KiB
TypeScript
718 lines
39 KiB
TypeScript
import { App, TFile } from "obsidian";
|
|
import { buildStashZip, importStashZip, splitFrontmatter, resolveNoteAttachmentFiles } from "./stash-package";
|
|
import { encryptWithKey, decryptWithKey, isEncryptedStash } from "./stash-crypto";
|
|
import { type StashpadId } from "./types";
|
|
import { unzipFiles, zipFiles } from "./zip";
|
|
|
|
/** In-vault locked-bundle extension (NOT `.stash` — `.stash` is an export to
|
|
* import; `.stashenc` is locked-in-place and must never auto-import). */
|
|
export const STASHENC_EXT = "stashenc";
|
|
/** Plaintext sidecar (`<blob>.stashmeta`) holding the placeholder metadata
|
|
* (parent/title/count/created/order). The durable source of truth for rendering
|
|
* the 🔒 stub in the right spot — survives a lost settings registry AND travels
|
|
* with the blob across devices. Never the note contents (those stay encrypted). */
|
|
export const STASHMETA_EXT = "stashmeta";
|
|
export interface LockedMeta { v: number; parentId: string | null; title: string; count: number; created: string; rootId: string; prevSibling: string | null; }
|
|
function sidecarPath(blobPath: string): string { return blobPath.replace(/\.stashenc$/, `.${STASHMETA_EXT}`); }
|
|
export async function readLockedMeta(app: App, blobPath: string): Promise<LockedMeta | null> {
|
|
try { return JSON.parse(await app.vault.adapter.read(sidecarPath(blobPath))) as LockedMeta; }
|
|
catch { return null; }
|
|
}
|
|
|
|
export interface LockResult {
|
|
blobPath: string;
|
|
noteCount: number;
|
|
rootId: StashpadId;
|
|
/** Parent id of the locked root, to anchor a placeholder later. */
|
|
parentId: StashpadId | null;
|
|
title: string;
|
|
/** Root note's `created` — lets the placeholder slot back into list order. */
|
|
created: string;
|
|
/** Plaintext files that could NOT be removed (delete failed, or the file was
|
|
* edited after bundling). Non-empty = the lock succeeded but readable copies
|
|
* remain on disk — callers must warn the user loudly. */
|
|
unpurged: string[];
|
|
}
|
|
|
|
interface SubtreeNode { id: StashpadId; file: TFile; parent: StashpadId | null; created: string; }
|
|
|
|
/** Collect a note + all its descendants within `folder` by walking frontmatter
|
|
* `parent` links. Returns the root note and the rest, plus the root's parent. */
|
|
export async function collectSubtree(app: App, folder: string, rootId: StashpadId): Promise<{
|
|
rootNote: SubtreeNode; descendants: SubtreeNode[]; parentId: StashpadId | null;
|
|
} | null> {
|
|
const cleaned = folder.replace(/\/+$/, "");
|
|
const inFolder: SubtreeNode[] = [];
|
|
// Read frontmatter from DISK, not metadataCache — the cache can lag right
|
|
// after edits/imports, and an under-read here would bundle an INCOMPLETE
|
|
// subtree (leaving children orphaned or stranded). Disk is authoritative.
|
|
for (const f of app.vault.getMarkdownFiles()) {
|
|
if ((f.parent?.path?.replace(/\/+$/, "") ?? "") !== cleaned) continue;
|
|
let fm: Record<string, unknown>;
|
|
try { fm = splitFrontmatter(await app.vault.read(f)).fm; } catch { continue; }
|
|
if (typeof fm.id !== "string") continue;
|
|
inFolder.push({ id: fm.id, file: f, parent: typeof fm.parent === "string" ? fm.parent : null, created: typeof fm.created === "string" ? fm.created : "" });
|
|
}
|
|
const root = inFolder.find((n) => n.id === rootId);
|
|
if (!root) return null;
|
|
|
|
// BFS down the parent graph.
|
|
const childrenOf = new Map<StashpadId, SubtreeNode[]>();
|
|
for (const n of inFolder) {
|
|
if (!n.parent) continue;
|
|
const arr = childrenOf.get(n.parent) ?? [];
|
|
arr.push(n); childrenOf.set(n.parent, arr);
|
|
}
|
|
const descendants: SubtreeNode[] = [];
|
|
const seen = new Set<StashpadId>([rootId]);
|
|
let frontier = [rootId];
|
|
while (frontier.length) {
|
|
const next: StashpadId[] = [];
|
|
for (const id of frontier) {
|
|
for (const c of childrenOf.get(id) ?? []) {
|
|
if (seen.has(c.id)) continue; // cycle guard
|
|
seen.add(c.id); descendants.push(c); next.push(c.id);
|
|
}
|
|
}
|
|
frontier = next;
|
|
}
|
|
return { rootNote: root, descendants, parentId: root.parent };
|
|
}
|
|
|
|
/** Reject a sidecar/dest folder that could escape the vault (sidecars are
|
|
* plaintext JSON anyone can edit — a tampered `originalFolder` of `../..` or
|
|
* an absolute/drive path must never become a write destination). Returns the
|
|
* cleaned folder, or null if it's unsafe/empty. */
|
|
function safeVaultFolder(raw: string | null | undefined): string | null {
|
|
if (!raw) return null;
|
|
const cleaned = raw.replace(/\/+$/, "");
|
|
if (!cleaned) return null;
|
|
if (cleaned.startsWith("/") || cleaned.includes("\\") || /^[a-zA-Z]:/.test(cleaned)) return null;
|
|
if (cleaned.split("/").some((seg) => seg === ".." || seg === ".")) return null;
|
|
return cleaned;
|
|
}
|
|
|
|
/** Write the encrypted blob, then READ IT BACK and byte-compare — the in-memory
|
|
* round-trip proves the encryption, but only this proves the bytes that landed
|
|
* on disk (network drives / sync clients can truncate or corrupt a write, and
|
|
* we permanently delete the plaintext right after). Removes a bad blob and
|
|
* throws so no plaintext is touched. */
|
|
async function writeBlobVerified(app: App, blobPath: string, blob: Uint8Array): Promise<void> {
|
|
await app.vault.adapter.writeBinary(blobPath, blob as unknown as ArrayBuffer);
|
|
let onDisk: Uint8Array;
|
|
try { onDisk = new Uint8Array(await app.vault.adapter.readBinary(blobPath)); }
|
|
catch (e) { throw new Error(`Couldn't read back the encrypted file to verify it (${(e as Error).message}). Nothing was deleted.`); }
|
|
let ok = onDisk.length === blob.length;
|
|
if (ok) for (let i = 0; i < blob.length; i++) { if (onDisk[i] !== blob[i]) { ok = false; break; } }
|
|
if (!ok) {
|
|
try { await app.vault.adapter.remove(blobPath); } catch { /* leave the bad blob; plaintext is intact */ }
|
|
throw new Error("The encrypted file on disk doesn't match what was written (bad write?). Nothing was deleted.");
|
|
}
|
|
}
|
|
|
|
function titleFromFile(file: TFile): string {
|
|
return file.basename.replace(/-[a-z0-9]{4,12}$/, "").replace(/-/g, " ").trim() || file.basename;
|
|
}
|
|
function safeBlobBase(title: string): string {
|
|
return (title.replace(/[\\/:*?"<>|]+/g, " ").replace(/\s+/g, " ").trim() || "locked").slice(0, 60);
|
|
}
|
|
|
|
/** PERMANENTLY delete a subtree's plaintext: the note files AND the attachments
|
|
* referenced ONLY by this subtree (shared ones are kept). The encrypted blob is
|
|
* the recoverable copy. We use `vault.delete`, NOT trashFile/trash — those honor
|
|
* the user's "Deleted files" setting and would leave a readable plaintext copy in
|
|
* the system trash OR the vault's `.trash/` (which can sync), defeating the
|
|
* encryption. Exclusivity is computed from the live `resolvedLinks` graph BEFORE
|
|
* anything is deleted (it needs the notes present). Shared by lock + delete-encrypt.
|
|
*
|
|
* `mtimes` is each note's mtime captured when the bundle was BUILT: a file
|
|
* edited in the window between bundling and this purge (other pane, coworker
|
|
* on a shared drive, sync client) is NOT deleted — the blob holds the stale
|
|
* content and deleting would destroy the newer edit irreversibly. Skipped and
|
|
* failed deletions are returned so callers can warn loudly: a file we report
|
|
* as "locked" but couldn't remove is still readable plaintext on disk. */
|
|
async function purgeSubtreePlaintext(
|
|
app: App, all: { file: TFile }[], mtimes?: Map<string, number>,
|
|
): Promise<{ unpurged: string[] }> {
|
|
const subtreePaths = new Set(all.map((n) => n.file.path));
|
|
const subtreeAtts = new Map<string, TFile>();
|
|
for (const n of all) {
|
|
for (const af of await resolveNoteAttachmentFiles(app, n.file)) subtreeAtts.set(af.path, af);
|
|
}
|
|
const sharedExternally = new Set<string>();
|
|
const resolved = app.metadataCache.resolvedLinks ?? {};
|
|
for (const notePath of Object.keys(resolved)) {
|
|
if (subtreePaths.has(notePath)) continue; // a subtree note referencing it isn't "shared"
|
|
for (const target of Object.keys(resolved[notePath] ?? {})) {
|
|
if (subtreeAtts.has(target)) sharedExternally.add(target);
|
|
}
|
|
}
|
|
const unpurged: string[] = [];
|
|
for (const n of all) {
|
|
const baseline = mtimes?.get(n.file.path);
|
|
if (baseline != null) {
|
|
try {
|
|
const st = await app.vault.adapter.stat(n.file.path);
|
|
if (st && st.mtime !== baseline) {
|
|
console.warn("[Stashpad] note changed since it was bundled — keeping plaintext", n.file.path);
|
|
unpurged.push(n.file.path);
|
|
continue;
|
|
}
|
|
} catch { /* stat failed — fall through and let delete try */ }
|
|
}
|
|
try { await app.vault.delete(n.file); }
|
|
catch (e) { console.warn("[Stashpad] couldn't delete plaintext note", n.file.path, e); unpurged.push(n.file.path); }
|
|
}
|
|
for (const [path, af] of subtreeAtts) {
|
|
if (sharedExternally.has(path)) continue;
|
|
try { await app.vault.delete(af); }
|
|
catch (e) { console.warn("[Stashpad] couldn't delete exclusive attachment", path, e); unpurged.push(path); }
|
|
}
|
|
return { unpurged };
|
|
}
|
|
|
|
/** Lock a subtree into one `.stashenc` bundle, encrypted with `dek`. RAM-first:
|
|
* build + encrypt in memory, write the blob, **verify it round-trips**, and only
|
|
* THEN trash the plaintext note files. Returns info for a placeholder. */
|
|
export async function lockSubtree(
|
|
app: App, folder: string, rootId: StashpadId, dek: Uint8Array, prevSibling: StashpadId | null = null,
|
|
hideTitle = false, blobFolder?: string,
|
|
): Promise<LockResult> {
|
|
const sub = await collectSubtree(app, folder, rootId);
|
|
if (!sub) throw new Error("Couldn't find that note to lock.");
|
|
const { rootNote, descendants, parentId } = sub;
|
|
|
|
// Baseline mtimes BEFORE bundling — purge skips any file edited after this
|
|
// point (its newer content isn't in the blob and must not be destroyed).
|
|
const allNodes = [rootNote, ...descendants];
|
|
const mtimes = new Map<string, number>();
|
|
for (const n of allNodes) {
|
|
try { const st = await app.vault.adapter.stat(n.file.path); if (st) mtimes.set(n.file.path, st.mtime); } catch { /* no baseline → delete proceeds unguarded */ }
|
|
}
|
|
|
|
const zip = await buildStashZip(app, {
|
|
rootNotes: [{ id: rootNote.id, file: rootNote.file }],
|
|
allDescendants: descendants.map((d) => ({ id: d.id, file: d.file })),
|
|
sourceFolder: folder,
|
|
});
|
|
const blob = await encryptWithKey(zip, dek);
|
|
|
|
// Verify the blob round-trips BEFORE destroying any plaintext — full byte
|
|
// equality, not just length, since we PERMANENTLY delete the originals below
|
|
// (the blob is the only surviving copy, so it must be provably decryptable).
|
|
const back = await decryptWithKey(blob, dek);
|
|
if (back.length !== zip.length) throw new Error("Encryption self-check failed (size mismatch).");
|
|
for (let i = 0; i < zip.length; i++) {
|
|
if (back[i] !== zip[i]) throw new Error("Encryption self-check failed (content mismatch).");
|
|
}
|
|
|
|
// Write the blob next to the originals (unique name) — or into `blobFolder`
|
|
// when given (archive: read+delete from the source folder, but the encrypted
|
|
// blob lives in the archive folder so the 🔒 stub appears there).
|
|
const cleaned = (blobFolder ?? folder).replace(/\/+$/, "");
|
|
if (blobFolder && !(await app.vault.adapter.exists(cleaned))) await app.vault.adapter.mkdir(cleaned);
|
|
// When hiding titles, name the blob opaquely (by rootId) so the FILENAME doesn't
|
|
// leak the note title on disk / in sync. Otherwise use a readable title-based name.
|
|
const base = hideTitle ? safeBlobBase(rootId) : safeBlobBase(titleFromFile(rootNote.file));
|
|
let blobPath = `${cleaned}/${base}.${STASHENC_EXT}`;
|
|
for (let n = 1; await app.vault.adapter.exists(blobPath); n++) blobPath = `${cleaned}/${base} (${n}).${STASHENC_EXT}`;
|
|
await writeBlobVerified(app, blobPath, blob);
|
|
|
|
// Write the plaintext sidecar (placeholder metadata) BEFORE trashing originals.
|
|
const all = allNodes;
|
|
const meta: LockedMeta = {
|
|
// Empty title when hiding — the real title lives ONLY inside the encrypted
|
|
// blob. Placement uses parentId/rootId/prevSibling, so "" doesn't break it.
|
|
v: 1, parentId, title: hideTitle ? "" : titleFromFile(rootNote.file), count: all.length,
|
|
created: rootNote.created, rootId, prevSibling,
|
|
};
|
|
try { await app.vault.adapter.write(sidecarPath(blobPath), JSON.stringify(meta)); }
|
|
catch (e) { console.warn("[Stashpad] couldn't write lock sidecar", e); }
|
|
|
|
// Blob + sidecar written + verified byte-for-byte (in memory AND on disk) —
|
|
// now PERMANENTLY delete the plaintext originals (notes + subtree-exclusive
|
|
// attachments). The blob is the recoverable copy. See purgeSubtreePlaintext
|
|
// for the why-not-trash rationale.
|
|
const { unpurged } = await purgeSubtreePlaintext(app, all, mtimes);
|
|
|
|
return { blobPath, noteCount: all.length, rootId, parentId, title: meta.title, created: rootNote.created, unpurged };
|
|
}
|
|
|
|
/** Unlock a `.stashenc` bundle back into a folder (decrypt → importStashZip), then
|
|
* remove the blob. Defaults to the blob's own folder; pass `destFolder` to restore
|
|
* elsewhere (e.g. archive-undo restores the blob back to its SOURCE folder). */
|
|
export async function unlockBundle(
|
|
app: App, blobPath: string, dek: Uint8Array, existingIds: Set<StashpadId>, destFolder?: string,
|
|
): Promise<{ notesWritten: number; restoredTo: string }> {
|
|
const blob = new Uint8Array(await app.vault.adapter.readBinary(blobPath));
|
|
if (!isEncryptedStash(blob)) throw new Error("Not an encrypted bundle.");
|
|
const zip = await decryptWithKey(blob, dek); // throws on wrong key / tampering
|
|
// Current callers pass trusted blob-derived paths, but reject traversal /
|
|
// absolute destinations anyway in case a future caller plumbs sidecar data.
|
|
const folder = safeVaultFolder(destFolder) ?? blobPath.replace(/\/[^/]*$/, "");
|
|
// dedupeExisting: a SHARED attachment's original wasn't trashed on lock, so
|
|
// reuse it instead of writing a duplicate copy into _attachments on unlock.
|
|
const summary = await importStashZip(app, zip, folder, existingIds, { dedupeExisting: true });
|
|
await app.vault.adapter.remove(blobPath);
|
|
try { await app.vault.adapter.remove(sidecarPath(blobPath)); } catch { /* sidecar may not exist */ }
|
|
return { notesWritten: summary.notesWritten, restoredTo: folder };
|
|
}
|
|
|
|
// ---------------- Phase 5: encrypted trash (`_deleted/`) ----------------
|
|
|
|
/** Vault-level store for encrypted-deleted notes. Reserved + excluded from
|
|
* Stashpad/Obsidian scanning. The ONLY trash location Stashpad fully controls
|
|
* (vs the system/OS trash), so it can encrypt + list + restore from here. */
|
|
export const DELETED_DIR = "_deleted";
|
|
export interface DeletedMeta {
|
|
/** "deleted" = a Stashpad note bundle (importStashZip on restore).
|
|
* "rawtrash" = a raw zip of Obsidian's `.trash/` tree from the backfill
|
|
* command (plain unzip back into `.trash/` on restore). */
|
|
v: number; kind: "deleted" | "rawtrash" | "rawfolder";
|
|
/** Folder the note was deleted FROM — where Restore puts it back. EMPTY when
|
|
* titles are hidden (the sidecar is plaintext; the origin would leak where a
|
|
* hidden note came from) — `originalFolderEnc` carries it instead. */
|
|
originalFolder: string;
|
|
/** Base64 of `encryptWithKey(utf8(originalFolder), dek)` — set when titles
|
|
* are hidden so Restore can still find the origin without leaking it. */
|
|
originalFolderEnc?: string;
|
|
parentId: string | null; title: string; count: number; created: string;
|
|
rootId: string; deletedAt: string;
|
|
/** Per-folder trash keys: the FOLDER key id this blob was encrypted under
|
|
* (plaintext — just an identifier, safe to expose). Absent ⇒ encrypted under the
|
|
* vault DEK (legacy / vault-keyed folders). Restore resolves the right key by this
|
|
* id; rotation re-encrypts the trash blobs whose keyId matches the rotated key. */
|
|
keyId?: string;
|
|
}
|
|
|
|
function bytesToB64(bytes: Uint8Array): string {
|
|
let s = ""; for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
|
|
return btoa(s);
|
|
}
|
|
function b64ToBytes(b64: string): Uint8Array {
|
|
const s = atob(b64); const out = new Uint8Array(s.length);
|
|
for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i);
|
|
return out;
|
|
}
|
|
|
|
/** The restore destination for a deleted blob: plaintext `originalFolder`, or
|
|
* the encrypted one (hidden-titles delete) when a dek is at hand. Tampered /
|
|
* unsafe values are rejected (sidecars are editable plaintext). */
|
|
export async function deletedRestoreDest(app: App, blobPath: string, meta: DeletedMeta | null, dek?: Uint8Array): Promise<string> {
|
|
let origin = safeVaultFolder(meta?.originalFolder);
|
|
if (!origin && meta?.originalFolderEnc && dek) {
|
|
try { origin = safeVaultFolder(new TextDecoder().decode(await decryptWithKey(b64ToBytes(meta.originalFolderEnc), dek))); }
|
|
catch { origin = null; } // wrong key / tampered — fall back
|
|
}
|
|
if (origin) {
|
|
// Recreate a missing origin folder rather than falling back: for trash
|
|
// blobs the fallback would be `_deleted/` itself — decrypted plaintext
|
|
// dumped INTO the (reserved, hidden-from-discovery) trash store, stranded.
|
|
if (!(await app.vault.adapter.exists(origin))) await app.vault.adapter.mkdir(origin);
|
|
return origin;
|
|
}
|
|
const blobDir = blobPath.replace(/\/[^/]*$/, "");
|
|
if (blobDir === DELETED_DIR || blobDir.startsWith(`${DELETED_DIR}/`)) {
|
|
throw new Error("This deleted note's origin folder is unknown (missing or tampered sidecar) — can't restore it safely. The encrypted copy was kept.");
|
|
}
|
|
return blobDir;
|
|
}
|
|
|
|
// 0.112.2: `anyStashencOnDisk()` (a full recursive adapter walk) was removed —
|
|
// it took minutes on big vaults and its premise was wrong. Empirically (Claude
|
|
// Dev Vault, pendingEncBlobs empty): `vault.getFiles()` DOES return
|
|
// `_deleted/*.stashenc` (it's a normal, fully-indexed folder), so the old
|
|
// comment "`_deleted/` blobs aren't indexed" was stale. The only location
|
|
// `getFiles()` misses is the `.trash/` DOT-folder, which `plugin.encryptionState`
|
|
// + `encryptionStateStrict` now handle directly. Use those instead.
|
|
|
|
/** Encrypt-delete a subtree into `_deleted/` (recoverable, encrypted), then
|
|
* permanently delete the plaintext. Mirrors lockSubtree but the blob lives in the
|
|
* trash store + the sidecar records the original folder so Restore can put it
|
|
* back. `deletedAt` is passed in (callers stamp it — keeps this pure-ish). */
|
|
export async function deleteEncryptSubtree(
|
|
app: App, folder: string, rootId: StashpadId, dek: Uint8Array, deletedAt: string, hideTitle = false, keyId?: string,
|
|
): Promise<{ blobPath: string; noteCount: number; rootId: StashpadId; originalFolder: string; title: string; unpurged: string[] }> {
|
|
const sub = await collectSubtree(app, folder, rootId);
|
|
if (!sub) throw new Error("Couldn't find that note to delete.");
|
|
const { rootNote, descendants, parentId } = sub;
|
|
|
|
const allNodes = [rootNote, ...descendants];
|
|
const mtimes = new Map<string, number>();
|
|
for (const n of allNodes) {
|
|
try { const st = await app.vault.adapter.stat(n.file.path); if (st) mtimes.set(n.file.path, st.mtime); } catch { /* no baseline */ }
|
|
}
|
|
|
|
const zip = await buildStashZip(app, {
|
|
rootNotes: [{ id: rootNote.id, file: rootNote.file }],
|
|
allDescendants: descendants.map((d) => ({ id: d.id, file: d.file })),
|
|
sourceFolder: folder,
|
|
});
|
|
const blob = await encryptWithKey(zip, dek);
|
|
// Byte-for-byte verify before deleting the only plaintext copy.
|
|
const back = await decryptWithKey(blob, dek);
|
|
if (back.length !== zip.length) throw new Error("Encryption self-check failed (size).");
|
|
for (let i = 0; i < zip.length; i++) if (back[i] !== zip[i]) throw new Error("Encryption self-check failed (content).");
|
|
|
|
if (!(await app.vault.adapter.exists(DELETED_DIR))) await app.vault.adapter.mkdir(DELETED_DIR);
|
|
const cleanedFolder = folder.replace(/\/+$/, "");
|
|
const folderSlug = cleanedFolder.split("/").pop() || "vault";
|
|
// Readable name groups by folder; opaque (rootId) when hiding titles.
|
|
const base = hideTitle ? safeBlobBase(rootId) : safeBlobBase(`${folderSlug} ${titleFromFile(rootNote.file)}`);
|
|
let blobPath = `${DELETED_DIR}/${base}.${STASHENC_EXT}`;
|
|
for (let n = 1; await app.vault.adapter.exists(blobPath); n++) blobPath = `${DELETED_DIR}/${base} (${n}).${STASHENC_EXT}`;
|
|
await writeBlobVerified(app, blobPath, blob);
|
|
|
|
const all = allNodes;
|
|
// Hidden titles → the plaintext sidecar must not reveal the origin folder
|
|
// either; store it encrypted instead (decrypted on restore with the dek).
|
|
const meta: DeletedMeta = {
|
|
v: 1, kind: "deleted",
|
|
originalFolder: hideTitle ? "" : cleanedFolder,
|
|
...(hideTitle ? { originalFolderEnc: bytesToB64(await encryptWithKey(new TextEncoder().encode(cleanedFolder), dek)) } : {}),
|
|
parentId,
|
|
title: hideTitle ? "" : titleFromFile(rootNote.file), count: all.length,
|
|
created: rootNote.created, rootId, deletedAt,
|
|
...(keyId ? { keyId } : {}),
|
|
};
|
|
try { await app.vault.adapter.write(sidecarPath(blobPath), JSON.stringify(meta)); }
|
|
catch (e) { console.warn("[Stashpad] couldn't write deleted sidecar", e); }
|
|
|
|
const { unpurged } = await purgeSubtreePlaintext(app, all, mtimes);
|
|
return { blobPath, noteCount: all.length, rootId, originalFolder: cleanedFolder, title: meta.title, unpurged };
|
|
}
|
|
|
|
/** Restore an encrypted-deleted bundle back into its ORIGINAL folder (from the
|
|
* sidecar), then remove the blob + sidecar. Falls back to the blob's own dir if
|
|
* the sidecar/originalFolder is missing. */
|
|
export async function restoreDeleted(
|
|
app: App, blobPath: string, dek: Uint8Array, existingIds: Set<StashpadId>,
|
|
): Promise<{ notesWritten: number; restoredTo: string }> {
|
|
const blob = new Uint8Array(await app.vault.adapter.readBinary(blobPath));
|
|
if (!isEncryptedStash(blob)) throw new Error("Not an encrypted bundle.");
|
|
const meta = await readDeletedMeta(app, blobPath);
|
|
// Sanitized + decrypts the hidden-titles origin; tampered sidecar values
|
|
// can't redirect decrypted plaintext outside the vault.
|
|
const dest = await deletedRestoreDest(app, blobPath, meta, dek);
|
|
const zip = await decryptWithKey(blob, dek);
|
|
const summary = await importStashZip(app, zip, dest, existingIds, { dedupeExisting: true });
|
|
await app.vault.adapter.remove(blobPath);
|
|
try { await app.vault.adapter.remove(sidecarPath(blobPath)); } catch { /* may not exist */ }
|
|
return { notesWritten: summary.notesWritten, restoredTo: dest };
|
|
}
|
|
|
|
export async function readDeletedMeta(app: App, blobPath: string): Promise<DeletedMeta | null> {
|
|
try { return JSON.parse(await app.vault.adapter.read(sidecarPath(blobPath))) as DeletedMeta; }
|
|
catch { return null; }
|
|
}
|
|
|
|
/** Permanently delete an encrypted-trash item: the `.stashenc` blob AND its
|
|
* `.stashmeta` sidecar. No decrypt, no restore — the content is GONE forever.
|
|
* Callers MUST confirm first (it's irreversible). 0.112.3. */
|
|
export async function purgeDeletedBlob(app: App, blobPath: string): Promise<void> {
|
|
await app.vault.adapter.remove(blobPath);
|
|
try { await app.vault.adapter.remove(sidecarPath(blobPath)); } catch { /* sidecar may not exist */ }
|
|
}
|
|
|
|
// ------- v2 backfill: encrypt Obsidian's pre-existing plaintext trash -------
|
|
|
|
/** Obsidian's in-vault trash folder ("Deleted files" → "Move to vault trash"). */
|
|
export const OBSIDIAN_TRASH_DIR = ".trash";
|
|
|
|
async function listFilesRecursive(app: App, dir: string): Promise<string[]> {
|
|
const out: string[] = [];
|
|
const queue = [dir];
|
|
while (queue.length) {
|
|
const d = queue.shift()!;
|
|
let listing;
|
|
try { listing = await app.vault.adapter.list(d); } catch { continue; }
|
|
out.push(...listing.files);
|
|
queue.push(...listing.folders);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** A `.trash/` zip entry's relative path, validated segment-by-segment (the
|
|
* blob could be tampered with like any sidecar — never let an entry escape
|
|
* `.trash/`). Returns null for unsafe names. */
|
|
function safeTrashRelPath(name: string): string | null {
|
|
const segs = name.split("/").filter(Boolean);
|
|
if (segs.length === 0) return null;
|
|
for (const s of segs) {
|
|
if (s === ".." || s === "." || s.includes("\\") || /^[a-zA-Z]:/.test(s)) return null;
|
|
}
|
|
return segs.join("/");
|
|
}
|
|
|
|
/** v2 backfill ("Encrypt existing trash"): sweep everything in Obsidian's
|
|
* plaintext `.trash/` into ONE encrypted blob in `_deleted/`, then permanently
|
|
* remove the plaintext. `.trash` contents are arbitrary files (not Stashpad
|
|
* bundles), so the blob is a RAW zip of the tree, marked kind:"rawtrash";
|
|
* restore unzips it back into `.trash/`. Same safety rails as lock: read-back
|
|
* blob verify before any delete, mtime guard, unpurged reporting. Returns
|
|
* null when `.trash/` has nothing in it. */
|
|
export async function backfillTrashEncrypt(
|
|
app: App, dek: Uint8Array, deletedAt: string, hideTitle = false,
|
|
): Promise<{ blobPath: string; fileCount: number; unpurged: string[] } | null> {
|
|
if (!(await app.vault.adapter.exists(OBSIDIAN_TRASH_DIR))) return null;
|
|
const files = await listFilesRecursive(app, OBSIDIAN_TRASH_DIR);
|
|
if (files.length === 0) return null;
|
|
|
|
const zipEntries: { name: string; data: ArrayBuffer }[] = [];
|
|
const mtimes = new Map<string, number>();
|
|
for (const path of files) {
|
|
const rel = path.slice(OBSIDIAN_TRASH_DIR.length + 1);
|
|
zipEntries.push({ name: `files/${rel}`, data: await app.vault.adapter.readBinary(path) });
|
|
try { const st = await app.vault.adapter.stat(path); if (st) mtimes.set(path, st.mtime); } catch { /* no baseline */ }
|
|
}
|
|
const zipBytes = await zipFiles(zipEntries);
|
|
const blob = await encryptWithKey(zipBytes, dek);
|
|
const back = await decryptWithKey(blob, dek);
|
|
if (back.length !== zipBytes.length) throw new Error("Encryption self-check failed (size).");
|
|
for (let i = 0; i < zipBytes.length; i++) if (back[i] !== zipBytes[i]) throw new Error("Encryption self-check failed (content).");
|
|
|
|
if (!(await app.vault.adapter.exists(DELETED_DIR))) await app.vault.adapter.mkdir(DELETED_DIR);
|
|
const base = hideTitle ? safeBlobBase(`trash-${deletedAt.replace(/[^0-9]/g, "").slice(0, 14)}`) : safeBlobBase("Obsidian trash backfill");
|
|
let blobPath = `${DELETED_DIR}/${base}.${STASHENC_EXT}`;
|
|
for (let n = 1; await app.vault.adapter.exists(blobPath); n++) blobPath = `${DELETED_DIR}/${base} (${n}).${STASHENC_EXT}`;
|
|
await writeBlobVerified(app, blobPath, blob);
|
|
|
|
const meta: DeletedMeta = {
|
|
v: 1, kind: "rawtrash", originalFolder: OBSIDIAN_TRASH_DIR, parentId: null,
|
|
title: hideTitle ? "" : "Obsidian trash (backfill)", count: files.length,
|
|
created: deletedAt, rootId: "", deletedAt,
|
|
};
|
|
try { await app.vault.adapter.write(sidecarPath(blobPath), JSON.stringify(meta)); }
|
|
catch (e) { console.warn("[Stashpad] couldn't write backfill sidecar", e); }
|
|
|
|
// Permanently remove the plaintext trash files (same rationale + rails as
|
|
// purgeSubtreePlaintext — but these aren't TFiles, so adapter.remove).
|
|
const unpurged: string[] = [];
|
|
for (const path of files) {
|
|
const baseline = mtimes.get(path);
|
|
try {
|
|
if (baseline != null) {
|
|
const st = await app.vault.adapter.stat(path);
|
|
if (st && st.mtime !== baseline) { unpurged.push(path); continue; }
|
|
}
|
|
await app.vault.adapter.remove(path);
|
|
} catch (e) { console.warn("[Stashpad] couldn't delete trash file", path, e); unpurged.push(path); }
|
|
}
|
|
return { blobPath, fileCount: files.length, unpurged };
|
|
}
|
|
|
|
/** Restore a kind:"rawtrash" blob: decrypt + unzip back into `.trash/`, then
|
|
* remove the blob + sidecar. Existing files are never overwritten (suffix). */
|
|
export async function restoreRawTrash(app: App, blobPath: string, dek: Uint8Array): Promise<{ filesWritten: number }> {
|
|
const blob = new Uint8Array(await app.vault.adapter.readBinary(blobPath));
|
|
if (!isEncryptedStash(blob)) throw new Error("Not an encrypted bundle.");
|
|
const zipBytes = await decryptWithKey(blob, dek);
|
|
const zip = await unzipFiles(zipBytes);
|
|
let written = 0;
|
|
for (const [name, entry] of Object.entries(zip)) {
|
|
if (!name.startsWith("files/")) continue;
|
|
const rel = safeTrashRelPath(name.slice("files/".length));
|
|
if (!rel) { console.warn("[Stashpad] skipped unsafe trash entry", name); continue; }
|
|
const dir = `${OBSIDIAN_TRASH_DIR}/${rel}`.split("/").slice(0, -1).join("/");
|
|
let cur = "";
|
|
for (const seg of dir.split("/")) {
|
|
cur = cur ? `${cur}/${seg}` : seg;
|
|
if (!(await app.vault.adapter.exists(cur))) await app.vault.adapter.mkdir(cur);
|
|
}
|
|
let dest = `${OBSIDIAN_TRASH_DIR}/${rel}`;
|
|
for (let n = 1; await app.vault.adapter.exists(dest); n++) {
|
|
dest = `${OBSIDIAN_TRASH_DIR}/${rel.replace(/(\.[^./]*)?$/, ` (${n})$1`)}`;
|
|
}
|
|
await app.vault.adapter.writeBinary(dest, entry.buffer as ArrayBuffer);
|
|
written++;
|
|
}
|
|
await app.vault.adapter.remove(blobPath);
|
|
try { await app.vault.adapter.remove(sidecarPath(blobPath)); } catch { /* may not exist */ }
|
|
return { filesWritten: written };
|
|
}
|
|
|
|
// ---- Phase B: key rotation (re-encrypt a folder's blobs old DEK → new DEK) ----
|
|
|
|
/** Re-encrypt ONE live `.stashenc` blob from `oldDek` to `newDek`, writing the
|
|
* result to `<blob>.rot` (verified on disk). The ORIGINAL is left untouched — the
|
|
* caller commits all `.rot` files only after every blob in the folder succeeds, so
|
|
* a crash mid-rotation never strands a note under a key nobody holds. Throws on a
|
|
* wrong old key (decrypt fails) or any self-check failure. The live-lock `.stashmeta`
|
|
* sidecar is PLAINTEXT (no key-encrypted fields), so it needs no re-encryption. */
|
|
export async function writeRotatedBlob(app: App, blobPath: string, oldDek: Uint8Array, newDek: Uint8Array): Promise<void> {
|
|
const bytes = new Uint8Array(await app.vault.adapter.readBinary(blobPath));
|
|
if (!isEncryptedStash(bytes)) throw new Error(`Not an encrypted blob: ${blobPath}`);
|
|
const plain = await decryptWithKey(bytes, oldDek); // throws on wrong key / tampering
|
|
try {
|
|
const reblob = await encryptWithKey(plain, newDek);
|
|
const back = await decryptWithKey(reblob, newDek); // round-trips under the NEW key?
|
|
if (back.length !== plain.length) throw new Error("Rotation self-check failed (size).");
|
|
for (let i = 0; i < plain.length; i++) { if (back[i] !== plain[i]) throw new Error("Rotation self-check failed (content)."); }
|
|
await writeBlobVerified(app, `${blobPath}.rot`, reblob);
|
|
} finally { plain.fill(0); }
|
|
}
|
|
|
|
/** During a folder-key rotation, re-wrap a deleted blob's sidecar field that is
|
|
* itself encrypted with the folder DEK (`originalFolderEnc`, hidden-titles deletes)
|
|
* old → new. No-op if absent. Run AFTER the blob's `.rot` is committed. */
|
|
export async function rewrapDeletedSidecar(app: App, blobPath: string, oldDek: Uint8Array, newDek: Uint8Array): Promise<void> {
|
|
const meta = await readDeletedMeta(app, blobPath);
|
|
if (!meta?.originalFolderEnc) return;
|
|
const plain = await decryptWithKey(b64ToBytes(meta.originalFolderEnc), oldDek);
|
|
try { meta.originalFolderEnc = bytesToB64(await encryptWithKey(plain, newDek)); }
|
|
finally { plain.fill(0); }
|
|
await app.vault.adapter.write(sidecarPath(blobPath), JSON.stringify(meta));
|
|
}
|
|
|
|
/** Trash blob paths in `_deleted/` whose sidecar `keyId` matches `keyId`. */
|
|
export async function deletedBlobsForKeyId(app: App, keyId: string): Promise<string[]> {
|
|
const out: string[] = [];
|
|
for (const b of await listDeletedBlobs(app)) {
|
|
const m = await readDeletedMeta(app, b);
|
|
if (m?.keyId === keyId) out.push(b);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Commit a re-encrypted blob: replace the original with its verified `.rot`, then
|
|
* remove the temp. No-op if the `.rot` is missing (already committed). */
|
|
export async function commitRotatedBlob(app: App, blobPath: string): Promise<void> {
|
|
const rot = `${blobPath}.rot`;
|
|
if (!(await app.vault.adapter.exists(rot))) return;
|
|
const bytes = new Uint8Array(await app.vault.adapter.readBinary(rot));
|
|
await writeBlobVerified(app, blobPath, bytes); // replace original (verified)
|
|
try { await app.vault.adapter.remove(rot); } catch { /* leave temp; original is good */ }
|
|
}
|
|
|
|
/** Remove any leftover `.rot` temp files under a folder SUBTREE (cleanup after an
|
|
* aborted rotation — originals are intact, so the temps are just orphans).
|
|
* Recursive: subfolders inherit the parent key, so their blobs rotate too. */
|
|
export async function cleanupRotTemps(app: App, folder: string): Promise<void> {
|
|
const cleaned = folder.replace(/\/+$/, "");
|
|
for (const f of await listFilesRecursive(app, cleaned)) {
|
|
if (f.endsWith(`.${STASHENC_EXT}.rot`)) { try { await app.vault.adapter.remove(f); } catch { /* */ } }
|
|
}
|
|
}
|
|
|
|
/** The original blob paths that have a pending `.rot` temp anywhere under a folder
|
|
* SUBTREE — read from the ADAPTER (authoritative), not the in-memory file index, so
|
|
* rotation resume works during onload before the index is populated. */
|
|
export async function listRotTemps(app: App, folder: string): Promise<string[]> {
|
|
const cleaned = folder.replace(/\/+$/, "");
|
|
return (await listFilesRecursive(app, cleaned))
|
|
.filter((f) => f.endsWith(`.${STASHENC_EXT}.rot`))
|
|
.map((f) => f.replace(/\.rot$/, ""));
|
|
}
|
|
|
|
// ---- Generic (non-Stashpad) folder encryption: bundle a whole folder ----
|
|
|
|
/** Encrypt an ARBITRARY folder (any file types, not Stashpad notes) into a single
|
|
* `.stashenc` bundle living inside the folder, then delete the originals. Reuses the
|
|
* raw-zip path (like the trash backfill). `keyId` is the folder key it's encrypted
|
|
* under (stamped in the sidecar so decrypt resolves the right key). */
|
|
export async function lockRawFolder(app: App, folder: string, dek: Uint8Array, keyId: string | undefined, stamp: string): Promise<{ blobPath: string; fileCount: number; unpurged: string[] }> {
|
|
const cleaned = folder.replace(/\/+$/, "");
|
|
const files = (await listFilesRecursive(app, cleaned)).filter((f) => !f.endsWith(`.${STASHENC_EXT}`) && !f.endsWith(`.${STASHMETA_EXT}`));
|
|
if (files.length === 0) throw new Error("This folder has no files to encrypt.");
|
|
const entries: { name: string; data: ArrayBuffer }[] = [];
|
|
const mtimes = new Map<string, number>();
|
|
for (const p of files) {
|
|
entries.push({ name: `files/${p.slice(cleaned.length + 1)}`, data: await app.vault.adapter.readBinary(p) });
|
|
try { const st = await app.vault.adapter.stat(p); if (st) mtimes.set(p, st.mtime); } catch { /* no baseline */ }
|
|
}
|
|
const zipBytes = await zipFiles(entries);
|
|
const blob = await encryptWithKey(zipBytes, dek);
|
|
const back = await decryptWithKey(blob, dek);
|
|
if (back.length !== zipBytes.length) throw new Error("Encryption self-check failed (size).");
|
|
for (let i = 0; i < zipBytes.length; i++) if (back[i] !== zipBytes[i]) throw new Error("Encryption self-check failed (content).");
|
|
const base = safeBlobBase(cleaned.split("/").pop() || "folder");
|
|
let blobPath = `${cleaned}/${base}.${STASHENC_EXT}`;
|
|
for (let n = 1; await app.vault.adapter.exists(blobPath); n++) blobPath = `${cleaned}/${base} (${n}).${STASHENC_EXT}`;
|
|
await writeBlobVerified(app, blobPath, blob);
|
|
const meta: DeletedMeta = { v: 1, kind: "rawfolder", originalFolder: cleaned, parentId: null, title: cleaned.split("/").pop() || "", count: files.length, created: stamp, rootId: "", deletedAt: stamp, ...(keyId ? { keyId } : {}) };
|
|
try { await app.vault.adapter.write(sidecarPath(blobPath), JSON.stringify(meta)); } catch (e) { console.warn("[Stashpad] couldn't write rawfolder sidecar", e); }
|
|
const unpurged: string[] = [];
|
|
for (const p of files) {
|
|
const baseline = mtimes.get(p);
|
|
try {
|
|
if (baseline != null) { const st = await app.vault.adapter.stat(p); if (st && st.mtime !== baseline) { unpurged.push(p); continue; } }
|
|
await app.vault.adapter.remove(p);
|
|
} catch (e) { console.warn("[Stashpad] couldn't delete file", p, e); unpurged.push(p); }
|
|
}
|
|
// Gap 5: prune subdirectories emptied by the deletion (deepest first) so the folder
|
|
// isn't left full of hollow dirs. Never touch `cleaned` itself (it holds the blob).
|
|
if (unpurged.length === 0) {
|
|
const subdirs = [...new Set(files.map((p) => p.slice(0, p.lastIndexOf("/"))))]
|
|
.filter((d) => d.length > cleaned.length)
|
|
.sort((a, b) => b.length - a.length);
|
|
for (const d of subdirs) {
|
|
// rmdir's 2nd arg must be `true` even for an empty dir — with `false` the adapter
|
|
// calls file-`rm` and fails EISDIR. We've already confirmed it's empty, so recursing
|
|
// removes nothing extra.
|
|
try { const l = await app.vault.adapter.list(d); if (!l.files.length && !l.folders.length) await app.vault.adapter.rmdir(d, true); } catch { /* keep non-empty/locked dirs */ }
|
|
}
|
|
}
|
|
return { blobPath, fileCount: files.length, unpurged };
|
|
}
|
|
|
|
/** Decrypt a kind:"rawfolder" bundle back into its folder, then remove the blob +
|
|
* sidecar. Existing files are never overwritten (suffix). */
|
|
export async function unlockRawFolder(app: App, blobPath: string, dek: Uint8Array): Promise<{ filesWritten: number; folder: string }> {
|
|
const blob = new Uint8Array(await app.vault.adapter.readBinary(blobPath));
|
|
if (!isEncryptedStash(blob)) throw new Error("Not an encrypted bundle.");
|
|
const meta = await readDeletedMeta(app, blobPath);
|
|
const folder = safeVaultFolder(meta?.originalFolder) ?? blobPath.replace(/\/[^/]*$/, "");
|
|
const zipBytes = await decryptWithKey(blob, dek);
|
|
const zip = await unzipFiles(zipBytes);
|
|
let written = 0;
|
|
for (const [name, entry] of Object.entries(zip)) {
|
|
if (!name.startsWith("files/")) continue;
|
|
const rel = safeTrashRelPath(name.slice("files/".length));
|
|
if (!rel) { console.warn("[Stashpad] skipped unsafe folder entry", name); continue; }
|
|
const dir = `${folder}/${rel}`.split("/").slice(0, -1).join("/");
|
|
let cur = "";
|
|
for (const seg of dir.split("/")) { cur = cur ? `${cur}/${seg}` : seg; if (cur && !(await app.vault.adapter.exists(cur))) await app.vault.adapter.mkdir(cur); }
|
|
let dest = `${folder}/${rel}`;
|
|
for (let n = 1; await app.vault.adapter.exists(dest); n++) dest = `${folder}/${rel.replace(/(\.[^./]*)?$/, ` (${n})$1`)}`;
|
|
await app.vault.adapter.writeBinary(dest, entry.buffer as ArrayBuffer);
|
|
written++;
|
|
}
|
|
await app.vault.adapter.remove(blobPath);
|
|
try { await app.vault.adapter.remove(sidecarPath(blobPath)); } catch { /* */ }
|
|
return { filesWritten: written, folder };
|
|
}
|
|
|
|
/** The kind:"rawfolder" blob in a folder (the bundle from lockRawFolder), or null. */
|
|
export async function rawFolderBlobIn(app: App, folder: string): Promise<string | null> {
|
|
const cleaned = folder.replace(/\/+$/, "");
|
|
try {
|
|
const listing = await app.vault.adapter.list(cleaned);
|
|
for (const f of listing.files) {
|
|
if (!f.endsWith(`.${STASHENC_EXT}`)) continue;
|
|
const m = await readDeletedMeta(app, f);
|
|
if (m?.kind === "rawfolder") return f;
|
|
}
|
|
} catch { /* no folder */ }
|
|
return null;
|
|
}
|
|
|
|
/** Every kind:"rawfolder" bundle in the vault, as {folder, blobPath}. Scans the
|
|
* `.stashenc` files Obsidian has indexed and keeps those whose sidecar is rawfolder.
|
|
* Used by the command-palette "Decrypt a folder bundle…" picker (gap 4). */
|
|
export async function listRawFolderBlobs(app: App): Promise<{ folder: string; blobPath: string }[]> {
|
|
const out: { folder: string; blobPath: string }[] = [];
|
|
for (const f of app.vault.getFiles()) {
|
|
if (f.extension !== STASHENC_EXT) continue;
|
|
try { const m = await readDeletedMeta(app, f.path); if (m?.kind === "rawfolder") out.push({ folder: f.parent?.path?.replace(/\/+$/, "") ?? "", blobPath: f.path }); } catch { /* skip */ }
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** All encrypted-deleted blob paths in `_deleted/`. */
|
|
export async function listDeletedBlobs(app: App): Promise<string[]> {
|
|
if (!(await app.vault.adapter.exists(DELETED_DIR))) return [];
|
|
try {
|
|
const listing = await app.vault.adapter.list(DELETED_DIR);
|
|
return listing.files.filter((f) => f.endsWith(`.${STASHENC_EXT}`));
|
|
} catch { return []; }
|
|
}
|