diff --git a/CLAUDE.md b/CLAUDE.md
index e63e7a7..28282dd 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -128,6 +128,8 @@ These are the non-obvious constraints; the rest of the architecture is in `docs/
- **A file dropped on the window is a native event, not a DOM one.** The WebView never sees the file, only Rust does, so `FileDropService` wraps `onDragDropEvent`; an HTML `drop` handler would receive nothing. A pasted image is the same idea from the other end: the editor's `paste` reads only the **type**, and `attach_clipboard_image` re-reads the system clipboard natively and encodes the PNG — the bytes never cross the bridge.
- **Deleting a note doesn't delete it.** `notes::store::trash` stamps `notes.deleted_at` — the name is deliberate, `spaces::store::delete` and `attachments::store::delete` erase; `notes::trash::RETENTION` (30 days) decides when it really goes. Every read has to filter on `deleted_at IS NULL` — `fetch`, `find`, both facet queries, the tag counts — or a trashed note comes back editable without saying it's on borrowed time. `purge` is restricted to `deleted_at IS NOT NULL` so nothing short-circuits the reprieve, and `purgeAt` is derived rather than stored (the retention can change between versions). The startup sweep in `lib.rs` and the purge inside `list_trash` are what make the retention hold even if nobody opens the panel.
- **A `{{field}}` is decided in `notes::placeholder`, and only there.** The name is restricted to `[A-Za-z0-9_-]` on purpose: without it a note holding Angular template code (`{{ user.name }}`) would demand a form on every copy. `fill` leaves an unrecognised token untouched — it's part of the text, not of the form — and an empty value falls back to the snippet's default. **The values are kept**, in `note_placeholders` (case-sensitive key, unlike `note_tags`) and written only by `set_placeholder_values`; the _text_ still decides which fields exist, so a value whose token was renamed stays stored but out of sight. The editor's fold-away panel (`placeholder-panel/`), the card's ⚡ and the palette all edit that same single set, through the same rows (`placeholder-fields/`) — an empty input is a suggestion shown as its placeholder, never a typed value, or the day the snippet's default changes the stored copy would win.
+- **⚠️ The library is encrypted, and nothing answers until it is unlocked.** `Db = Mutex>` — the connection _and_ the key — is empty until `unlock_vault` fills it, and a command that runs first answers `StorageError::Locked`. The front end gates on `vault_state` in `app.component.html`: the outlet is not hidden while locked, it is **never created**, which is what keeps every store free of a "locked" branch. The titlebar renders in front of the gate, so the File menu is gated too — it injects `SpacesStore`, and creating it queried a database nobody had opened. Sealed: titles, bodies, sources, item texts, space names, `{{field}}` values, attachment names and attachment bytes. Not sealed, deliberately: tags, instants, ids, `kind`, `language`, foreign keys — what SQL filters, sorts and joins on. `a_note_is_not_readable_in_the_file_it_was_written_to` (`tests/notes.rs`) holds that split against the raw file. Argon2id + AES-256-GCM, pure Rust crates and no `build.rs`: **do not** reach for SQLCipher, it was measured (4× slower) and refused (vendored OpenSSL in CI). Details in `docs/architecture.md` → "Encryption at rest".
+- **A passphrase is never held, and never logged.** It arrives owned from the IPC payload and is `zeroize`d before the command returns; the key is a `Zeroizing<[u8; 32]>` whose `Debug` prints `Vault(…)`. ⚠️ The phrase **wraps** the library's key rather than deriving it (`vault.json` holds it sealed, and opening it is the only check there is), which is what makes `change_passphrase` a hundred bytes of rewriting instead of re-encrypting the corpus — and what means a changed phrase answers a leaked phrase, never a leaked key. An export gets a key of its own (`transfer/protect.rs`) rather than the library's, and an unprotected export is still plaintext on purpose — the interface's job is to ask which, and to say which it wrote. ⚠️ `open_attachment` writes a decrypted copy under `app_data_dir()/open/` (the profile, never the shared OS temporary directory), swept at `RunEvent::Exit` and at every launch: the program that opens a document reads it from disk, and one click is the requirement.
- **Attachment bytes are not in the database.** The `attachments` table holds a record; the file lives in `app_data_dir()/attachments/` under a name derived from the record id (`model::stored_name`) — two `capture.png` must not overwrite each other, and a name from outside has no business deciding a write path. They cross the bridge as `data:` URIs one at a time (the CSP forbids a local file, and a URI costs a third more than the file). Write order is load-bearing: copy the file, then insert; a purge collects the file names **before** the `DELETE`, since the cascade takes the records with it.
- **`updated_at` is not touched by what the user didn't aim at a note.** Deleting a space, a global retag, restoring from the trash, filling a `{{field}}`: none of the four refreshes it. The canvas sorts on that column and would float notes nobody reopened to the top — which is also why the field values have a command of their own (`set_placeholder_values`) rather than a `NotePatch` field, the patch path existing precisely to refresh that column.
- **A rename onto an existing tag is a merge**, because the primary key `(note_id, tag)` is `NOCASE`. In `notes::store::retag`, the target is swept along with the sources and rewritten — `INSERT OR IGNORE` alone would make a pure case correction (`auth` → `Auth`) a no-op.
diff --git a/README.md b/README.md
index c81c070..d322f3a 100644
--- a/README.md
+++ b/README.md
@@ -12,27 +12,58 @@ palette, where `Enter` copies and the window steps aside:

-Everything stays on your machine, in a SQLite file you can copy. Nothing is uploaded, there
-is no account, and the application works with the network off.
+Everything stays on your machine, encrypted with a passphrase you choose and type once at
+launch. Nothing is uploaded, there is no account, and the application works with the network
+off.
## What it does
-| Feature | What it gives you |
-| -------------------- | ------------------------------------------------------------------------------------------------- |
-| **Quick paste** | `Ctrl+Alt+P` from any application: search a snippet, `Enter` copies it and the window steps aside |
-| **`{{fields}}`** | `psql -h {{host}} -p {{port=5432}}` asks for its values before landing in the clipboard |
-| **Keyboard canvas** | arrows to move, `Enter` to open, `C` to copy, `P` to pin, `X` to select, `Del` to trash |
-| **Todo lists** | a second kind of note: an ordered, tickable list instead of a body |
-| **Trash** | deleting is undoable, and reversible for 30 days |
-| **Bulk actions** | select several notes, then move, tag, export or trash them in one go |
-| **Tag management** | rename, merge or drop a tag across the whole library |
-| **Attachments** | drop a file on the editor or paste an image; open it, save it elsewhere, preview it inline |
-| **Import / export** | a JSON bundle both ways — everything, one space, or the selection — with a report either way |
-| **Copy as Markdown** | the selection rendered for a pull request, a ticket or a chat message |
+| Feature | What it gives you |
+| --------------------- | ------------------------------------------------------------------------------------------------- |
+| **Quick paste** | `Ctrl+Alt+P` from any application: search a snippet, `Enter` copies it and the window steps aside |
+| **`{{fields}}`** | `psql -h {{host}} -p {{port=5432}}` asks for its values before landing in the clipboard |
+| **Keyboard canvas** | arrows to move, `Enter` to open, `C` to copy, `P` to pin, `X` to select, `Del` to trash |
+| **Todo lists** | a second kind of note: an ordered, tickable list instead of a body |
+| **Trash** | deleting is undoable, and reversible for 30 days |
+| **Bulk actions** | select several notes, then move, tag, export or trash them in one go |
+| **Tag management** | rename, merge or drop a tag across the whole library |
+| **Attachments** | drop a file on the editor or paste an image; open it, save it elsewhere, preview it inline |
+| **Import / export** | a `.devbox` archive both ways, attachments included — everything, one space, or the selection |
+| **Copy as Markdown** | the selection rendered for a pull request, a ticket or a chat message |
+| **Encrypted at rest** | one passphrase at launch; notes and attachments sealed on disk, exports optionally too |
Syntax highlighting covers eighteen languages, the interface is available in French and
English, and it ships with a light and a dark theme.
+## Your library is encrypted
+
+DevBox asks for a passphrase the first time it runs, and once at every launch after that.
+It is what opens the library, and it is never stored anywhere — not in a keychain, not
+behind a "remember me". While the application runs the key lives in memory and nowhere
+else.
+
+What is sealed on disk: note titles, bodies and sources, checklist items, space names,
+`{{field}}` values, attachment file names, and the attachment files themselves.
+
+| What | How |
+| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **Key derivation** | Argon2id, 64 MiB and 3 passes, over a random salt kept beside the database. The passphrase seals the library key rather than being it, so you can change it from Preferences → Security without re-encrypting anything |
+| **Encryption** | AES-256-GCM, a fresh nonce per write; the authentication tag refuses a tampered value rather than decrypting it into nonsense |
+| **Not sealed** | tags, dates, ids and the links between rows — what the database filters, sorts and joins on. Sealing them would mean loading the whole library to answer a query, and tag names are the visible cost of that trade |
+
+⚠️ **There is no recovery.** No account, no escrow, no reset: a lost passphrase is a lost
+library. An export written in the clear is the only copy that does not depend on it.
+
+⚠️ **Opening an attachment** writes a decrypted copy — inside your own profile, never the
+shared temporary folder — because the program that opens it reads from disk. DevBox deletes
+those copies when it quits, and sweeps whatever survived — a file another application still
+held, a crash — at the next launch.
+
+An export is the one file meant to leave the machine, so it is offered a key of its own:
+give it a passphrase and it travels sealed, attachments included, or write it in the clear
+for a file any DevBox can read. The application asks which, every time, and says which one
+it wrote.
+
## Install
DevBox runs on **Windows and Linux**. There is no macOS build: it cannot be tested here,
diff --git a/docs/architecture.md b/docs/architecture.md
index 096eb69..8c8cc71 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -1139,15 +1139,51 @@ about samples nobody asked for would only add noise.
### Import, export and copying out
-- **Export** writes a JSON bundle (`transfer::model::Bundle`: a version, an instant, the
- spaces cited and the notes), for everything, one space, or the current selection. The format
- reuses the domain types rather than duplicating them, so a field added to `Note` is exported
- without anyone thinking about it. Only the spaces actually cited travel: exporting one space
- should not recreate a whole tree on the other side.
+- **Export writes an archive**, `.devbox`, which is a zip: `bundle.json` at the root
+ (`transfer::model::Bundle` — a version, an instant, the spaces cited, the notes and the
+ attachment records) and one entry per attachment under `attachments/`, named by its
+ `stored_name`. The bundle is deflated, being repetitive text; the attachments are stored as
+ they are, a PNG being compressed already. The format reuses the domain types rather than
+ duplicating them, so a field added to `Note` is exported without anyone thinking about it.
+ Only the spaces actually cited travel: exporting one space should not recreate a whole tree
+ on the other side.
+- **⚠️ An export can be sealed, and with a key of its own.** The library key is derived
+ from the passphrase typed at launch and never leaves the machine; an export is the one
+ file meant to reach another one, so `transfer/protect.rs` derives a second key from a
+ phrase the user gives that file, and writes the recipe (version, algorithm, cost, salt)
+ in the clear beside the payload — a salt is not a secret, and a reader has to know how
+ to derive before it can ask anything else. The sealed `bundle.sealed` **replaces**
+ `bundle.json` rather than sitting beside it, or a reader that could not open it would
+ quietly fall back on a bundle in the clear. Attachments are opened under the library key
+ and resealed under the export’s, so the file is openable by whoever was given the
+ phrase and by nobody else.
+- **⚠️ An unprotected export is still written, and is still plaintext.** Refusing one would
+ break the portability the exchange format exists for. What the interface owes the user
+ instead is to ask which of the two it is about to write (`PassphrasePromptComponent`,
+ with the warning beside the button) and to say which one it wrote — `ExportReport.protected`
+ picks the message. On the way in, `export_is_protected` is what lets the prompt appear
+ before the import starts rather than as a failure after it; a refused phrase asks again
+ rather than failing, since it is the ordinary answer to a typo.
+- **⚠️ Base64 inside the JSON was the obvious alternative and was refused.** It costs a third
+ more bytes, and the import path holds the file as a `String`, then a `serde_json::Value`,
+ then a `Bundle` — three copies of every screenshot in memory, which a library of a hundred
+ captures turns into a gigabyte. `file::Payload` hands entries over one at a time instead.
+- **⚠️ A new DevBox reads an old file; an old DevBox does not read a new one.** `file::read`
+ sniffs the zip magic and falls back to parsing the whole file as JSON, so every `.json`
+ export written before the archive still imports. The picker keeps `json` among its
+ extensions on the way in for exactly that reason, and offers only `devbox` on the way out.
+ `FORMAT_VERSION` is untouched: the container changed, the data shape did not.
- **Import merges, it never replaces.** Spaces are matched by name, case-insensitively, and a
note whose id is already taken is counted as skipped rather than overwritten — so the same
file can be imported twice without duplicating anything. A bundle from a newer format
version is refused outright rather than half-read.
+- **An attachment comes back only with a note that actually arrived.** One belonging to a
+ skipped note is already in the library. ⚠️ The file is written **before** the record, the
+ rule `attachments.rs` already holds — a record without a file is a broken thumbnail, where
+ a file without a record is swept at the next startup, which is also what collects these if
+ the transaction rolls back. A record the archive names but does not carry is counted in
+ `attachments_missing` rather than swallowed: the note arrives with a preview that will stay
+ empty, and the report is the only thing that explains it.
- **Copying out stops at the clipboard.** `share_notes` renders the selection as Markdown
(heading, space, context, tags, then a fenced block). The fence is longer than the longest
run of backticks in the content, otherwise a note that already contains a Markdown block
@@ -1574,6 +1610,124 @@ whitespace, so `"Personal "` would otherwise sit beside `"Personal"`, identical
`NotesQuery.now` as a parseable instant — falling back to the server clock would silently
re-cut every section on a different day.
+## Encryption at rest
+
+The library is sealed with a key derived from a passphrase typed once per launch. Nothing
+is kept of it: no keychain, no "remember me", no recovery — the semantics of a KeePass file,
+and the same consequence.
+
+### Why not SQLCipher
+
+Measured rather than assumed. SQLCipher encrypts every 4 KiB page with AES-256-CBC and
+authenticates it with HMAC-SHA512; on the 8000-note benchmark corpus that costs ~112 ms on
+`query_notes`, 88% of it in the HMAC. It also has to be built — the crate wants a vendored
+OpenSSL, which means a C toolchain in CI on every platform, for every build.
+
+Sealing values instead costs ~28 ms on the same corpus, four times less, because it seals
+what a reader would want rather than every byte the file system moves. The crates are pure
+Rust (`aes-gcm`, `argon2`, `getrandom`, `zeroize`): no `build.rs`, no C, nothing added to a
+CI build. On a realistic library (~2 MB) the sealing costs well under a millisecond.
+
+### What is sealed, and what is not
+
+Sealed: note titles, bodies and sources, checklist item texts, space names, `{{field}}`
+values and their global defaults, attachment file names — and the attachment files
+themselves, bytes and all.
+
+Not sealed, deliberately: tags, instants, ids, `kind`, `language` and the foreign keys.
+They are what SQL filters, sorts, groups and joins on, and sealing them would move every
+query into Rust over the whole corpus. ⚠️ Tag names are the visible cost of that line, and
+the one thing a reader of the raw file learns.
+`a_note_is_not_readable_in_the_file_it_was_written_to` (`tests/notes.rs`) greps a freshly
+written database and asserts exactly that split — the only test here that reads the file
+rather than the API.
+
+### What this protects against, and what it does not
+
+The threat is a file read at rest: a stolen laptop, a copied profile directory, a backup
+that ended up somewhere it should not have. Against that, a value is unreadable and a
+tampered one is refused rather than decrypted into nonsense.
+
+⚠️ It is **not** a defence against someone who can write to the file while you are away.
+A sealed value is authenticated on its own and not bound to the row it sits in — no
+associated data — so a value could be moved from one row to another and its tag would
+still verify. Binding it would mean threading the row identity through every seal and open
+call in the stores; it buys nothing against the threat above, where the attacker reads the
+file rather than edits it and hands it back.
+
+⚠️ Nor is it a defence against a machine already compromised while DevBox runs: the key is
+in this process’s memory for the length of the session, and there is no idle re-lock.
+
+### The pieces
+
+- **`vault/key.rs`** — `Vault`: the key, and the two operations. Argon2id (`Cost`: 64 MiB,
+ 3 passes, 1 lane — 1.16 s on the development machine, which is the point of it) and
+ AES-256-GCM with a fresh 96-bit nonce per write, laid out `nonce || ciphertext || tag`,
+ base64 for a TEXT column and raw bytes for a file. The key is a `Zeroizing<[u8; 32]>` and
+ the hand-written `Debug` prints `Vault(…)`, so it cannot reach a log line.
+- **`vault/file.rs`** — `vault.json` beside the database: format version, KDF parameters,
+ salt, and **the library's key sealed under the phrase**. ⚠️ The phrase does not derive the
+ key the notes are sealed with, it wraps it: the key is random, and a new phrase re-wraps
+ the same one. That is what makes a passphrase change a hundred bytes of rewriting rather
+ than re-encrypting every note and every attachment — an operation that could not be
+ atomic across the database and the files, and would leave a half-readable library if it
+ stopped halfway. Opening the wrapped key is also the check: a phrase that fails to open
+ it is the wrong phrase, said by the authentication tag, so there is no separate check
+ value to attack. Written staged-then-renamed, and `create` refuses to overwrite one.
+
+ ⚠️ The other side of that coin, and the reason it is worth writing down: changing the
+ passphrase answers a phrase somebody **learned**, never a key somebody **took**. Whoever
+ got hold of the unwrapped key keeps it, exactly as with LUKS or KeePass.
+
+- **`vault/migrate.rs`** — `seal_existing`, one transaction that seals a library written
+ before any of this. ⚠️ It runs from `create_vault` **after** the library is open and
+ **before** the startup sweeps: the orphan-attachment sweep reads stored file names and
+ would meet them in the clear if it ran first.
+- **`db.rs`** — `Library` carries the `SqliteConnection` **and** the `Vault`, and derefs to
+ the connection so the store functions did not have to grow an argument. `split()` hands
+ the two fields over separately where the borrow checker needs both at once, and
+ `Db = Mutex >` is empty until `unlock_vault` fills it — a command that
+ runs before the unlock answers `StorageError::Locked` rather than reading a database
+ nobody opened.
+
+### The gate
+
+`vault_state` answers `absent` / `locked` / `unlocked`; `app.config.ts` awaits it before the
+first render and `app.component.html` puts `VaultGateComponent` in front of the outlet. ⚠️ It
+does not hide the outlet, it never creates it — which is what keeps every store free of a
+"locked" branch: the canvas queries notes the moment it mounts, and nothing would be there
+to answer.
+
+⚠️ The unlocked state lives in Rust and not in the front end: a page reload must not ask
+again for a library this process already has open. That is also what keeps `reopenSession()`
+working in the end-to-end suite, where the front end reboots and the process does not.
+
+### Changing the passphrase
+
+`change_passphrase` (from Préférences → Sécurité) unlocks with the current phrase, then
+rewrites the key file with a fresh salt and the same library key wrapped under the new
+one. ⚠️ It refuses before writing anything when the current phrase is wrong — a change
+that took it on trust would lock the library behind a phrase nobody chose. The open
+session is untouched: the key in memory is the one that was already there.
+
+⚠️ The command asks for the connection only to check that the library is open, then
+releases it: the two derivations cost about a second each, and holding the mutex across
+them would freeze every other command.
+
+### Attachments, and the one plaintext copy
+
+The bytes are sealed on the way in (`attachments::copy_within_limit`) and opened in memory
+on the way out: `read_attachment` decrypts into the `data:` URI the preview already used,
+and `save_attachment` writes plaintext where the user chose to put it.
+
+⚠️ `open_attachment` is the exception, and a deliberate one: the program that opens a
+document reads it from disk, so DevBox writes a decrypted copy under `app_data_dir()/open/`
+and opens that — one click, as before. Those copies are swept on the way out
+(`RunEvent::Exit`) and again at every launch, which is what covers one another application
+still held, and a crash. ⚠️ The profile and **not** the OS temporary directory: that one is
+shared with every account on the machine, where the copy would be readable by all of them
+and a directory somebody else created first would be theirs rather than ours.
+
## Persistence (Rust)
Storage is **SQLite**, queried through **Diesel** and embedded via `libsqlite3-sys` with the
@@ -1593,7 +1747,8 @@ installed or shipped alongside the executable. The database file lives in Tauri'
the migration SQL. Diesel obeys those; it does not own them.
- **Concurrency.** A `SqliteConnection` is not `Sync`, and Diesel takes it exclusively for
every query, reads included. A single connection is shared as `tauri::State`
- (`Db = Mutex`), registered with `.manage()` in `lib.rs` — never a global.
+ (`Db = Mutex>`, the connection and the key together), registered with
+ `.manage()` in `lib.rs` — never a global, and empty until the library is unlocked.
Overlapping commands serialize on that mutex, and each command holds `db::lock` for its
whole body, so a check and the write that depends on it cannot be interleaved.
- **⚠️ Commands that touch the database or the disk are `#[tauri::command(async)]`.** A plain
diff --git a/e2e/specs/01-first-launch.e2e.ts b/e2e/specs/01-first-launch.e2e.ts
index fe1ef26..82262db 100644
--- a/e2e/specs/01-first-launch.e2e.ts
+++ b/e2e/specs/01-first-launch.e2e.ts
@@ -2,6 +2,7 @@ import { browser, expect } from '@wdio/globals';
import { canvas } from '../pageobjects/canvas.page.js';
import { banners, fileMenu, titlebar } from '../pageobjects/titlebar.page.js';
+import { passTheGate } from '../support/app.js';
import { bridge, homeSpaceId, query } from '../support/bridge.js';
/**
@@ -19,6 +20,10 @@ describe('First launch', () => {
* would be its own witness.
*/
before(async () => {
+ // ⚠️ Before anything is asked of the library: this is the only file that meets the
+ // gate, and no command is answered — not even a read — until it has been passed.
+ await passTheGate();
+
await browser.waitUntil(async () => (await bridge.queryNotes(query())).matched > 0, {
timeout: 30_000,
timeoutMsg: 'the first launch seeded no note',
@@ -76,6 +81,9 @@ describe('First launch', () => {
it('boots without an error banner', async () => {
// NG0203, a missing capability and a failed migration all land here.
- expect(await banners.error().isExisting()).toBe(false);
+ const banner = banners.error();
+ const shown = (await banner.isExisting()) ? await banner.getText() : '';
+
+ expect(shown).toBe('');
});
});
diff --git a/e2e/specs/10-library-transfer.e2e.ts b/e2e/specs/10-library-transfer.e2e.ts
index e2eeeb2..5113a08 100644
--- a/e2e/specs/10-library-transfer.e2e.ts
+++ b/e2e/specs/10-library-transfer.e2e.ts
@@ -15,11 +15,18 @@ import { bridge, draft, homeSpaceId, query } from '../support/bridge.js';
* ⚠️ The OS file picker is not driven here (see `support/app.ts`): the commands take a
* path, and the path is where the real work happens.
*/
+/** The message rather than the throw: a refusal is what these two assertions are about. */
+async function failureOf(running: Promise): Promise {
+ return running.then(
+ () => 'it was not refused',
+ (error: Error) => error.message,
+ );
+}
describe('Import, export and share', () => {
const directory = mkdtempSync(join(tmpdir(), 'devbox-e2e-'));
/** ⚠️ Forward slashes: `\` is an escape on the wire and a separator on Windows. */
- const bundlePath = join(directory, 'library.json').replaceAll('\\', '/');
+ const bundlePath = join(directory, 'library.devbox').replaceAll('\\', '/');
/** Kept from `before`: the seeded space is named from a translation (see below). */
let homeId = '';
@@ -39,8 +46,11 @@ describe('Import, export and share', () => {
expect(written.spaces).toBe((await bridge.listSpaces()).length);
expect(existsSync(bundlePath)).toBe(true);
- const bundle = JSON.parse(readFileSync(bundlePath, 'utf8')) as { notes: { title: string }[] };
- expect(bundle.notes.map((note) => note.title)).toContain('Worth exporting');
+
+ // ⚠️ An archive, not JSON: the attachments travel as entries beside the bundle. What
+ // the archive holds is asserted in `tests/transfer.rs`, which can open one — reading
+ // a deflated entry from here would mean a zip reader in the harness for one check.
+ expect(readFileSync(bundlePath).subarray(0, 4)).toEqual(Buffer.from('PK\x03\x04', 'binary'));
});
it('imports nothing when every note is already there', async () => {
@@ -96,17 +106,22 @@ describe('Import, export and share', () => {
* arrives with that field brought down to the default rather than failing the file.
*/
it('imports a bundle from a newer version instead of refusing it whole', async () => {
- const source = JSON.parse(readFileSync(bundlePath, 'utf8')) as {
- notes: Record[];
- };
+ // ⚠️ Written as a bare `.json`, which is also the shape DevBox exported before the
+ // archive: this doubles as the proof that an old export still imports.
+ const exported = await bridge.queryNotes(query({ search: 'Worth exporting' }));
+ const source = exported.sections[0]?.notes[0];
+ expect(source).toBeDefined();
+
const newerPath = join(directory, 'newer.json').replaceAll('\\', '/');
writeFileSync(
newerPath,
JSON.stringify({
- ...source,
+ version: 1,
+ exportedAt: new Date().toISOString(),
+ spaces: await bridge.listSpaces(),
notes: [
{
- ...source.notes[0],
+ ...source,
id: 'written-by-a-newer-devbox',
title: 'Ahead of this build',
language: 'from-the-future',
@@ -124,6 +139,29 @@ describe('Import, export and share', () => {
expect(view.sections[0]?.notes[0]?.language).toBe('txt');
});
+ /**
+ * ⚠️ The export is the one file the library key does not protect: it is meant to reach
+ * another machine, so it carries a key of its own. Read from Node, against the bytes on
+ * disk rather than against what the application says about them.
+ */
+ it('seals an export with a phrase, and will not open it without that phrase', async () => {
+ const sealedPath = join(directory, 'sealed.devbox').replaceAll('\\', '/');
+
+ const written = await bridge.exportNotes(sealedPath, null, 'an export passphrase');
+ expect(written.protected).toBe(true);
+ expect(await bridge.exportIsProtected(sealedPath)).toBe(true);
+ expect(await bridge.exportIsProtected(bundlePath)).toBe(false);
+ expect(readFileSync(sealedPath).includes(Buffer.from('Worth exporting'))).toBe(false);
+
+ const refused = await failureOf(bridge.importNotes(sealedPath));
+ expect(refused).toContain('passphraseRequired');
+
+ const wrong = await failureOf(bridge.importNotes(sealedPath, 'not the phrase'));
+ expect(wrong).toContain('wrongPassphrase');
+
+ const report = await bridge.importNotes(sealedPath, 'an export passphrase');
+ expect(report.notesSkipped).toBeGreaterThan(0);
+ });
it('greys out the menu entries that have nothing to act on', async () => {
await fileMenu.open();
// The entry stays in the DOM and clickable — it carries `aria-disabled`, not `disabled`.
diff --git a/e2e/specs/16-passphrase-change.e2e.ts b/e2e/specs/16-passphrase-change.e2e.ts
new file mode 100644
index 0000000..c902ff6
--- /dev/null
+++ b/e2e/specs/16-passphrase-change.e2e.ts
@@ -0,0 +1,55 @@
+import { expect } from '@wdio/globals';
+import { readFileSync } from 'node:fs';
+
+import { canvas } from '../pageobjects/canvas.page.js';
+import { PASSPHRASE } from '../support/app.js';
+import { bridge, query } from '../support/bridge.js';
+import { vaultPath } from '../support/profile.js';
+
+/**
+ * ⚠️ Last on purpose: it leaves the profile behind a phrase nothing else in the run
+ * knows. Nothing after it unlocks — the process stays open for the whole suite — but a
+ * scenario inserted after this one would be the first to find out the hard way.
+ *
+ * What it proves is the shape of the change: the key file is rewritten, the notes are
+ * not, and the session carries on. Read from Node, against the file rather than against
+ * what the application says about it.
+ */
+describe('Changing the passphrase', () => {
+ const NEXT = 'a second end-to-end passphrase';
+
+ function keyFile(): { kdf: { salt: string }; key: string } {
+ return JSON.parse(readFileSync(vaultPath(), 'utf8')) as { kdf: { salt: string }; key: string };
+ }
+
+ before(canvas.open);
+
+ it('refuses a change that cannot name the current phrase, and rewrites nothing', async () => {
+ const before = keyFile();
+
+ const refused = await bridge.changePassphrase('not the current one', NEXT).then(
+ () => 'it was not refused',
+ (error: Error) => error.message,
+ );
+
+ expect(refused).toContain('wrongPassphrase');
+ expect(keyFile()).toEqual(before);
+ });
+
+ it('rewraps the key without touching a note', async () => {
+ const before = keyFile();
+ const corpus = (await bridge.queryNotes(query())).matched;
+ expect(corpus).toBeGreaterThan(0);
+
+ await bridge.changePassphrase(PASSPHRASE, NEXT);
+
+ const after = keyFile();
+ // A fresh salt and a fresh wrapping, so neither phrase says anything about the other.
+ expect(after.kdf.salt).not.toBe(before.kdf.salt);
+ expect(after.key).not.toBe(before.key);
+
+ // ⚠️ The library is still open on the same key: a change that re-encrypted would have
+ // had to stop and restart everything to prove as much.
+ expect((await bridge.queryNotes(query())).matched).toBe(corpus);
+ });
+});
diff --git a/e2e/support/app.ts b/e2e/support/app.ts
index 3a00ee8..86d1677 100644
--- a/e2e/support/app.ts
+++ b/e2e/support/app.ts
@@ -15,7 +15,39 @@ const SETTLE_INTERVAL = 200;
* The canvas is behind a lazy route, a `resource` and a debounce; every scenario waits
* for it rather than for the window, which exists long before anything is queryable.
*/
+/**
+ * ⚠️ The library is encrypted, so a run creates this on its first launch and carries it
+ * for the rest. There is nothing to remember between runs: `resetProfile` deletes the key
+ * file with everything else, so every run starts from a library that has none.
+ */
+export const PASSPHRASE = 'an end-to-end passphrase';
+
+/**
+ * Passes the unlock screen when it is there, and says nothing when it is not.
+ *
+ * ⚠️ Only `01-first-launch` ever meets it. The unlocked state lives in Rust and the
+ * process outlives every page reload — `before()` and `reopenSession` both rebuild the
+ * front end over a library that is already open — so a later file finds no gate at all.
+ */
+export async function passTheGate(): Promise {
+ const field = $(testid('vault-passphrase'));
+ if (!(await field.isExisting())) return;
+
+ await setField(testid('vault-passphrase'), PASSPHRASE);
+
+ // Two entries on a library that has never been encrypted, one on a locked one.
+ if (await $(testid('vault-confirmation')).isExisting()) {
+ await setField(testid('vault-confirmation'), PASSPHRASE);
+ }
+
+ await $(testid('vault-submit')).click();
+ // ⚠️ Generous: deriving the key is deliberately slow, and a first launch seals whatever
+ // was already there on top of it.
+ await field.waitForExist({ reverse: true, timeout: 60_000 });
+}
+
export async function waitForCanvas(): Promise {
+ await passTheGate();
await $(testid('canvas')).waitForExist({ timeout: 30_000 });
// ⚠️ `aria-busy` answers "has anything at all arrived yet", once, and then says nothing
diff --git a/e2e/support/bridge.ts b/e2e/support/bridge.ts
index 9bb2d92..0133ba5 100644
--- a/e2e/support/bridge.ts
+++ b/e2e/support/bridge.ts
@@ -33,7 +33,7 @@ async function invoke(command: string, args: Record = {}): P
tauri.core
.invoke(name, payload)
.then((value: unknown) => done({ ok: value }))
- .catch((error: unknown) => done({ err: String(error) }));
+ .catch((error: unknown) => done({ err: typeof error === 'string' ? error : JSON.stringify(error) }));
},
command,
args,
@@ -71,9 +71,13 @@ export const bridge = {
setGlobalPlaceholders: (values: Record) =>
invoke>('set_global_placeholders', { values }),
- exportNotes: (path: string, spaceId: string | null = null) =>
- invoke('export_notes', { path, spaceId }),
- importNotes: (path: string) => invoke('import_notes', { path }),
+ exportNotes: (path: string, spaceId: string | null = null, passphrase: string | null = null) =>
+ invoke('export_notes', { path, spaceId, passphrase }),
+ importNotes: (path: string, passphrase: string | null = null) =>
+ invoke('import_notes', { path, passphrase }),
+ exportIsProtected: (path: string) => invoke('export_is_protected', { path }),
+
+ changePassphrase: (current: string, next: string) => invoke('change_passphrase', { current, next }),
} as const;
/** A `NoteDraft` is exhaustive on the wire; a scenario cares about two or three fields. */
diff --git a/e2e/support/profile.ts b/e2e/support/profile.ts
index 38880a0..01f67e4 100644
--- a/e2e/support/profile.ts
+++ b/e2e/support/profile.ts
@@ -39,6 +39,11 @@ export function preferencesPath(): string {
return join(e2eDataDir(), 'preferences.json');
}
+/** The key file, beside the database — what a passphrase change rewrites. */
+export function vaultPath(): string {
+ return join(e2eDataDir(), 'vault.json');
+}
+
/**
* Where `homeSpaceId()` records the seeded space. Outside the profile on purpose: it is
* the harness's own note, not the application's state.
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index 7871ba1..56f8d64 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -14,6 +14,41 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+[[package]]
+name = "aead"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99"
+dependencies = [
+ "crypto-common 0.2.2",
+ "inout",
+]
+
+[[package]]
+name = "aes"
+version = "0.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58"
+dependencies = [
+ "cipher",
+ "cpubits",
+ "cpufeatures 0.3.1",
+]
+
+[[package]]
+name = "aes-gcm"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f"
+dependencies = [
+ "aead",
+ "aes",
+ "cipher",
+ "ctr",
+ "ctutils",
+ "ghash",
+]
+
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -121,6 +156,18 @@ dependencies = [
"x11rb",
]
+[[package]]
+name = "argon2"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c"
+dependencies = [
+ "base64ct",
+ "blake2",
+ "cpufeatures 0.3.1",
+ "password-hash",
+]
+
[[package]]
name = "async-broadcast"
version = "0.7.2"
@@ -368,6 +415,12 @@ version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
+[[package]]
+name = "base64ct"
+version = "1.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
+
[[package]]
name = "bit-set"
version = "0.8.0"
@@ -398,6 +451,15 @@ dependencies = [
"serde_core",
]
+[[package]]
+name = "blake2"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f"
+dependencies = [
+ "digest 0.11.3",
+]
+
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -407,6 +469,15 @@ dependencies = [
"generic-array",
]
+[[package]]
+name = "block-buffer"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
+dependencies = [
+ "hybrid-array",
+]
+
[[package]]
name = "block2"
version = "0.6.2"
@@ -659,6 +730,17 @@ dependencies = [
"half",
]
+[[package]]
+name = "cipher"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
+dependencies = [
+ "block-buffer 0.12.1",
+ "crypto-common 0.2.2",
+ "inout",
+]
+
[[package]]
name = "clap"
version = "4.6.7"
@@ -693,6 +775,12 @@ dependencies = [
"error-code",
]
+[[package]]
+name = "cmov"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
+
[[package]]
name = "combine"
version = "4.6.7"
@@ -772,6 +860,12 @@ dependencies = [
"libc",
]
+[[package]]
+name = "cpubits"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
+
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -781,6 +875,15 @@ dependencies = [
"libc",
]
+[[package]]
+name = "cpufeatures"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
+dependencies = [
+ "libc",
+]
+
[[package]]
name = "crc32fast"
version = "1.5.0"
@@ -854,6 +957,17 @@ dependencies = [
"typenum",
]
+[[package]]
+name = "crypto-common"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
+dependencies = [
+ "getrandom 0.4.3",
+ "hybrid-array",
+ "rand_core",
+]
+
[[package]]
name = "cssparser"
version = "0.36.0"
@@ -893,6 +1007,24 @@ version = "0.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
+[[package]]
+name = "ctr"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21"
+dependencies = [
+ "cipher",
+]
+
+[[package]]
+name = "ctutils"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
+dependencies = [
+ "cmov",
+]
+
[[package]]
name = "darling"
version = "0.21.3"
@@ -1018,11 +1150,14 @@ dependencies = [
name = "devbox"
version = "0.1.4"
dependencies = [
+ "aes-gcm",
+ "argon2",
"base64 0.23.1",
"chrono",
"criterion",
"diesel",
"diesel_migrations",
+ "getrandom 0.3.4",
"libsqlite3-sys",
"log",
"png 0.18.1",
@@ -1050,6 +1185,8 @@ dependencies = [
"toml 1.1.3+spec-1.1.0",
"unicode-normalization",
"uuid",
+ "zeroize",
+ "zip 8.6.0",
]
[[package]]
@@ -1104,8 +1241,19 @@ version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
- "block-buffer",
- "crypto-common",
+ "block-buffer 0.10.4",
+ "crypto-common 0.1.7",
+]
+
+[[package]]
+name = "digest"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
+dependencies = [
+ "block-buffer 0.12.1",
+ "crypto-common 0.2.2",
+ "ctutils",
]
[[package]]
@@ -1764,6 +1912,16 @@ dependencies = [
"cfg-if",
"libc",
"r-efi 6.0.0",
+ "rand_core",
+]
+
+[[package]]
+name = "ghash"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5"
+dependencies = [
+ "polyval",
]
[[package]]
@@ -2120,6 +2278,15 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+[[package]]
+name = "hybrid-array"
+version = "0.4.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
+dependencies = [
+ "typenum",
+]
+
[[package]]
name = "hyper"
version = "1.11.0"
@@ -2370,6 +2537,15 @@ dependencies = [
"cfb",
]
+[[package]]
+name = "inout"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
+dependencies = [
+ "hybrid-array",
+]
+
[[package]]
name = "ipnet"
version = "2.12.0"
@@ -3235,6 +3411,16 @@ dependencies = [
"windows-link 0.2.1",
]
+[[package]]
+name = "password-hash"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b"
+dependencies = [
+ "getrandom 0.4.3",
+ "phc",
+]
+
[[package]]
name = "paste"
version = "1.0.15"
@@ -3258,6 +3444,17 @@ dependencies = [
"indexmap 2.14.0",
]
+[[package]]
+name = "phc"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892"
+dependencies = [
+ "base64ct",
+ "ctutils",
+ "getrandom 0.4.3",
+]
+
[[package]]
name = "phf"
version = "0.13.1"
@@ -3387,6 +3584,17 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "polyval"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd"
+dependencies = [
+ "cpubits",
+ "cpufeatures 0.3.1",
+ "universal-hash",
+]
+
[[package]]
name = "potential_utf"
version = "0.1.5"
@@ -3512,6 +3720,12 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+[[package]]
+name = "rand_core"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
+
[[package]]
name = "raw-window-handle"
version = "0.6.2"
@@ -4113,8 +4327,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
- "cpufeatures",
- "digest",
+ "cpufeatures 0.2.17",
+ "digest 0.10.7",
]
[[package]]
@@ -4852,7 +5066,7 @@ dependencies = [
"tokio",
"url",
"windows-sys 0.60.2",
- "zip",
+ "zip 4.6.1",
]
[[package]]
@@ -5480,6 +5694,12 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+[[package]]
+name = "typed-path"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
+
[[package]]
name = "typeid"
version = "1.0.3"
@@ -5565,6 +5785,16 @@ version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
+[[package]]
+name = "universal-hash"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96"
+dependencies = [
+ "crypto-common 0.2.2",
+ "ctutils",
+]
+
[[package]]
name = "untrusted"
version = "0.9.0"
@@ -6733,6 +6963,19 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "zip"
+version = "8.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
+dependencies = [
+ "crc32fast",
+ "flate2",
+ "indexmap 2.14.0",
+ "memchr",
+ "typed-path",
+]
+
[[package]]
name = "zmij"
version = "1.0.23"
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index baf07f0..0aa20b4 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -67,6 +67,11 @@ tauri-plugin-wdio-webdriver = { version = "1", optional = true }
log = "0.4"
thiserror = "2.0.20"
unicode-normalization = "0.1.25"
+zip = { version = "8.6.0", default-features = false, features = ["deflate-flate2"] }
+aes-gcm = "0.11.1"
+argon2 = "0.6.0"
+zeroize = "1.9.0"
+getrandom = "0.3"
[dev-dependencies]
criterion = { version = "0.8", default-features = false, features = ["cargo_bench_support"] }
diff --git a/src-tauri/benches/commands.rs b/src-tauri/benches/commands.rs
index d683d09..ab8c3bc 100644
--- a/src-tauri/benches/commands.rs
+++ b/src-tauri/benches/commands.rs
@@ -191,21 +191,36 @@ fn disk(c: &mut Criterion) {
// The bundle is ~100 MB, so these two want their own sample size.
group.sample_size(10);
- let path = std::env::temp_dir().join("devbox-bench-export.json");
+ let path = std::env::temp_dir().join("devbox-bench-export.devbox");
let target = path.to_string_lossy().to_string();
+ // The corpus seeds no attachment, so the archive carries the bundle alone. What the
+ // directory is does not matter; that it exists does.
+ let attachments = std::env::temp_dir();
group.bench_function("export_notes", |b| {
b.iter(|| {
let notes = store::all(&mut corpus.connection, None).expect("the corpus");
let packed = bundle::collect(&mut corpus.connection, notes).expect("a bundle");
- black_box(file::write(&target, &packed).expect("a written file"));
+ black_box(
+ file::write(
+ &target,
+ &packed,
+ &attachments,
+ corpus.connection.vault(),
+ None,
+ )
+ .expect("a written file"),
+ );
});
});
group.bench_function("import_notes, every id already there", |b| {
b.iter(|| {
- let incoming = file::read(&target).expect("a readable file");
- black_box(bundle::merge(&mut corpus.connection, incoming).expect("a merge"));
+ let (incoming, mut payload) = file::read(&target, None).expect("a readable file");
+ black_box(
+ bundle::merge(&mut corpus.connection, incoming, &mut payload, &attachments)
+ .expect("a merge"),
+ );
});
});
diff --git a/src-tauri/benches/corpus.rs b/src-tauri/benches/corpus.rs
index 89da3f1..e3b5d06 100644
--- a/src-tauri/benches/corpus.rs
+++ b/src-tauri/benches/corpus.rs
@@ -9,7 +9,7 @@
use std::path::PathBuf;
use chrono::{DateTime, TimeDelta, Utc};
-use diesel::SqliteConnection;
+use devbox_lib::db::Library;
use devbox_lib::db;
use devbox_lib::notes::checklist::{ChecklistItem, NoteKind};
@@ -28,7 +28,7 @@ const TAGS: &[&str] = &[
/// A database file of its own per group, erased with the guard.
pub(crate) struct Corpus {
- pub(crate) connection: SqliteConnection,
+ pub(crate) connection: Library,
pub(crate) space_ids: Vec,
pub(crate) note_ids: Vec,
/// ⚠️ Last, and the erasure lives on this field rather than on `Corpus`: fields drop
@@ -109,7 +109,13 @@ pub(crate) fn build() -> Corpus {
let directory = std::env::temp_dir().join(format!("devbox-bench-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&directory).expect("a writable temporary directory");
- let mut connection = db::open(&directory.join("bench.sqlite3")).expect("a fresh database");
+ // The corpus is sealed like a real library, so the benchmarks measure what the
+ // commands really pay, encryption included.
+ let mut connection = db::open(
+ &directory.join("bench.sqlite3"),
+ db::bench_vault().expect("a key"),
+ )
+ .expect("a fresh database");
let space_ids: Vec = (0..SPACES)
.map(|at| {
diff --git a/src-tauri/src/attachments.rs b/src-tauri/src/attachments.rs
index 64a00c2..ba0b705 100644
--- a/src-tauri/src/attachments.rs
+++ b/src-tauri/src/attachments.rs
@@ -5,6 +5,7 @@
#![allow(clippy::needless_pass_by_value)]
pub mod model;
+pub mod sealed;
pub mod store;
use std::io::Read;
@@ -18,6 +19,7 @@ use uuid::Uuid;
use crate::db::{Db, lock};
use crate::error::{AppError, StorageError};
+use crate::vault::key::Vault;
use model::Attachment;
const DIRECTORY: &str = "attachments";
@@ -52,15 +54,21 @@ pub(crate) fn remove_files(directory: &Path, stored_names: &[String]) {
/// ⚠️ The limit is enforced by the copy itself: reading `metadata().len()` first leaves
/// the two free to disagree, and a file growing between them lands whole.
-fn copy_within_limit(source: &str, destination: &Path) -> Result {
+/// ⚠️ The limit is still enforced by the read rather than by `metadata`: a file growing
+/// between the two would land whole, whatever the limit said. What changed is that the
+/// bytes are sealed before they touch the destination, so nothing readable is ever
+/// written — not even briefly.
+fn copy_within_limit(vault: &Vault, source: &str, destination: &Path) -> Result {
let mut reader = std::fs::File::open(source).map_err(|error| file_error(source, &error))?;
- let mut writer =
- std::fs::File::create(destination).map_err(|error| file_error(source, &error))?;
- let copied = std::io::copy(&mut reader.by_ref().take(model::MAX_BYTES + 1), &mut writer)
+ let mut plain = Vec::new();
+ std::io::copy(&mut reader.by_ref().take(model::MAX_BYTES + 1), &mut plain)
.map_err(|error| file_error(source, &error))?;
- Ok(model::validate_size(copied)?)
+ let size = model::validate_size(plain.len() as u64)?;
+ sealed::write_sealed(vault, destination, &plain)?;
+
+ Ok(size)
}
#[tauri::command(async)]
@@ -84,9 +92,13 @@ pub fn attach_file(
let directory = directory(&app)?;
let destination = directory.join(attachment.stored_name());
- // ⚠️ Copy before the database write: a record without a file shows a broken
+
+ let mut connection = lock(&db)?;
+ let (db, vault) = connection.split();
+
+ // ⚠️ Seal and copy before the database write: a record without a file shows a broken
// thumbnail, where a file without a record is swept at startup.
- match copy_within_limit(&path, &destination) {
+ match copy_within_limit(vault, &path, &destination) {
Ok(byte_size) => attachment.byte_size = byte_size,
Err(error) => {
remove_files(&directory, &[attachment.stored_name()]);
@@ -94,8 +106,7 @@ pub fn attach_file(
}
}
- let mut connection = lock(&db)?;
- if let Err(error) = store::create(&mut connection, &attachment) {
+ if let Err(error) = store::create(db, vault, &attachment) {
remove_files(&directory, &[attachment.stored_name()]);
return Err(error.into());
}
@@ -136,11 +147,15 @@ fn write_attachment(
let directory = directory(app)?;
let destination = directory.join(attachment.stored_name());
- std::fs::write(&destination, bytes)
- .map_err(|error| file_error(&attachment.file_name, &error))?;
let mut connection = lock(db)?;
- if let Err(error) = store::create(&mut connection, &attachment) {
+ let (db, vault) = connection.split();
+
+ // Sealed on the way in, like a copied file: a pasted screenshot of a credentials page
+ // has no business being the one attachment left readable.
+ sealed::write_sealed(vault, &destination, &bytes)?;
+
+ if let Err(error) = store::create(db, vault, &attachment) {
remove_files(&directory, &[attachment.stored_name()]);
return Err(error.into());
}
@@ -166,7 +181,10 @@ pub fn read_attachment(id: String, app: AppHandle, db: State<'_, Db>) -> Result<
};
let path = directory(&app)?.join(attachment.stored_name());
- let bytes = std::fs::read(&path).map_err(|error| file_error(&attachment.file_name, &error))?;
+ let bytes = {
+ let connection = lock(&db)?;
+ sealed::read_sealed(connection.vault(), &path)?
+ };
Ok(format!(
"data:{};base64,{}",
@@ -177,13 +195,39 @@ pub fn read_attachment(id: String, app: AppHandle, db: State<'_, Db>) -> Result<
/// ⚠️ The call starts from Rust: opening a path from the front end would mean allowing
/// `opener:allow-open-path` over a whole directory.
+///
+/// ⚠️ **This is the one place a decrypted copy reaches the disk.** Handing a file to the
+/// application the desktop chose for it means handing over a path, and that file has to
+/// be readable. The copy goes under a directory of ours in the OS temporary folder and is
+/// swept at the next launch — it cannot be deleted on close, because the application that
+/// opened it still holds it. The README says so; replacing this with "save as" was the
+/// alternative and was turned down, one click being the point.
#[tauri::command(async)]
#[specta::specta]
pub fn open_attachment(id: String, app: AppHandle, db: State<'_, Db>) -> Result<(), AppError> {
- let path = locate(&id, &app, &db)?;
+ let attachment = {
+ let mut connection = lock(&db)?;
+ store::find(&mut connection, &id)?
+ .ok_or_else(|| StorageError::AttachmentNotFound(id.clone()))?
+ };
+
+ let stored = directory(&app)?.join(attachment.stored_name());
+ let bytes = {
+ let connection = lock(&db)?;
+ sealed::read_sealed(connection.vault(), &stored)?
+ };
+
+ let directory = sealed::plaintext_directory(&app)?;
+ std::fs::create_dir_all(&directory)
+ .map_err(|error| file_error("a directory for decrypted copies", &error))?;
+
+ // Named after the record, not after what the user called it: two `capture.png` must
+ // not overwrite each other here either.
+ let copy = directory.join(attachment.stored_name());
+ std::fs::write(©, &bytes).map_err(|error| file_error(&attachment.file_name, &error))?;
tauri_plugin_opener::OpenerExt::opener(&app)
- .open_path(path.to_string_lossy(), None::<&str>)
+ .open_path(copy.to_string_lossy(), None::<&str>)
.map_err(|error| StorageError::File(format!("open: {error}")))?;
Ok(())
@@ -199,8 +243,14 @@ pub fn save_attachment(
db: State<'_, Db>,
) -> Result<(), AppError> {
let source = locate(&id, &app, &db)?;
+ let bytes = {
+ let connection = lock(&db)?;
+ sealed::read_sealed(connection.vault(), &source)?
+ };
- std::fs::copy(&source, &path).map_err(|error| file_error(&path, &error))?;
+ // In the clear, where the user chose: that is what "save as" means, and it is an
+ // explicit gesture rather than something the application does behind them.
+ std::fs::write(&path, &bytes).map_err(|error| file_error(&path, &error))?;
Ok(())
}
@@ -267,6 +317,10 @@ pub fn sweep_orphan_files(app: &AppHandle, db: &Db) -> Result Vault {
+ crate::db::test_vault().expect("a key")
+ }
use crate::error::ErrorCode;
fn scratch() -> PathBuf {
@@ -283,10 +337,23 @@ mod tests {
std::fs::write(&source, vec![7u8; 2048]).unwrap();
let destination = directory.join("a-1.png");
- let copied = copy_within_limit(&source.to_string_lossy(), &destination).unwrap();
+ let vault = test_vault();
+ let copied = copy_within_limit(&vault, &source.to_string_lossy(), &destination).unwrap();
+ // The size reported is the plaintext's: it is what the interface shows.
assert_eq!(copied, 2048);
- assert_eq!(std::fs::read(&destination).unwrap().len(), 2048);
+
+ let written = std::fs::read(&destination).unwrap();
+ assert!(written.len() > 2048, "the file carries a nonce and a tag");
+ assert_ne!(
+ written[..2048],
+ [7u8; 2048],
+ "and none of the bytes as given"
+ );
+ assert_eq!(
+ sealed::read_sealed(&vault, &destination).unwrap(),
+ vec![7u8; 2048]
+ );
std::fs::remove_dir_all(&directory).ok();
}
@@ -302,7 +369,8 @@ mod tests {
.unwrap();
let destination = directory.join("a-1.bin");
- let error = copy_within_limit(&source.to_string_lossy(), &destination).unwrap_err();
+ let error =
+ copy_within_limit(&test_vault(), &source.to_string_lossy(), &destination).unwrap_err();
assert!(matches!(error.code, ErrorCode::InvalidInput));
assert_eq!(
@@ -316,7 +384,12 @@ mod tests {
fn a_missing_source_is_reported_rather_than_panicking() {
let directory = scratch();
- let error = copy_within_limit("no-such-file.png", &directory.join("a-1.png")).unwrap_err();
+ let error = copy_within_limit(
+ &test_vault(),
+ "no-such-file.png",
+ &directory.join("a-1.png"),
+ )
+ .unwrap_err();
assert!(matches!(error.code, ErrorCode::FileAccess));
std::fs::remove_dir_all(&directory).ok();
diff --git a/src-tauri/src/attachments/model.rs b/src-tauri/src/attachments/model.rs
index e87adc6..a7295fc 100644
--- a/src-tauri/src/attachments/model.rs
+++ b/src-tauri/src/attachments/model.rs
@@ -4,7 +4,7 @@
//! deciding a write path.
use chrono::{DateTime, Utc};
-use serde::Serialize;
+use serde::{Deserialize, Serialize};
use specta::Type;
use crate::count::saturating_u32;
@@ -13,7 +13,7 @@ use crate::error::{StorageError, ValidationError};
/// 10 MB: past that it is no longer a screenshot pasted next to a note.
pub const MAX_BYTES: u64 = 10 * 1024 * 1024;
-#[derive(Debug, Clone, Serialize, Type)]
+#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct Attachment {
pub id: String,
diff --git a/src-tauri/src/attachments/sealed.rs b/src-tauri/src/attachments/sealed.rs
new file mode 100644
index 0000000..543ff8c
--- /dev/null
+++ b/src-tauri/src/attachments/sealed.rs
@@ -0,0 +1,203 @@
+//! The bytes on disk, sealed like the rows beside them.
+//!
+//! ⚠️ A sealed file is read whole to be opened: AES-GCM authenticates the message, and a
+//! message is only authentic once all of it has been seen. That is the price of knowing a
+//! file was not tampered with, and the 10 MiB cap on an attachment is what keeps it
+//! bounded.
+
+use std::path::{Path, PathBuf};
+
+use tauri::{AppHandle, Manager};
+
+use crate::error::StorageError;
+use crate::vault::key::Vault;
+
+/// What [`plaintext_directory`] is called, beside the attachments themselves.
+///
+/// ⚠️ A decrypted copy lands here whenever an attachment is opened with the application
+/// the desktop chose for it — there is no other way to hand a file to another program.
+/// [`sweep_plaintext`] empties it on the way out and at every launch, so the copy outlives
+/// the session at worst, and the README says so plainly.
+const PLAINTEXT_DIRECTORY: &str = "open";
+
+pub fn seal_into(vault: &Vault, source: &Path, destination: &Path) -> Result {
+ let plain = std::fs::read(source)
+ .map_err(|error| StorageError::File(format!("{}: {error}", source.display())))?;
+
+ write_sealed(vault, destination, &plain)
+}
+
+pub fn write_sealed(vault: &Vault, destination: &Path, bytes: &[u8]) -> Result {
+ let sealed = vault.seal_bytes(bytes)?;
+ std::fs::write(destination, &sealed)
+ .map_err(|error| StorageError::File(format!("{}: {error}", destination.display())))?;
+
+ // ⚠️ The plaintext length, not the file's: the record is what the interface shows, and
+ // a size inflated by the nonce and the tag would be a lie the user could measure.
+ Ok(bytes.len() as u64)
+}
+
+pub fn read_sealed(vault: &Vault, path: &Path) -> Result, StorageError> {
+ let sealed = std::fs::read(path)
+ .map_err(|error| StorageError::File(format!("{}: {error}", path.display())))?;
+
+ vault.open_bytes(&sealed)
+}
+
+/// Seals a file that is still in the clear, for a library that predates the passphrase.
+///
+/// ⚠️ Staged then renamed: a file half-rewritten is an attachment lost, where a rename is
+/// atomic. And ⚠️ it is **not** idempotent — sealing twice gives a file that opens into
+/// ciphertext. It runs once, on the launch that creates the key file.
+pub fn seal_in_place(vault: &Vault, path: &Path) -> Result<(), StorageError> {
+ let plain = std::fs::read(path)
+ .map_err(|error| StorageError::File(format!("{}: {error}", path.display())))?;
+
+ let staged = path.with_extension("sealing");
+ write_sealed(vault, &staged, &plain)?;
+
+ std::fs::rename(&staged, path).map_err(|error| {
+ let _ = std::fs::remove_file(&staged);
+ StorageError::File(format!("{}: {error}", path.display()))
+ })
+}
+
+/// Where a decrypted copy goes so the desktop can open it.
+///
+/// ⚠️ The application's own data directory, deliberately, and **not** the OS temporary
+/// one: that is a namespace shared with every account on the machine, where the copy would
+/// be readable by all of them and where a directory somebody else created first would be
+/// theirs rather than ours. This one sits inside the user's profile.
+pub fn plaintext_directory(app: &AppHandle) -> Result {
+ Ok(app
+ .path()
+ .app_data_dir()
+ .map_err(|error| StorageError::File(format!("app_data_dir: {error}")))?
+ .join(PLAINTEXT_DIRECTORY))
+}
+
+/// ⚠️ Run on the way out *and* at every launch. A copy handed to another application
+/// cannot be deleted while that application holds it, and a crash reaches neither path —
+/// so the guarantee is "gone by the next launch", with the exit sweep narrowing the
+/// window to the session for everything not still open.
+pub fn sweep_plaintext(app: &AppHandle) {
+ let Ok(directory) = plaintext_directory(app) else {
+ return;
+ };
+
+ if let Err(error) = std::fs::remove_dir_all(&directory)
+ && error.kind() != std::io::ErrorKind::NotFound
+ {
+ log::warn!(
+ "Decrypted copies not swept from {}: {error}",
+ directory.display()
+ );
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::vault::key::Cost;
+
+ fn vault() -> Vault {
+ Vault::derive(
+ "a passphrase",
+ b"0123456789abcdef",
+ Cost {
+ memory_kib: 64,
+ passes: 1,
+ lanes: 1,
+ },
+ )
+ .unwrap()
+ }
+
+ fn scratch() -> std::path::PathBuf {
+ let directory = std::env::temp_dir().join(format!(
+ "devbox-sealed-{}",
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_nanos()
+ ));
+ std::fs::create_dir_all(&directory).unwrap();
+
+ directory
+ }
+
+ #[test]
+ fn a_sealed_file_reads_back_byte_for_byte() {
+ let directory = scratch();
+ let vault = vault();
+ let target = directory.join("capture.png");
+
+ let bytes = b"\x89PNG\r\n\x1a\n and whatever follows";
+ write_sealed(&vault, &target, bytes).unwrap();
+
+ assert_eq!(read_sealed(&vault, &target).unwrap(), bytes);
+ std::fs::remove_dir_all(&directory).ok();
+ }
+
+ /// ⚠️ The point: a screenshot of a credentials page must not be readable beside a
+ /// database that is.
+ #[test]
+ fn the_file_on_disk_carries_none_of_the_bytes_it_was_given() {
+ let directory = scratch();
+ let vault = vault();
+ let target = directory.join("capture.png");
+
+ write_sealed(&vault, &target, b"SECRET-TOKEN-abc123").unwrap();
+
+ let raw = std::fs::read(&target).unwrap();
+ assert!(!String::from_utf8_lossy(&raw).contains("SECRET-TOKEN"));
+ std::fs::remove_dir_all(&directory).ok();
+ }
+
+ /// The record shows what the user attached, not what the cipher added to it.
+ #[test]
+ fn the_size_reported_is_the_plaintext_size() {
+ let directory = scratch();
+ let vault = vault();
+
+ let written = write_sealed(&vault, &directory.join("f"), &[0u8; 1000]).unwrap();
+
+ assert_eq!(written, 1000);
+ std::fs::remove_dir_all(&directory).ok();
+ }
+
+ #[test]
+ fn a_file_sealed_under_another_key_will_not_open() {
+ let directory = scratch();
+ let target = directory.join("capture.png");
+ write_sealed(&vault(), &target, b"bytes").unwrap();
+
+ let other = Vault::derive(
+ "another passphrase",
+ b"0123456789abcdef",
+ Cost {
+ memory_kib: 64,
+ passes: 1,
+ lanes: 1,
+ },
+ )
+ .unwrap();
+
+ assert!(read_sealed(&other, &target).is_err());
+ std::fs::remove_dir_all(&directory).ok();
+ }
+
+ #[test]
+ fn a_truncated_file_is_refused_rather_than_half_read() {
+ let directory = scratch();
+ let vault = vault();
+ let target = directory.join("capture.png");
+ write_sealed(&vault, &target, b"a reasonably long run of bytes").unwrap();
+
+ let sealed = std::fs::read(&target).unwrap();
+ std::fs::write(&target, &sealed[..sealed.len() - 4]).unwrap();
+
+ assert!(read_sealed(&vault, &target).is_err());
+ std::fs::remove_dir_all(&directory).ok();
+ }
+}
diff --git a/src-tauri/src/attachments/store.rs b/src-tauri/src/attachments/store.rs
index 99ba4fc..39b9a6f 100644
--- a/src-tauri/src/attachments/store.rs
+++ b/src-tauri/src/attachments/store.rs
@@ -4,9 +4,11 @@ use diesel::prelude::*;
use super::model::{self, Attachment};
use crate::count::saturating_u32;
+use crate::db::Library;
use crate::db::iso8601;
use crate::db::schema::{attachments, notes};
use crate::error::StorageError;
+use crate::vault::key::Vault;
#[derive(Queryable, Selectable, Insertable)]
#[diesel(table_name = attachments)]
@@ -20,42 +22,41 @@ struct AttachmentRow {
created_at: String,
}
-impl TryFrom for Attachment {
- type Error = StorageError;
-
- fn try_from(row: AttachmentRow) -> Result {
+/// ⚠️ `mime_type` stays in the clear, deliberately: the file on disk is named
+/// `{id}.{extension}`, so the type is already public. Sealing it would be theatre.
+impl AttachmentRow {
+ fn open(row: Self, vault: &Vault) -> Result {
let created_at = iso8601::parse(&row.created_at).map_err(|_| StorageError::CorruptRow {
id: row.id.clone(),
field: "createdAt",
})?;
- Ok(Self {
+ Ok(Attachment {
byte_size: saturating_u32(row.byte_size),
+ file_name: vault.open(&row.file_name)?,
id: row.id,
note_id: row.note_id,
- file_name: row.file_name,
mime_type: row.mime_type,
created_at,
})
}
-}
-impl From<&Attachment> for AttachmentRow {
- fn from(attachment: &Attachment) -> Self {
- Self {
+ fn seal(attachment: &Attachment, vault: &Vault) -> Result {
+ Ok(Self {
id: attachment.id.clone(),
note_id: attachment.note_id.clone(),
- file_name: attachment.file_name.clone(),
+ file_name: vault.seal(&attachment.file_name)?,
mime_type: attachment.mime_type.clone(),
byte_size: i64::from(attachment.byte_size),
created_at: iso8601::format(attachment.created_at),
- }
+ })
}
}
/// The foreign key would refuse it too, but with a message the front cannot translate.
pub fn create(
connection: &mut SqliteConnection,
+ vault: &Vault,
attachment: &Attachment,
) -> Result<(), StorageError> {
connection.transaction(|connection| {
@@ -71,37 +72,35 @@ pub fn create(
}
diesel::insert_into(attachments::table)
- .values(AttachmentRow::from(attachment))
+ .values(AttachmentRow::seal(attachment, vault)?)
.execute(connection)?;
Ok(())
})
}
-pub fn list(
- connection: &mut SqliteConnection,
- note_id: &str,
-) -> Result, StorageError> {
+pub fn list(connection: &mut Library, note_id: &str) -> Result, StorageError> {
+ let (db, vault) = connection.split();
+
attachments::table
.filter(attachments::note_id.eq(note_id))
.select(AttachmentRow::as_select())
.order((attachments::created_at.asc(), attachments::id.asc()))
- .load::(connection)?
+ .load::(db)?
.into_iter()
- .map(Attachment::try_from)
+ .map(|row| AttachmentRow::open(row, vault))
.collect()
}
-pub fn find(
- connection: &mut SqliteConnection,
- id: &str,
-) -> Result, StorageError> {
+pub fn find(connection: &mut Library, id: &str) -> Result , StorageError> {
+ let (db, vault) = connection.split();
+
attachments::table
.find(id)
.select(AttachmentRow::as_select())
- .first::(connection)
+ .first::(db)
.optional()?
- .map(Attachment::try_from)
+ .map(|row| AttachmentRow::open(row, vault))
.transpose()
}
@@ -115,31 +114,57 @@ pub fn delete(connection: &mut SqliteConnection, id: &str) -> Result<(), Storage
Ok(())
}
+/// The records an export carries. The bytes are not here: the caller reads them from the
+/// attachments directory by [`model::stored_name`].
+pub fn for_notes(
+ connection: &mut Library,
+ note_ids: &[String],
+) -> Result, StorageError> {
+ if note_ids.is_empty() {
+ return Ok(Vec::new());
+ }
+
+ let (db, vault) = connection.split();
+
+ attachments::table
+ .filter(attachments::note_id.eq_any(note_ids))
+ .select(AttachmentRow::as_select())
+ .order((attachments::created_at.asc(), attachments::id.asc()))
+ .load::(db)?
+ .into_iter()
+ .map(|row| AttachmentRow::open(row, vault))
+ .collect()
+}
+
/// ⚠️ Collected before a purge: the cascade takes the records, never the files.
pub fn stored_names_of(
- connection: &mut SqliteConnection,
+ connection: &mut Library,
note_ids: &[String],
) -> Result, StorageError> {
if note_ids.is_empty() {
return Ok(Vec::new());
}
- Ok(attachments::table
+ let (db, vault) = connection.split();
+
+ attachments::table
.filter(attachments::note_id.eq_any(note_ids))
.select((attachments::id, attachments::file_name))
- .load::<(String, String)>(connection)?
+ .load::<(String, String)>(db)?
.iter()
- .map(|(id, file_name)| model::stored_name(id, file_name))
- .collect())
+ .map(|(id, file_name)| Ok(model::stored_name(id, &vault.open(file_name)?)))
+ .collect::, StorageError>>()
}
-pub fn all_stored_names(connection: &mut SqliteConnection) -> Result, StorageError> {
- Ok(attachments::table
+pub fn all_stored_names(connection: &mut Library) -> Result, StorageError> {
+ let (db, vault) = connection.split();
+
+ attachments::table
.select((attachments::id, attachments::file_name))
- .load::<(String, String)>(connection)?
+ .load::<(String, String)>(db)?
.iter()
- .map(|(id, file_name)| model::stored_name(id, file_name))
- .collect())
+ .map(|(id, file_name)| Ok(model::stored_name(id, &vault.open(file_name)?)))
+ .collect::, StorageError>>()
}
/// One note; [`counts`] answers for the whole corpus at once.
@@ -174,7 +199,7 @@ mod tests {
use crate::notes::language::Language;
use crate::notes::model::{NoteDraft, NoteLifecycle};
- fn note(connection: &mut SqliteConnection) -> String {
+ fn note(connection: &mut Library) -> String {
let space = crate::spaces::store::create(connection, "Personal").unwrap();
crate::notes::store::create(
connection,
@@ -196,6 +221,12 @@ mod tests {
.id
}
+ /// The tests hold a library; `create` takes the pair, as an import does.
+ fn create_here(connection: &mut Library, attachment: &Attachment) -> Result<(), StorageError> {
+ let (db, vault) = connection.split();
+ create(db, vault, attachment)
+ }
+
fn sample(id: &str, note_id: &str) -> Attachment {
Attachment {
id: id.to_string(),
@@ -212,7 +243,7 @@ mod tests {
let mut connection = open_in_memory().unwrap();
let note_id = note(&mut connection);
- create(&mut connection, &sample("a-1", ¬e_id)).unwrap();
+ create_here(&mut connection, &sample("a-1", ¬e_id)).unwrap();
let listed = list(&mut connection, ¬e_id).unwrap();
assert_eq!(listed.len(), 1);
@@ -224,7 +255,7 @@ mod tests {
fn attaching_to_an_unknown_note_is_refused() {
let mut connection = open_in_memory().unwrap();
- let error = create(&mut connection, &sample("a-1", "ghost")).unwrap_err();
+ let error = create_here(&mut connection, &sample("a-1", "ghost")).unwrap_err();
assert!(matches!(error, StorageError::NoteNotFound(_)));
}
@@ -233,7 +264,7 @@ mod tests {
fn purging_a_note_takes_its_attachment_rows_with_it() {
let mut connection = open_in_memory().unwrap();
let note_id = note(&mut connection);
- create(&mut connection, &sample("a-1", ¬e_id)).unwrap();
+ create_here(&mut connection, &sample("a-1", ¬e_id)).unwrap();
let files = stored_names_of(&mut connection, std::slice::from_ref(¬e_id)).unwrap();
crate::notes::store::trash::trash(&mut connection, ¬e_id, Utc::now()).unwrap();
diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs
index 295c60e..574b0dd 100644
--- a/src-tauri/src/db.rs
+++ b/src-tauri/src/db.rs
@@ -1,6 +1,7 @@
pub mod migration;
pub mod schema;
+use std::ops::{Deref, DerefMut};
use std::path::Path;
use std::sync::{Mutex, MutexGuard};
@@ -8,34 +9,141 @@ use diesel::connection::SimpleConnection;
use diesel::prelude::*;
use crate::error::StorageError;
+use crate::vault::key::Vault;
pub const DB_FILE_NAME: &str = "devbox.sqlite3";
-/// `SqliteConnection` is not `Sync`: overlapping commands serialize on this mutex.
-pub type Db = Mutex;
+/// The connection, and the key everything it holds is sealed with.
+///
+/// ⚠️ It derefs to the connection, so a caller keeps writing `&mut connection`. What it
+/// does **not** do is stand in for one where Diesel expects it: `load` and its siblings
+/// take their connection as a generic parameter, and a generic gets no deref coercion —
+/// hence [`Library::db`] at every query.
+pub struct Library {
+ connection: SqliteConnection,
+ vault: Vault,
+}
+
+impl Library {
+ /// The connection, for the Diesel call that needs it by that name.
+ pub fn db(&mut self) -> &mut SqliteConnection {
+ &mut self.connection
+ }
+
+ /// ⚠️ Both halves at once. Two calls would not do: one borrows mutably and the other
+ /// shared, and the compiler cannot see they touch different fields until they are
+ /// destructured together.
+ pub fn split(&mut self) -> (&mut SqliteConnection, &Vault) {
+ (&mut self.connection, &self.vault)
+ }
+
+ /// The key. Sealing is the caller's to do — this only hands it over.
+ pub fn vault(&self) -> &Vault {
+ &self.vault
+ }
+
+ /// ⚠️ Hands the closure the connection **and** the key. `SqliteConnection::transaction`
+ /// alone gives back a bare connection, which would leave a caller unable to seal
+ /// anything inside the transaction it just opened.
+ pub fn transaction(&mut self, f: F) -> Result
+ where
+ F: FnOnce(&mut SqliteConnection, &Vault) -> Result,
+ {
+ // Split borrows: the connection mutably, the key shared, and they are disjoint
+ // fields — which is the whole reason this is destructured rather than chained.
+ let Self { connection, vault } = self;
+ connection.transaction(|connection| f(connection, vault))
+ }
+}
+
+impl Deref for Library {
+ type Target = SqliteConnection;
+
+ fn deref(&self) -> &Self::Target {
+ &self.connection
+ }
+}
+
+impl DerefMut for Library {
+ fn deref_mut(&mut self) -> &mut Self::Target {
+ &mut self.connection
+ }
+}
+
+/// ⚠️ Empty until the passphrase has been given. Every command runs behind the front
+/// end's unlock gate, so `None` here is a caller that jumped the queue, not a state to
+/// render.
+pub type Db = Mutex>;
/// A poisoned mutex means a command panicked while holding it.
-pub(crate) fn lock(db: &Db) -> Result, StorageError> {
- db.lock().map_err(|_| StorageError::Unavailable)
+pub(crate) fn lock(db: &Db) -> Result, StorageError> {
+ let guard = db.lock().map_err(|_| StorageError::Unavailable)?;
+ if guard.is_none() {
+ return Err(StorageError::Locked);
+ }
+
+ Ok(LibraryGuard(guard))
}
-pub fn open(path: &Path) -> Result {
+/// The guard, narrowed to a library that is definitely there — [`lock`] refused otherwise.
+pub(crate) struct LibraryGuard<'a>(MutexGuard<'a, Option>);
+
+impl Deref for LibraryGuard<'_> {
+ type Target = Library;
+
+ fn deref(&self) -> &Self::Target {
+ self.0.as_ref().expect("a library checked by lock")
+ }
+}
+
+impl DerefMut for LibraryGuard<'_> {
+ fn deref_mut(&mut self) -> &mut Self::Target {
+ self.0.as_mut().expect("a library checked by lock")
+ }
+}
+
+pub fn open(path: &Path, vault: Vault) -> Result {
let mut connection = SqliteConnection::establish(&path.to_string_lossy())
.map_err(|error| StorageError::Migration(error.to_string()))?;
configure(&mut connection)?;
migration::run(&mut connection)?;
- Ok(connection)
+ Ok(Library { connection, vault })
}
/// Public for the integration tests, which see nothing of the crate but its API.
-pub fn open_in_memory() -> Result {
+pub fn open_in_memory() -> Result {
let mut connection = SqliteConnection::establish(":memory:")
.map_err(|error| StorageError::Migration(error.to_string()))?;
configure(&mut connection)?;
migration::run(&mut connection)?;
- Ok(connection)
+ Ok(Library {
+ connection,
+ vault: test_vault()?,
+ })
+}
+
+/// The same key the in-memory libraries use, for the benchmarks, which open a file.
+pub fn bench_vault() -> Result {
+ test_vault()
+}
+
+/// ⚠️ A key of its own per in-memory library, derived at a cost nobody would ship. These
+/// libraries exist for the length of a test and never reach a file, so what matters is
+/// that the sealing path is the real one — not that the key is expensive to guess.
+pub fn test_vault() -> Result {
+ use crate::vault::key::Cost;
+
+ Vault::derive(
+ "in-memory",
+ b"0123456789abcdef",
+ Cost {
+ memory_kib: 64,
+ passes: 1,
+ lanes: 1,
+ },
+ )
}
fn configure(connection: &mut SqliteConnection) -> Result<(), StorageError> {
@@ -109,7 +217,7 @@ mod tests {
use diesel::sql_types::Integer;
fn in_memory() -> Db {
- Mutex::new(SqliteConnection::establish(":memory:").unwrap())
+ Mutex::new(Some(open_in_memory().unwrap()))
}
/// Stays here rather than in `tests/`: `configure` is private.
@@ -124,7 +232,7 @@ mod tests {
let mut connection = open_in_memory().unwrap();
let enabled = diesel::sql_query("PRAGMA foreign_keys")
- .get_result::(&mut connection)
+ .get_result::(connection.db())
.unwrap()
.foreign_keys;
@@ -138,6 +246,20 @@ mod tests {
assert!(lock(&db).is_ok());
}
+ /// ⚠️ A command that reached the library before the passphrase did. The front gates
+ /// on the unlock screen, so this only catches a caller that jumped the queue — but it
+ /// answers rather than unwrapping a `None`.
+ #[test]
+ fn a_library_still_locked_is_reported_rather_than_unwrapped() {
+ let db: Db = Mutex::new(None);
+
+ let Err(error) = lock(&db) else {
+ panic!("a locked library must be reported, not handed over");
+ };
+
+ assert!(matches!(error, StorageError::Locked));
+ }
+
#[test]
fn a_poisoned_connection_is_reported_instead_of_panicking_again() {
let db = in_memory();
diff --git a/src-tauri/src/db/migration.rs b/src-tauri/src/db/migration.rs
index af347a8..753d73c 100644
--- a/src-tauri/src/db/migration.rs
+++ b/src-tauri/src/db/migration.rs
@@ -148,8 +148,8 @@ mod tests {
std::fs::create_dir_all(&directory).unwrap();
let path = directory.join(DB_FILE_NAME);
- open(&path).unwrap();
- let mut connection = open(&path).unwrap();
+ open(&path, crate::db::test_vault().unwrap()).unwrap();
+ let mut connection = open(&path, crate::db::test_vault().unwrap()).unwrap();
assert!(!connection.has_pending_migration(MIGRATIONS).unwrap());
@@ -301,7 +301,7 @@ mod tests {
diesel::sql_query(
"INSERT INTO __diesel_schema_migrations (version) VALUES ('2099-01-01-000000')",
)
- .execute(&mut connection)
+ .execute(connection.db())
.unwrap();
let error = run(&mut connection).unwrap_err();
diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs
index b62ff7f..0fccec5 100644
--- a/src-tauri/src/error.rs
+++ b/src-tauri/src/error.rs
@@ -47,9 +47,24 @@ pub enum StorageError {
SchemaTooRecent(String),
#[error("Migration failed: {0}")]
Migration(String),
+ /// Deriving a key, sealing a value, or opening one that will not open.
+ #[error("Vault error: {0}")]
+ Vault(String),
+ /// ⚠️ Says only that: which of the passphrase and the file is wrong is not something
+ /// to help a caller narrow down.
+ #[error("Wrong passphrase")]
+ WrongPassphrase,
+ /// A protected export was offered without the phrase that opens it. Not a failure —
+ /// the interface has to ask, and nothing could know before looking inside.
+ #[error("This file is protected by a passphrase")]
+ PassphraseRequired,
/// A command panicked while holding the connection.
#[error("Storage unavailable: a previous operation failed")]
Unavailable,
+ /// ⚠️ A command reached the library before the passphrase did. The front gates on the
+ /// unlock screen, so this is a caller that jumped the queue rather than a state.
+ #[error("The library is locked")]
+ Locked,
/// `#[from]`: required by `Connection::transaction`.
#[error("Storage error: {0}")]
Sqlite(#[from] diesel::result::Error),
@@ -57,7 +72,7 @@ pub enum StorageError {
/// ⚠️ Adding a variant breaks the front-end build until `CODE_KEYS`
/// (`core/services/errors/error-notifier.service.ts`) and both locales have their key.
-#[derive(Debug, Clone, Copy, Serialize, Type)]
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum ErrorCode {
NoteNotFound,
@@ -70,6 +85,13 @@ pub enum ErrorCode {
InvalidInput,
/// Poisoned mutex: a command panicked while holding the connection.
StorageUnavailable,
+ /// The one the unlock screen acts on: it clears the field rather than banishing the
+ /// user to a banner.
+ WrongPassphrase,
+ /// A command ran before the library was unlocked.
+ Locked,
+ /// The import needs the phrase the export was protected with.
+ PassphraseRequired,
Storage,
}
@@ -134,11 +156,15 @@ impl From for AppError {
Self::with(ErrorCode::AttachmentNotFound, detail, "id", &id)
}
StorageError::Unavailable => Self::new(ErrorCode::StorageUnavailable, detail),
+ StorageError::WrongPassphrase => Self::new(ErrorCode::WrongPassphrase, detail),
+ StorageError::PassphraseRequired => Self::new(ErrorCode::PassphraseRequired, detail),
+ StorageError::Locked => Self::new(ErrorCode::Locked, detail),
StorageError::File(_) => Self::new(ErrorCode::FileAccess, detail),
StorageError::ImportFormat(_) => Self::new(ErrorCode::ImportFormat, detail),
// Nothing here gives the front anything to do beyond reporting the failure.
StorageError::SchemaTooRecent(_)
| StorageError::Migration(_)
+ | StorageError::Vault(_)
| StorageError::CorruptRow { .. }
| StorageError::Sqlite(_) => Self::new(ErrorCode::Storage, detail),
}
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 27cbf06..5f6dd2b 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -9,6 +9,7 @@ pub mod error;
pub mod notes;
pub mod spaces;
pub mod transfer;
+pub mod vault;
pub(crate) mod app_info;
pub(crate) mod closed_enum;
@@ -31,7 +32,8 @@ use notes::{
tag_notes, update_note,
};
use spaces::{create_space, delete_space, list_spaces, pin_space, rename_space};
-use transfer::{export_notes, export_selection, import_notes, share_notes};
+use transfer::{export_is_protected, export_notes, export_selection, import_notes, share_notes};
+use vault::{change_passphrase, create_vault, unlock_vault, vault_state};
/// ⚠️ Resolved from the manifest: a relative path writes the file next to whatever the
/// current directory happens to be, without saying a word.
@@ -87,6 +89,11 @@ fn ipc_builder() -> Builder {
export_selection,
import_notes,
share_notes,
+ export_is_protected,
+ vault_state,
+ create_vault,
+ unlock_vault,
+ change_passphrase,
app_changelog,
sync_tray,
set_global_shortcuts,
@@ -185,23 +192,29 @@ fn setup(app: &tauri::App) -> Result<(), Box> {
let directory = app.path().app_data_dir()?;
std::fs::create_dir_all(&directory)?;
- let connection = db::open(&directory.join(db::DB_FILE_NAME))?;
- app.manage(db::Db::new(connection));
-
- sweep(app.handle());
+ // ⚠️ Nothing is opened here any more: the key comes from a passphrase the front end
+ // has not asked for yet. `vault::unlock` is what fills this and runs the sweeps.
+ app.manage(db::Db::new(None));
Ok(())
}
/// What makes retention hold even if nobody opens the trash. Neither sweep is fatal:
/// the application has to start.
-fn sweep(handle: &tauri::AppHandle) {
+///
+/// ⚠️ Moved behind the unlock with the database itself. A sweep needs to read the notes,
+/// and before the passphrase there is nothing to read.
+pub(crate) fn sweep(handle: &tauri::AppHandle) {
let db = handle.state::();
notes::trash::sweep_at_startup(handle, &db);
if let Err(error) = attachments::sweep_orphan_files(handle, &db) {
log::warn!("Orphan attachment files not swept: {error}");
}
+ // ⚠️ The decrypted copies `open_attachment` had to write. They cannot be deleted on
+ // close — the application that opened one still holds it — so this is the guarantee:
+ // gone by the next launch.
+ attachments::sealed::sweep_plaintext(handle);
}
/// ⚠️ Both are refused when there is no tray to find the window in (see `desktop`).
@@ -239,8 +252,18 @@ pub fn run() {
.setup(|app| setup(app))
.on_window_event(on_window_event)
.invoke_handler(builder.invoke_handler())
- .run(tauri::generate_context!())
- .expect("error while launching the Tauri application");
+ .build(tauri::generate_context!())
+ .expect("error while launching the Tauri application")
+ .run(|handle, event| {
+ // ⚠️ Built and run rather than `run` alone, for this one event: a decrypted
+ // copy handed to another application should not outlive the session that
+ // asked for it. Best effort by design — one the desktop still holds is
+ // locked and stays, and a crash reaches none of this, which is what the
+ // sweep at launch is for.
+ if matches!(event, tauri::RunEvent::Exit) {
+ attachments::sealed::sweep_plaintext(handle);
+ }
+ });
}
#[cfg(test)]
diff --git a/src-tauri/src/notes.rs b/src-tauri/src/notes.rs
index 03c9841..c1582f2 100644
--- a/src-tauri/src/notes.rs
+++ b/src-tauri/src/notes.rs
@@ -50,12 +50,11 @@ pub(crate) mod fixtures {
use std::collections::BTreeMap;
use chrono::Utc;
-use diesel::SqliteConnection;
use tauri::{AppHandle, State};
use crate::attachments;
use crate::count::saturating_u32 as count;
-use crate::db::{Db, lock};
+use crate::db::{Db, Library, lock};
use crate::error::{AppError, StorageError};
use model::{DisplayNote, NoteDraft, NotePatch, TagUsage};
use trash::TrashedNote;
@@ -294,10 +293,7 @@ pub fn set_global_placeholders(
/// What only the database knows. Both are queries of their own, which is why neither
/// lives in [`model::decorate`].
-fn display(
- connection: &mut SqliteConnection,
- note: model::Note,
-) -> Result {
+fn display(connection: &mut Library, note: model::Note) -> Result {
let mut decorated = model::decorate_now(note);
decorated.attachment_count = attachments::store::count_for(connection, &decorated.id)?;
model::apply_global_defaults(
diff --git a/src-tauri/src/notes/store.rs b/src-tauri/src/notes/store.rs
index 838db4b..2c305c2 100644
--- a/src-tauri/src/notes/store.rs
+++ b/src-tauri/src/notes/store.rs
@@ -12,10 +12,11 @@ use super::checklist;
use super::model::{self, Note, NoteDraft, NoteLifecycle, NotePatch};
use super::placeholder;
use super::view::{Facets, NoteFilter, NotesQuery};
-use crate::db::iso8601;
use crate::db::schema::{global_placeholders, note_tags, notes};
+use crate::db::{Library, iso8601};
use crate::error::StorageError;
use crate::spaces::store as spaces;
+use crate::vault::key::Vault;
#[derive(Queryable, Selectable, Insertable)]
#[diesel(table_name = notes)]
@@ -35,13 +36,15 @@ pub(super) struct NoteRow {
kind: String,
}
+/// ⚠️ Not a `TryFrom`: opening a row needs the key, and a trait cannot take one. The
+/// same goes for [`NoteRow::seal`] in the other direction.
+///
/// An unreadable date fails the read: these columns are only ever written by
/// [`iso8601::format`]. Language and `kind` degrade instead — a newer version may have
-/// written a value this build does not know.
-impl TryFrom for Note {
- type Error = StorageError;
-
- fn try_from(row: NoteRow) -> Result {
+/// written a value this build does not know. ⚠️ A value that will not open does **not**
+/// degrade: a wrong key must stop the read rather than hand back plausible emptiness.
+impl NoteRow {
+ fn open(row: Self, vault: &Vault) -> Result {
let instant = |field: &'static str, value: &str| {
iso8601::parse(value).map_err(|_| StorageError::CorruptRow {
id: row.id.clone(),
@@ -57,16 +60,16 @@ impl TryFrom for Note {
_ => NoteLifecycle::Permanent,
};
- Ok(Self {
+ Ok(Note {
created_at: instant("createdAt", &row.created_at)?,
updated_at: instant("updatedAt", &row.updated_at)?,
language: row.language.parse().unwrap_or_default(),
kind: row.kind.parse().unwrap_or_default(),
+ title: vault.open(&row.title)?,
+ content: vault.open(&row.content)?,
+ source: vault.open(&row.source)?,
id: row.id,
space_id: row.space_id,
- title: row.title,
- content: row.content,
- source: row.source,
tags: Vec::new(),
items: Vec::new(),
placeholder_values: BTreeMap::new(),
@@ -74,32 +77,41 @@ impl TryFrom for Note {
lifecycle,
})
}
-}
-impl From<&Note> for NoteRow {
- fn from(note: &Note) -> Self {
+ /// ⚠️ `space_id`, the instants, `pinned`, `language` and `kind` stay in the clear:
+ /// every one of them is filtered, ordered or grouped on in SQL, and sealing one would
+ /// move that work into Rust for no secret. What is sealed is what a reader would want.
+ fn seal(note: &Note, vault: &Vault) -> Result {
let (lifecycle_kind, lifecycle_expires_at) = match note.lifecycle {
NoteLifecycle::Permanent => ("permanent", None),
NoteLifecycle::Expires { at } => ("expires", Some(iso8601::format(at))),
};
- Self {
+ Ok(Self {
id: note.id.clone(),
space_id: note.space_id.clone(),
- title: note.title.clone(),
+ title: vault.seal(¬e.title)?,
language: note.language.to_string(),
- content: note.content.clone(),
- source: note.source.clone(),
+ content: vault.seal(¬e.content)?,
+ source: vault.seal(¬e.source)?,
pinned: note.pinned,
created_at: iso8601::format(note.created_at),
updated_at: iso8601::format(note.updated_at),
lifecycle_kind: lifecycle_kind.to_string(),
lifecycle_expires_at,
kind: note.kind.to_string(),
- }
+ })
}
}
+/// ⚠️ Every row or none: a value that will not open stops the read rather than handing
+/// back a note with an empty body. A wrong key is not a degraded note.
+fn open_all(rows: Vec, vault: &Vault) -> Result, StorageError> {
+ rows.into_iter()
+ .map(|row| NoteRow::open(row, vault))
+ .collect()
+}
+
pub(super) fn notes_of_space(
space_id: &str,
) -> diesel::helper_types::Filter<
@@ -111,34 +123,39 @@ pub(super) fn notes_of_space(
.filter(notes::space_id.eq(space_id.to_string()))
}
+/// ⚠️ The value is sealed like a note's own, the name is not: the name is the key rows
+/// are found by, and a variable called `host` is worth a good deal less than what it holds.
pub fn global_placeholder_values(
- connection: &mut SqliteConnection,
+ connection: &mut Library,
) -> Result, StorageError> {
- Ok(global_placeholders::table
+ let (db, vault) = connection.split();
+
+ global_placeholders::table
.select((global_placeholders::name, global_placeholders::value))
.order(global_placeholders::name.asc())
- .load::<(String, String)>(connection)?
+ .load::<(String, String)>(db)?
.into_iter()
- .collect())
+ .map(|(name, value)| Ok((name, vault.open(&value)?)))
+ .collect::, StorageError>>()
}
pub fn replace_global_placeholder_values(
- connection: &mut SqliteConnection,
+ connection: &mut Library,
values: &BTreeMap,
) -> Result<(), StorageError> {
- connection.transaction(|connection| {
+ connection.transaction(|connection, vault| {
diesel::delete(global_placeholders::table).execute(connection)?;
if !values.is_empty() {
let rows: Vec<_> = values
.iter()
.map(|(name, value)| {
- (
+ Ok((
global_placeholders::name.eq(name),
- global_placeholders::value.eq(value),
- )
+ global_placeholders::value.eq(vault.seal(value)?),
+ ))
})
- .collect();
+ .collect::, StorageError>>()?;
diesel::insert_into(global_placeholders::table)
.values(rows)
.execute(connection)?;
@@ -149,10 +166,7 @@ pub fn replace_global_placeholder_values(
}
/// Scoped to the space and not to the current filter — see [`NotesView`].
-fn facets(
- connection: &mut SqliteConnection,
- space_id: Option<&str>,
-) -> Result {
+fn facets(connection: &mut Library, space_id: Option<&str>) -> Result {
let mut tags = note_tags::table
.inner_join(notes::table)
.filter(notes::deleted_at.is_null())
@@ -173,10 +187,10 @@ fn facets(
}
Ok(Facets {
- tags: tags.load::(connection)?,
+ tags: tags.load::(connection.db())?,
// A stored language this build does not know has no facet to offer.
languages: languages
- .load::(connection)?
+ .load::(connection.db())?
.iter()
.filter_map(|language| language.parse().ok())
.collect(),
@@ -185,7 +199,7 @@ fn facets(
/// Coarse criteria only; `view::build` takes over for search and sections.
pub fn fetch(
- connection: &mut SqliteConnection,
+ connection: &mut Library,
request: &NotesQuery,
) -> Result<(Vec, Facets), StorageError> {
let mut query = notes::table
@@ -222,19 +236,21 @@ pub fn fetch(
// On `updated_at` although the sections group on `created_at`: the section says when
// a note was born, the order within it which one moved last.
- let mut notes = query
+ let rows = query
.order((notes::updated_at.desc(), notes::id.asc()))
- .load::(connection)?
- .into_iter()
- .map(Note::try_from)
- .collect::, _>>()?;
-
- related::attach_related(connection, &mut notes, request.space_id.as_deref())?;
+ .load::(connection.db())?;
+ let (db, vault) = connection.split();
+ let mut notes = open_all(rows, vault)?;
+ related::attach_related(db, vault, &mut notes, request.space_id.as_deref())?;
Ok((notes, facets(connection, request.space_id.as_deref())?))
}
-fn find(connection: &mut SqliteConnection, id: &str) -> Result, StorageError> {
+fn find(
+ connection: &mut SqliteConnection,
+ vault: &Vault,
+ id: &str,
+) -> Result , StorageError> {
let Some(row) = notes::table
.find(id)
.filter(notes::deleted_at.is_null())
@@ -245,20 +261,24 @@ fn find(connection: &mut SqliteConnection, id: &str) -> Result , Sto
return Ok(None);
};
+ let tags = related::tags_of(connection, id)?;
+ let items = related::items_of(connection, vault, id)?;
+ let placeholder_values = related::placeholder_values_of(connection, vault, id)?;
+
Ok(Some(Note {
- tags: related::tags_of(connection, id)?,
- items: related::items_of(connection, id)?,
- placeholder_values: related::placeholder_values_of(connection, id)?,
- ..Note::try_from(row)?
+ tags,
+ items,
+ placeholder_values,
+ ..NoteRow::open(row, vault)?
}))
}
pub fn create(
- connection: &mut SqliteConnection,
+ connection: &mut Library,
draft: NoteDraft,
now: DateTime,
) -> Result {
- connection.transaction(|connection| {
+ connection.transaction(|connection, vault| {
if !spaces::exists(connection, &draft.space_id)? {
return Err(StorageError::SpaceNotFound(draft.space_id));
}
@@ -266,24 +286,24 @@ pub fn create(
let mut note = draft.into_note(Uuid::new_v4().to_string(), now);
diesel::insert_into(notes::table)
- .values(NoteRow::from(¬e))
+ .values(NoteRow::seal(¬e, vault)?)
.execute(connection)?;
let written = std::mem::take(&mut note.tags);
note.tags = related::replace_tags(connection, ¬e.id, &written)?;
- related::replace_items(connection, ¬e.id, ¬e.items)?;
+ related::replace_items(connection, vault, ¬e.id, ¬e.items)?;
Ok(note)
})
}
pub fn update(
- connection: &mut SqliteConnection,
+ connection: &mut Library,
id: &str,
patch: &NotePatch,
now: DateTime,
) -> Result {
- connection.transaction(|connection| {
- let Some(mut note) = find(connection, id)? else {
+ connection.transaction(|connection, vault| {
+ let Some(mut note) = find(connection, vault, id)? else {
return Err(StorageError::NoteNotFound(id.to_string()));
};
@@ -297,7 +317,7 @@ pub fn update(
// Columns listed rather than an `AsChangeset`, which would also rewrite
// `created_at`.
- let row = NoteRow::from(¬e);
+ let row = NoteRow::seal(¬e, vault)?;
diesel::update(notes::table.find(¬e.id))
.set((
notes::space_id.eq(&row.space_id),
@@ -319,7 +339,7 @@ pub fn update(
}
if patch.items.is_some() {
- related::replace_items(connection, ¬e.id, ¬e.items)?;
+ related::replace_items(connection, vault, ¬e.id, ¬e.items)?;
}
Ok(note)
@@ -329,16 +349,16 @@ pub fn update(
/// ⚠️ `updated_at` is not touched: filling a field is not editing the note, and the
/// canvas sorts on that column.
pub fn set_placeholder_values(
- connection: &mut SqliteConnection,
+ connection: &mut Library,
id: &str,
values: &BTreeMap,
) -> Result {
- connection.transaction(|connection| {
- let Some(mut note) = find(connection, id)? else {
+ connection.transaction(|connection, vault| {
+ let Some(mut note) = find(connection, vault, id)? else {
return Err(StorageError::NoteNotFound(id.to_string()));
};
- related::replace_placeholder_values(connection, id, values)?;
+ related::replace_placeholder_values(connection, vault, id, values)?;
note.placeholder_values = values.clone();
Ok(note)
@@ -346,7 +366,7 @@ pub fn set_placeholder_values(
}
pub fn move_many(
- connection: &mut SqliteConnection,
+ connection: &mut Library,
ids: &[String],
space_id: &str,
now: DateTime,
@@ -355,7 +375,7 @@ pub fn move_many(
return Ok(0);
}
- connection.transaction(|connection| {
+ connection.transaction(|connection, _vault| {
if !spaces::exists(connection, space_id)? {
return Err(StorageError::SpaceNotFound(space_id.to_string()));
}
@@ -375,7 +395,7 @@ pub fn move_many(
}
pub fn tag_many(
- connection: &mut SqliteConnection,
+ connection: &mut Library,
ids: &[String],
tags: &[String],
now: DateTime,
@@ -384,7 +404,7 @@ pub fn tag_many(
return Ok(0);
}
- connection.transaction(|connection| {
+ connection.transaction(|connection, _vault| {
let targets = notes::table
.filter(notes::id.eq_any(ids))
.filter(notes::deleted_at.is_null())
@@ -412,20 +432,20 @@ pub fn tag_many(
})
}
-pub fn tag_usage(connection: &mut SqliteConnection) -> Result, StorageError> {
+pub fn tag_usage(connection: &mut Library) -> Result, StorageError> {
Ok(note_tags::table
.inner_join(notes::table)
.filter(notes::deleted_at.is_null())
.group_by(note_tags::tag)
.select((note_tags::tag, diesel::dsl::count_star()))
.order(note_tags::tag.asc())
- .load::<(String, i64)>(connection)?)
+ .load::<(String, i64)>(connection.db())?)
}
/// ⚠️ `updated_at` stays intact — the canvas sorts on it, and a corpus-wide rename would
/// float up notes nobody reopened.
pub fn retag(
- connection: &mut SqliteConnection,
+ connection: &mut Library,
sources: &[String],
target: &str,
) -> Result {
@@ -433,7 +453,7 @@ pub fn retag(
return Ok(0);
}
- connection.transaction(|connection| {
+ connection.transaction(|connection, _vault| {
let renamed = note_tags::table
.filter(note_tags::tag.eq_any(sources))
.select(note_tags::note_id)
@@ -469,22 +489,19 @@ pub fn retag(
})
}
-pub fn drop_tags(
- connection: &mut SqliteConnection,
- tags: &[String],
-) -> Result {
+pub fn drop_tags(connection: &mut Library, tags: &[String]) -> Result {
if tags.is_empty() {
return Ok(0);
}
- Ok(diesel::delete(note_tags::table.filter(note_tags::tag.eq_any(tags))).execute(connection)?)
+ Ok(
+ diesel::delete(note_tags::table.filter(note_tags::tag.eq_any(tags)))
+ .execute(connection.db())?,
+ )
}
/// Export only: no command hands this list to the front, which would re-filter it.
-pub fn all(
- connection: &mut SqliteConnection,
- space_id: Option<&str>,
-) -> Result, StorageError> {
+pub fn all(connection: &mut Library, space_id: Option<&str>) -> Result, StorageError> {
let mut query = notes::table
.filter(notes::deleted_at.is_null())
.select(NoteRow::as_select())
@@ -494,46 +511,46 @@ pub fn all(
query = query.filter(notes::space_id.eq(space_id.to_string()));
}
- let mut notes = query
+ let rows = query
.order((notes::created_at.asc(), notes::id.asc()))
- .load::(connection)?
- .into_iter()
- .map(Note::try_from)
- .collect::, _>>()?;
-
- related::attach_related(connection, &mut notes, space_id)?;
+ .load::(connection.db())?;
+ let (db, vault) = connection.split();
+ let mut notes = open_all(rows, vault)?;
+ related::attach_related(db, vault, &mut notes, space_id)?;
Ok(notes)
}
-pub fn by_ids(
- connection: &mut SqliteConnection,
- ids: &[String],
-) -> Result, StorageError> {
+pub fn by_ids(connection: &mut Library, ids: &[String]) -> Result, StorageError> {
if ids.is_empty() {
return Ok(Vec::new());
}
- let mut notes = notes::table
+ let rows = notes::table
.filter(notes::id.eq_any(ids))
.filter(notes::deleted_at.is_null())
.select(NoteRow::as_select())
.order((notes::created_at.asc(), notes::id.asc()))
- .load::(connection)?
- .into_iter()
- .map(Note::try_from)
- .collect::, _>>()?;
-
- related::attach_related(connection, &mut notes, None)?;
+ .load::(connection.db())?;
+ let (db, vault) = connection.split();
+ let mut notes = open_all(rows, vault)?;
+ related::attach_related(db, vault, &mut notes, None)?;
Ok(notes)
}
-pub fn insert_imported(
+pub fn insert_imported(connection: &mut Library, note: &Note) -> Result {
+ connection.transaction(|connection, vault| insert_imported_in(connection, vault, note))
+}
+
+/// For a caller already inside a transaction: an import is one transaction for the whole
+/// file, and every note it brings in runs inside it.
+pub(crate) fn insert_imported_in(
connection: &mut SqliteConnection,
+ vault: &Vault,
note: &Note,
) -> Result {
- connection.transaction(|connection| {
+ {
let taken = notes::table
.find(¬e.id)
.select(notes::id)
@@ -545,20 +562,22 @@ pub fn insert_imported(
}
diesel::insert_into(notes::table)
- .values(NoteRow::from(note))
+ .values(NoteRow::seal(note, vault)?)
.execute(connection)?;
related::replace_tags(connection, ¬e.id, &model::normalize_tags(¬e.tags))?;
related::replace_items(
connection,
+ vault,
¬e.id,
&checklist::normalize_items(¬e.items),
)?;
related::replace_placeholder_values(
connection,
+ vault,
¬e.id,
&placeholder::normalize_values(note.placeholder_values.clone()),
)?;
Ok(true)
- })
+ }
}
diff --git a/src-tauri/src/notes/store/related.rs b/src-tauri/src/notes/store/related.rs
index 3ebeec7..b2d340b 100644
--- a/src-tauri/src/notes/store/related.rs
+++ b/src-tauri/src/notes/store/related.rs
@@ -10,6 +10,7 @@ use crate::db::schema::{note_items, note_placeholders, note_tags};
use crate::error::StorageError;
use crate::notes::checklist::ChecklistItem;
use crate::notes::model::Note;
+use crate::vault::key::Vault;
/// ⚠️ Narrowed by subquery, not by a list of bound ids: binding one parameter per note
/// measured slower than reading the table whole past a few thousand notes. Reading a
@@ -70,6 +71,7 @@ pub fn replace_tags(
pub fn all_items(
connection: &mut SqliteConnection,
+ vault: &Vault,
space_id: Option<&str>,
) -> Result>, StorageError> {
let mut query = note_items::table
@@ -83,10 +85,10 @@ pub fn all_items(
let mut grouped: HashMap> = HashMap::new();
for (note_id, text, done) in query.load::<(String, String, bool)>(connection)? {
- grouped
- .entry(note_id)
- .or_default()
- .push(ChecklistItem { text, done });
+ grouped.entry(note_id).or_default().push(ChecklistItem {
+ text: vault.open(&text)?,
+ done,
+ });
}
Ok(grouped)
@@ -94,22 +96,29 @@ pub fn all_items(
pub fn items_of(
connection: &mut SqliteConnection,
+ vault: &Vault,
note_id: &str,
) -> Result, StorageError> {
- Ok(note_items::table
+ note_items::table
.filter(note_items::note_id.eq(note_id))
.select((note_items::text, note_items::done))
.order(note_items::position.asc())
.load::<(String, bool)>(connection)?
.into_iter()
- .map(|(text, done)| ChecklistItem { text, done })
- .collect())
+ .map(|(text, done)| {
+ Ok(ChecklistItem {
+ text: vault.open(&text)?,
+ done,
+ })
+ })
+ .collect()
}
/// Wiped then reinserted: the position is part of the key, so reordering would otherwise
/// move rows one at a time under a key that refuses duplicates.
pub fn replace_items(
connection: &mut SqliteConnection,
+ vault: &Vault,
note_id: &str,
items: &[ChecklistItem],
) -> Result<(), StorageError> {
@@ -121,14 +130,14 @@ pub fn replace_items(
.iter()
.enumerate()
.map(|(position, item)| {
- (
+ Ok((
note_items::note_id.eq(note_id),
note_items::position.eq(i32::try_from(position).unwrap_or(i32::MAX)),
- note_items::text.eq(&item.text),
+ note_items::text.eq(vault.seal(&item.text)?),
note_items::done.eq(item.done),
- )
+ ))
})
- .collect();
+ .collect::, StorageError>>()?;
diesel::insert_into(note_items::table)
.values(rows)
.execute(connection)?;
@@ -138,6 +147,7 @@ pub fn replace_items(
}
pub fn attach_related(
connection: &mut SqliteConnection,
+ vault: &Vault,
notes: &mut [Note],
space_id: Option<&str>,
) -> Result<(), StorageError> {
@@ -146,8 +156,8 @@ pub fn attach_related(
}
let mut tags = all_tags(connection, space_id)?;
- let mut items = all_items(connection, space_id)?;
- let mut values = all_placeholder_values(connection, space_id)?;
+ let mut items = all_items(connection, vault, space_id)?;
+ let mut values = all_placeholder_values(connection, vault, space_id)?;
for note in notes {
note.tags = tags.remove(¬e.id).unwrap_or_default();
@@ -160,6 +170,7 @@ pub fn attach_related(
pub fn all_placeholder_values(
connection: &mut SqliteConnection,
+ vault: &Vault,
space_id: Option<&str>,
) -> Result>, StorageError> {
let mut query = note_placeholders::table
@@ -176,7 +187,10 @@ pub fn all_placeholder_values(
let mut grouped: HashMap> = HashMap::new();
for (note_id, name, value) in query.load::<(String, String, String)>(connection)? {
- grouped.entry(note_id).or_default().insert(name, value);
+ grouped
+ .entry(note_id)
+ .or_default()
+ .insert(name, vault.open(&value)?);
}
Ok(grouped)
@@ -184,18 +198,21 @@ pub fn all_placeholder_values(
pub fn placeholder_values_of(
connection: &mut SqliteConnection,
+ vault: &Vault,
note_id: &str,
) -> Result, StorageError> {
- Ok(note_placeholders::table
+ note_placeholders::table
.filter(note_placeholders::note_id.eq(note_id))
.select((note_placeholders::name, note_placeholders::value))
.load::<(String, String)>(connection)?
.into_iter()
- .collect())
+ .map(|(name, value)| Ok((name, vault.open(&value)?)))
+ .collect::, StorageError>>()
}
pub fn replace_placeholder_values(
connection: &mut SqliteConnection,
+ vault: &Vault,
note_id: &str,
values: &BTreeMap,
) -> Result<(), StorageError> {
@@ -206,13 +223,13 @@ pub fn replace_placeholder_values(
let rows: Vec<_> = values
.iter()
.map(|(name, value)| {
- (
+ Ok((
note_placeholders::note_id.eq(note_id),
note_placeholders::name.eq(name),
- note_placeholders::value.eq(value),
- )
+ note_placeholders::value.eq(vault.seal(value)?),
+ ))
})
- .collect();
+ .collect::, StorageError>>()?;
diesel::insert_into(note_placeholders::table)
.values(rows)
.execute(connection)?;
diff --git a/src-tauri/src/notes/store/trash.rs b/src-tauri/src/notes/store/trash.rs
index de46754..42f8c79 100644
--- a/src-tauri/src/notes/store/trash.rs
+++ b/src-tauri/src/notes/store/trash.rs
@@ -8,6 +8,7 @@ use chrono::{DateTime, Utc};
use diesel::prelude::*;
use super::{NoteRow, related};
+use crate::db::Library;
use crate::db::iso8601;
use crate::db::schema::notes;
use crate::error::StorageError;
@@ -17,11 +18,7 @@ use crate::notes::trash;
/// ⚠️ Stamps `deleted_at`; the row survives for [`trash::RETENTION`]. [`purge`] is what
/// erases — `delete` is the word `spaces::store` and `attachments::store` use for an
/// irreversible one.
-pub fn trash(
- connection: &mut SqliteConnection,
- id: &str,
- now: DateTime,
-) -> Result<(), StorageError> {
+pub fn trash(connection: &mut Library, id: &str, now: DateTime) -> Result<(), StorageError> {
if trash_many(connection, std::slice::from_ref(&id.to_string()), now)? == 0 {
return Err(StorageError::NoteNotFound(id.to_string()));
}
@@ -32,7 +29,7 @@ pub fn trash(
/// Returns what was actually moved: a selection can hold an id gone stale, and failing
/// the whole batch for one of them would be worse than a partial result.
pub fn trash_many(
- connection: &mut SqliteConnection,
+ connection: &mut Library,
ids: &[String],
now: DateTime,
) -> Result {
@@ -46,14 +43,11 @@ pub fn trash_many(
.filter(notes::deleted_at.is_null()),
)
.set(notes::deleted_at.eq(iso8601::format(now)))
- .execute(connection)?)
+ .execute(connection.db())?)
}
/// `updated_at` is not touched: the note comes back where it was.
-pub fn restore_many(
- connection: &mut SqliteConnection,
- ids: &[String],
-) -> Result {
+pub fn restore_many(connection: &mut Library, ids: &[String]) -> Result {
if ids.is_empty() {
return Ok(0);
}
@@ -64,19 +58,18 @@ pub fn restore_many(
.filter(notes::deleted_at.is_not_null()),
)
.set(notes::deleted_at.eq(None::))
- .execute(connection)?)
+ .execute(connection.db())?)
}
-pub fn list_trashed(
- connection: &mut SqliteConnection,
-) -> Result)>, StorageError> {
+pub fn list_trashed(connection: &mut Library) -> Result)>, StorageError> {
let rows = notes::table
.filter(notes::deleted_at.is_not_null())
.select((NoteRow::as_select(), notes::deleted_at))
.order((notes::deleted_at.desc(), notes::id.asc()))
- .load::<(NoteRow, Option)>(connection)?;
+ .load::<(NoteRow, Option)>(connection.db())?;
let mut grouped = related::all_tags(connection, None)?;
+ let vault = connection.vault();
rows.into_iter()
.map(|(row, deleted_at)| {
let id = row.id.clone();
@@ -89,7 +82,7 @@ pub fn list_trashed(
Ok((
Note {
tags: grouped.remove(&id).unwrap_or_default(),
- ..Note::try_from(row)?
+ ..NoteRow::open(row, vault)?
},
deleted_at,
))
@@ -99,13 +92,13 @@ pub fn list_trashed(
/// Separate from [`purge`] so the caller can erase the attached files first.
pub fn expired_ids(
- connection: &mut SqliteConnection,
+ connection: &mut Library,
now: DateTime,
) -> Result, StorageError> {
let rows = notes::table
.filter(notes::deleted_at.is_not_null())
.select((notes::id, notes::deleted_at))
- .load::<(String, Option)>(connection)?;
+ .load::<(String, Option)>(connection.db())?;
Ok(rows
.into_iter()
@@ -116,16 +109,16 @@ pub fn expired_ids(
.collect())
}
-pub fn trashed_ids(connection: &mut SqliteConnection) -> Result, StorageError> {
+pub fn trashed_ids(connection: &mut Library) -> Result, StorageError> {
Ok(notes::table
.filter(notes::deleted_at.is_not_null())
.select(notes::id)
- .load::(connection)?)
+ .load::(connection.db())?)
}
/// Permanent. Tags and attachments leave by cascade — hence the `PRAGMA foreign_keys` in
/// `db::configure`. ⚠️ Restricted to trashed notes: nothing may short-circuit the reprieve.
-pub fn purge(connection: &mut SqliteConnection, ids: &[String]) -> Result {
+pub fn purge(connection: &mut Library, ids: &[String]) -> Result {
if ids.is_empty() {
return Ok(0);
}
@@ -135,5 +128,5 @@ pub fn purge(connection: &mut SqliteConnection, ids: &[String]) -> Result Result, StorageError> {
- let rows = spaces::table
+/// Pinned first, then by name — the same shape the canvas gives notes. Folded for the
+/// comparison, so "personal" does not land after "Zebra".
+fn in_display_order(spaces: &mut [Space]) {
+ spaces.sort_by(|left, right| {
+ right
+ .pinned
+ .cmp(&left.pinned)
+ .then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase()))
+ });
+}
+
+fn all(connection: &mut SqliteConnection, vault: &Vault) -> Result, StorageError> {
+ spaces::table
.select((spaces::id, spaces::name, spaces::pinned))
- // Pinned first, then by name — the same shape the canvas gives notes.
- .order(spaces::pinned.desc())
- // ⚠️ Raw fragment: Diesel does not model collations, and sorting as BINARY would
- // place "personal" after "Zebra".
- .then_order_by(sql::("name COLLATE NOCASE"))
- .load::<(String, String, bool)>(connection)?;
-
- Ok(rows
+ .load::<(String, String, bool)>(connection)?
.into_iter()
- .map(|(id, name, pinned)| Space { id, name, pinned })
- .collect())
+ .map(|(id, name, pinned)| {
+ Ok(Space {
+ id,
+ name: vault.open(&name)?,
+ pinned,
+ })
+ })
+ .collect()
+}
+
+/// An empty list is valid: it is the state of the first launch.
+pub fn list(connection: &mut Library) -> Result, StorageError> {
+ let (db, vault) = connection.split();
+
+ list_in(db, vault)
+}
+
+/// For a caller already inside a transaction, which holds the two halves apart.
+pub(crate) fn list_in(
+ connection: &mut SqliteConnection,
+ vault: &Vault,
+) -> Result, StorageError> {
+ let mut spaces = all(connection, vault)?;
+ in_display_order(&mut spaces);
+
+ Ok(spaces)
}
/// Reads back, so a rename does not quietly drop whether the space was pinned.
-fn find(connection: &mut SqliteConnection, id: &str) -> Result {
+fn find(connection: &mut SqliteConnection, vault: &Vault, id: &str) -> Result {
spaces::table
.find(id)
.select((spaces::id, spaces::name, spaces::pinned))
.first::<(String, String, bool)>(connection)
.optional()?
- .map(|(id, name, pinned)| Space { id, name, pinned })
+ .map(|(id, name, pinned)| -> Result {
+ Ok(Space {
+ id,
+ name: vault.open(&name)?,
+ pinned,
+ })
+ })
+ .transpose()?
.ok_or_else(|| StorageError::SpaceNotFound(id.to_string()))
}
-pub fn set_pinned(
- connection: &mut SqliteConnection,
- id: &str,
- pinned: bool,
-) -> Result {
- connection.transaction(|connection| {
+pub fn set_pinned(connection: &mut Library, id: &str, pinned: bool) -> Result {
+ connection.transaction(|connection, vault| {
if !exists(connection, id)? {
return Err(StorageError::SpaceNotFound(id.to_string()));
}
@@ -49,7 +86,7 @@ pub fn set_pinned(
.set(spaces::pinned.eq(pinned))
.execute(connection)?;
- find(connection, id)
+ find(connection, vault, id)
})
}
@@ -67,26 +104,21 @@ pub fn exists(connection: &mut SqliteConnection, id: &str) -> Result,
) -> Result<(), StorageError> {
- // ⚠️ `spaces.name` is not declared `NOCASE` — only the unique index is — so the
- // collation must be set on the comparison, or "PERSONAL" would miss "Personal".
- let mut query = spaces::table
- .filter(
- sql::("name = ")
- .bind::(name.to_string())
- .sql(" COLLATE NOCASE"),
- )
- .into_boxed();
-
- if let Some(id) = except_id {
- query = query.filter(spaces::id.ne(id.to_string()));
- }
+ let taken = all(connection, vault)?.into_iter().any(|space| {
+ Some(space.id.as_str()) != except_id && space.name.to_lowercase() == name.to_lowercase()
+ });
- if query.count().get_result::(connection)? > 0 {
+ if taken {
return Err(StorageError::DuplicateSpaceName(name.to_string()));
}
@@ -95,9 +127,18 @@ fn ensure_unique_name(
/// `name` is expected already validated: this layer only decides uniqueness, and the
/// transaction is what pairs the check with the write.
-pub fn create(connection: &mut SqliteConnection, name: &str) -> Result {
- connection.transaction(|connection| {
- ensure_unique_name(connection, name, None)?;
+pub fn create(connection: &mut Library, name: &str) -> Result {
+ connection.transaction(|connection, vault| create_in(connection, vault, name))
+}
+
+/// For a caller already inside a transaction — an import creates the spaces it needs.
+pub(crate) fn create_in(
+ connection: &mut SqliteConnection,
+ vault: &Vault,
+ name: &str,
+) -> Result {
+ {
+ ensure_unique_name(connection, vault, name, None)?;
let space = Space {
id: Uuid::new_v4().to_string(),
@@ -106,42 +147,37 @@ pub fn create(connection: &mut SqliteConnection, name: &str) -> Result Result {
- connection.transaction(|connection| {
+pub fn rename(connection: &mut Library, id: &str, name: &str) -> Result {
+ connection.transaction(|connection, vault| {
if !exists(connection, id)? {
return Err(StorageError::SpaceNotFound(id.to_string()));
}
- ensure_unique_name(connection, name, Some(id))?;
+ ensure_unique_name(connection, vault, name, Some(id))?;
diesel::update(spaces::table.find(id))
- .set(spaces::name.eq(name))
+ .set(spaces::name.eq(vault.seal(name)?))
.execute(connection)?;
- find(connection, id)
+ find(connection, vault, id)
})
}
/// ⚠️ Same transaction and this order: `notes.space_id` has an `ON DELETE CASCADE`, so
/// deleting first — or failing between the two — sweeps away the notes instead of moving
/// them. `updated_at` is not refreshed, or the absorbed space floats to the top.
-pub fn delete(
- connection: &mut SqliteConnection,
- id: &str,
- target_id: &str,
-) -> Result<(), StorageError> {
- connection.transaction(|connection| {
+pub fn delete(connection: &mut Library, id: &str, target_id: &str) -> Result<(), StorageError> {
+ connection.transaction(|connection, _vault| {
if !exists(connection, id)? {
return Err(StorageError::SpaceNotFound(id.to_string()));
}
diff --git a/src-tauri/src/transfer.rs b/src-tauri/src/transfer.rs
index a53d9ca..ed439f5 100644
--- a/src-tauri/src/transfer.rs
+++ b/src-tauri/src/transfer.rs
@@ -3,9 +3,11 @@
pub mod bundle;
pub mod file;
pub mod model;
+pub mod protect;
-use tauri::State;
+use tauri::{AppHandle, State};
+use crate::attachments;
use crate::db::{Db, lock};
use crate::error::AppError;
use crate::notes::store as notes;
@@ -17,17 +19,23 @@ use model::{ExportReport, ImportReport};
pub fn export_notes(
path: String,
space_id: Option,
+ passphrase: Option,
+ app: AppHandle,
db: State<'_, Db>,
) -> Result {
model::validate_path(&path)?;
- let exported = {
- let mut connection = lock(&db)?;
- let notes = notes::all(&mut connection, space_id.as_deref())?;
- bundle::collect(&mut connection, notes)?
- };
+ let mut connection = lock(&db)?;
+ let notes = notes::all(&mut connection, space_id.as_deref())?;
+ let exported = bundle::collect(&mut connection, notes)?;
- file::write(&path, &exported)
+ file::write(
+ &path,
+ &exported,
+ &attachments::directory(&app)?,
+ connection.vault(),
+ passphrase.as_deref(),
+ )
}
#[tauri::command(async)]
@@ -35,31 +43,57 @@ pub fn export_notes(
pub fn export_selection(
path: String,
ids: Vec,
+ passphrase: Option,
+ app: AppHandle,
db: State<'_, Db>,
) -> Result {
model::validate_path(&path)?;
- let exported = {
- let mut connection = lock(&db)?;
- let notes = notes::by_ids(&mut connection, &ids)?;
- bundle::collect(&mut connection, notes)?
- };
+ let mut connection = lock(&db)?;
+ let notes = notes::by_ids(&mut connection, &ids)?;
+ let exported = bundle::collect(&mut connection, notes)?;
- file::write(&path, &exported)
+ file::write(
+ &path,
+ &exported,
+ &attachments::directory(&app)?,
+ connection.vault(),
+ passphrase.as_deref(),
+ )
}
/// ⚠️ The file is read before the lock is taken: parsing a large export while holding the
/// connection would block every other command for the length of it.
#[tauri::command(async)]
#[specta::specta]
-pub fn import_notes(path: String, db: State<'_, Db>) -> Result {
+pub fn import_notes(
+ path: String,
+ passphrase: Option,
+ app: AppHandle,
+ db: State<'_, Db>,
+) -> Result {
model::validate_path(&path)?;
- let imported = file::read(&path)?;
+ let (imported, mut payload) = file::read(&path, passphrase.as_deref())?;
+ let directory = attachments::directory(&app)?;
let mut connection = lock(&db)?;
- Ok(bundle::merge(&mut connection, imported)?)
+ Ok(bundle::merge(
+ &mut connection,
+ imported,
+ &mut payload,
+ &directory,
+ )?)
+}
+
+/// Whether an import will want a phrase, so the interface can ask before it starts.
+#[tauri::command(async)]
+#[specta::specta]
+pub fn export_is_protected(path: String) -> Result {
+ model::validate_path(&path)?;
+
+ file::is_protected(&path)
}
/// Nothing is sent anywhere: "share" stops at the clipboard.
diff --git a/src-tauri/src/transfer/bundle.rs b/src-tauri/src/transfer/bundle.rs
index 14b6e5f..d2f6a26 100644
--- a/src-tauri/src/transfer/bundle.rs
+++ b/src-tauri/src/transfer/bundle.rs
@@ -1,33 +1,38 @@
//! The commands in `transfer.rs` open the file and hold the lock; the rules live here.
-use std::collections::BTreeMap;
+use std::collections::{BTreeMap, BTreeSet};
+use std::path::Path;
use chrono::Utc;
use diesel::SqliteConnection;
-use diesel::prelude::*;
+use super::file::Payload;
use super::model::{self, Bundle, ImportReport, IncomingBundle};
+use crate::attachments::model::Attachment;
+use crate::attachments::store as attachments;
+use crate::db::Library;
use crate::error::StorageError;
use crate::notes::model::Note;
use crate::notes::store as notes;
use crate::spaces::model::Space;
use crate::spaces::store as spaces;
+use crate::vault::key::Vault;
/// Only the spaces actually cited travel with the notes: exporting one space must not
/// recreate the whole tree for whoever imports it.
-pub fn collect(
- connection: &mut SqliteConnection,
- exported: Vec,
-) -> Result {
+pub fn collect(connection: &mut Library, exported: Vec) -> Result {
let spaces: Vec = spaces::list(connection)?
.into_iter()
.filter(|space| exported.iter().any(|note| note.space_id == space.id))
.collect();
+ let note_ids: Vec = exported.iter().map(|note| note.id.clone()).collect();
+
Ok(Bundle {
version: model::FORMAT_VERSION,
exported_at: Utc::now(),
spaces,
+ attachments: attachments::for_notes(connection, ¬e_ids)?,
notes: exported,
})
}
@@ -38,16 +43,19 @@ pub fn collect(
/// ⚠️ One transaction for the whole file, or a failure halfway leaves spaces created and
/// part of the notes in, with the report lost along with the error.
pub fn merge(
- connection: &mut SqliteConnection,
+ connection: &mut Library,
incoming: IncomingBundle,
+ payload: &mut Payload,
+ directory: &Path,
) -> Result {
let IncomingBundle { bundle, degraded } = incoming;
- connection.transaction(|connection| {
+ connection.transaction(|connection, vault| {
let mut report = ImportReport::default();
+ let mut arrived: BTreeSet = BTreeSet::new();
let mut mapping: BTreeMap = BTreeMap::new();
- let existing = spaces::list(connection)?;
+ let existing = spaces::list_in(connection, vault)?;
for space in &bundle.spaces {
let matched = existing
@@ -58,7 +66,7 @@ pub fn merge(
candidate.id.clone()
} else {
report.spaces_created += 1;
- spaces::create(connection, &space.name)?.id
+ spaces::create_in(connection, vault, &space.name)?.id
};
mapping.insert(space.id.clone(), local_id);
}
@@ -72,8 +80,9 @@ pub fn merge(
};
note.space_id.clone_from(space_id);
- if notes::insert_imported(connection, ¬e)? {
+ if notes::insert_imported_in(connection, vault, ¬e)? {
report.notes_imported += 1;
+ arrived.insert(note.id.clone());
// Only what actually came in, or re-importing the same file would keep
// reporting the same degradation.
if degraded.contains(¬e.id) {
@@ -84,14 +93,56 @@ pub fn merge(
}
}
+ restore_attachments(
+ connection,
+ vault,
+ bundle.attachments,
+ &arrived,
+ payload,
+ directory,
+ &mut report,
+ )?;
+
Ok(report)
})
}
-/// The space names an export needs to render a note's breadcrumb.
-pub fn space_names(
+/// ⚠️ The file is written **before** the record, the rule `attachments.rs` already holds:
+/// a record without a file is a broken thumbnail, where a file without a record is swept
+/// at the next startup — which is also what collects these when the transaction rolls back.
+///
+/// Only attachments whose note actually arrived: one belonging to a skipped note is
+/// already in the library, and re-importing the same file has to add nothing.
+fn restore_attachments(
connection: &mut SqliteConnection,
-) -> Result, StorageError> {
+ vault: &Vault,
+ records: Vec,
+ arrived: &BTreeSet,
+ payload: &mut Payload,
+ directory: &Path,
+ report: &mut ImportReport,
+) -> Result<(), StorageError> {
+ for record in records {
+ if !arrived.contains(&record.note_id) {
+ continue;
+ }
+
+ let Some(bytes) = payload.take(&record.stored_name()) else {
+ report.attachments_missing += 1;
+ continue;
+ };
+
+ std::fs::write(directory.join(record.stored_name()), &bytes)
+ .map_err(|error| StorageError::File(format!("{}: {error}", record.stored_name())))?;
+ attachments::create(connection, vault, &record)?;
+ report.attachments_imported += 1;
+ }
+
+ Ok(())
+}
+
+/// The space names an export needs to render a note's breadcrumb.
+pub fn space_names(connection: &mut Library) -> Result, StorageError> {
Ok(spaces::list(connection)?
.into_iter()
.map(|space| (space.id, space.name))
diff --git a/src-tauri/src/transfer/file.rs b/src-tauri/src/transfer/file.rs
index d59d735..d96f8f4 100644
--- a/src-tauri/src/transfer/file.rs
+++ b/src-tauri/src/transfer/file.rs
@@ -1,42 +1,336 @@
//! Nothing here knows the database.
+//!
+//! The export is a zip: the bundle at the root, one entry per attachment under
+//! `attachments/`. ⚠️ Base64 inside the JSON was the obvious alternative and was refused:
+//! it costs a third more bytes, and the import path holds the file as a `String`, then a
+//! `serde_json::Value`, then a `Bundle` — three copies of every screenshot in memory.
+//! Archive entries are pulled one at a time instead.
+//!
+//! ⚠️ An export leaves the library's key behind. The attachment files on disk are sealed
+//! with a key that never leaves this machine, so they are opened on the way out and then
+//! either written in the clear — an unprotected export is portable and readable, which is
+//! what the exchange format exists for — or resealed under a key derived from the phrase
+//! the user gave this one file.
use std::ffi::{OsStr, OsString};
+use std::fs::File;
+use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use uuid::Uuid;
+use zip::write::SimpleFileOptions;
+use zip::{CompressionMethod, ZipArchive, ZipWriter};
use super::model::{Bundle, ExportReport, IncomingBundle};
+use super::protect::{self, Recipe};
+use crate::attachments::model::Attachment;
use crate::count::saturating_u32;
use crate::error::{AppError, StorageError};
+use crate::vault::key::Vault;
-/// ⚠️ Written beside the target then renamed: `fs::write` truncates first, so an export
-/// that ran out of disk destroyed the file it was overwriting.
-pub fn write(path: &str, bundle: &Bundle) -> Result {
- let report = ExportReport {
- notes: saturating_u32(bundle.notes.len()),
- spaces: saturating_u32(bundle.spaces.len()),
- };
+const BUNDLE_ENTRY: &str = "bundle.json";
+const ATTACHMENTS_ENTRY: &str = "attachments";
+
+/// ⚠️ Present only in a protected export, and it **replaces** `bundle.json` rather than
+/// sitting beside it: a reader that finds this and cannot open it must not fall back on a
+/// plaintext bundle that should not exist.
+const SEALED_ENTRY: &str = "bundle.sealed";
+
+/// Beside the sealed payload, in the clear: the salt and the cost are what a reader needs
+/// to derive the same key, and neither is a secret.
+const RECIPE_ENTRY: &str = "recipe.json";
+/// What a zip opens with. An export written before the archive existed is plain JSON and
+/// is still read: a new DevBox reads an old file, an old DevBox does not read a new one.
+const ZIP_MAGIC: [u8; 4] = [b'P', b'K', 0x03, 0x04];
+
+fn file_error(what: &str, error: &std::io::Error) -> StorageError {
+ StorageError::File(format!("{what}: {error}"))
+}
+
+fn zip_error(error: &zip::result::ZipError) -> StorageError {
+ StorageError::File(error.to_string())
+}
+
+fn deflated() -> SimpleFileOptions {
+ SimpleFileOptions::default().compression_method(CompressionMethod::Deflated)
+}
+
+/// A PNG is compressed already, and ciphertext does not compress at all.
+fn stored_as_is() -> SimpleFileOptions {
+ SimpleFileOptions::default().compression_method(CompressionMethod::Stored)
+}
+
+/// ⚠️ Written beside the target then renamed: a truncating write destroys the previous
+/// export the day the disk fills.
+///
+/// `passphrase` protects the archive. Without one the file is plaintext — every note,
+/// every screenshot — which is what the interface has to say before it writes one.
+pub fn write(
+ path: &str,
+ bundle: &Bundle,
+ attachments: &Path,
+ library: &Vault,
+ passphrase: Option<&str>,
+) -> Result {
let json = serde_json::to_string_pretty(bundle)
.map_err(|error| StorageError::File(error.to_string()))?;
+ let protection = passphrase.map(protect::seal_with).transpose()?;
+
let staged = staging_path(path);
- std::fs::write(&staged, json)
- .map_err(|error| StorageError::File(format!("{}: {error}", staged.display())))?;
+ let stored = match archive(
+ &staged,
+ &json,
+ &bundle.attachments,
+ attachments,
+ library,
+ protection.as_ref(),
+ ) {
+ Ok(stored) => stored,
+ Err(error) => {
+ let _ = std::fs::remove_file(&staged);
+ return Err(error.into());
+ }
+ };
if let Err(error) = std::fs::rename(&staged, path) {
let _ = std::fs::remove_file(&staged);
return Err(StorageError::File(format!("{path}: {error}")).into());
}
- Ok(report)
+ Ok(ExportReport {
+ notes: saturating_u32(bundle.notes.len()),
+ spaces: saturating_u32(bundle.spaces.len()),
+ attachments: stored,
+ protected: passphrase.is_some(),
+ })
}
-pub fn read(path: &str) -> Result {
- let json = std::fs::read_to_string(path)
- .map_err(|error| StorageError::File(format!("{path}: {error}")))?;
+fn archive(
+ staged: &Path,
+ json: &str,
+ records: &[Attachment],
+ source: &Path,
+ library: &Vault,
+ protection: Option<&(Vault, Recipe)>,
+) -> Result {
+ let target =
+ File::create(staged).map_err(|error| file_error(&staged.display().to_string(), &error))?;
+ let mut writer = ZipWriter::new(target);
+
+ // The recipe goes in first and in the clear: a reader has to know how to derive the
+ // key before it can be asked for a phrase.
+ if let Some((_, recipe)) = protection {
+ let written = serde_json::to_string_pretty(recipe)
+ .map_err(|error| StorageError::File(error.to_string()))?;
+ writer
+ .start_file(RECIPE_ENTRY, deflated())
+ .map_err(|error| zip_error(&error))?;
+ writer
+ .write_all(written.as_bytes())
+ .map_err(|error| file_error(RECIPE_ENTRY, &error))?;
+ }
+
+ if let Some((vault, _)) = protection {
+ writer
+ .start_file(SEALED_ENTRY, stored_as_is())
+ .map_err(|error| zip_error(&error))?;
+ writer
+ .write_all(&vault.seal_bytes(json.as_bytes())?)
+ .map_err(|error| file_error(SEALED_ENTRY, &error))?;
+ } else {
+ writer
+ .start_file(BUNDLE_ENTRY, deflated())
+ .map_err(|error| zip_error(&error))?;
+ writer
+ .write_all(json.as_bytes())
+ .map_err(|error| file_error(BUNDLE_ENTRY, &error))?;
+ }
- Ok(super::model::read_bundle(&json)?)
+ let mut stored = 0;
+ for record in records {
+ let name = record.stored_name();
+
+ // A record whose file has gone missing leaves the export rather than failing it:
+ // the note still travels, and `attachments_missing` says so on the way back in.
+ let Ok(sealed) = std::fs::read(source.join(&name)) else {
+ continue;
+ };
+
+ // Opened under the library's key, then written the way this export travels.
+ let plain = library.open_bytes(&sealed)?;
+ let bytes = match protection {
+ Some((vault, _)) => vault.seal_bytes(&plain)?,
+ None => plain,
+ };
+
+ writer
+ .start_file(format!("{ATTACHMENTS_ENTRY}/{name}"), stored_as_is())
+ .map_err(|error| zip_error(&error))?;
+ writer
+ .write_all(&bytes)
+ .map_err(|error| file_error(&name, &error))?;
+ stored += 1;
+ }
+
+ writer.finish().map_err(|error| zip_error(&error))?;
+
+ Ok(stored)
+}
+
+/// The bundle, and whatever carries the attachment bytes that belong with it.
+///
+/// ⚠️ Answers [`StorageError::PassphraseRequired`] on a protected file offered without
+/// one: nothing can tell a protected archive from an ordinary one until it has looked
+/// inside, so looking is this function's job rather than the interface's.
+pub fn read(path: &str, passphrase: Option<&str>) -> Result<(IncomingBundle, Payload), AppError> {
+ let Some(mut archive) = open_archive(path)? else {
+ let json = std::fs::read_to_string(path).map_err(|error| file_error(path, &error))?;
+ return Ok((super::model::read_bundle(&json)?, Payload::Empty));
+ };
+
+ let Some(recipe) = read_recipe(&mut archive)? else {
+ let raw = entry(&mut archive, BUNDLE_ENTRY)?;
+ let json = String::from_utf8(raw)
+ .map_err(|_| StorageError::ImportFormat("the bundle is not text".to_string()))?;
+
+ return Ok((
+ super::model::read_bundle(&json)?,
+ Payload::Archive {
+ archive: Box::new(archive),
+ vault: None,
+ },
+ ));
+ };
+
+ let Some(passphrase) = passphrase else {
+ return Err(StorageError::PassphraseRequired.into());
+ };
+
+ let vault = protect::open_with(passphrase, &recipe)?;
+ let sealed = entry(&mut archive, SEALED_ENTRY)?;
+
+ // ⚠️ The payload's own tag is the check: a phrase that does not open it is refused
+ // here, so the file carries no separate verifier to work against.
+ let raw = vault
+ .open_bytes(&sealed)
+ .map_err(|_| StorageError::WrongPassphrase)?;
+ let json = String::from_utf8(raw)
+ .map_err(|_| StorageError::ImportFormat("the bundle is not text".to_string()))?;
+
+ Ok((
+ super::model::read_bundle(&json)?,
+ Payload::Archive {
+ archive: Box::new(archive),
+ vault: Some(vault),
+ },
+ ))
+}
+
+/// Whether a file will want a phrase, asked without one so an interface can prompt.
+///
+/// ⚠️ The recipe and nothing else. Answering this through [`read`] would parse the whole
+/// bundle — a hundred megabytes on a large library — and then throw it away, for the import
+/// to parse it again a moment later.
+pub fn is_protected(path: &str) -> Result {
+ let Some(mut archive) = open_archive(path)? else {
+ return Ok(false);
+ };
+
+ Ok(read_recipe(&mut archive)?.is_some())
+}
+
+/// `None` for a file that is not a zip: a `.json` export written before the archive, which
+/// carries no attachments and cannot be protected.
+fn open_archive(path: &str) -> Result>, AppError> {
+ let mut file = File::open(path).map_err(|error| file_error(path, &error))?;
+
+ let mut magic = [0u8; 4];
+ let zipped = file.read_exact(&mut magic).is_ok() && magic == ZIP_MAGIC;
+ file.seek(SeekFrom::Start(0))
+ .map_err(|error| file_error(path, &error))?;
+
+ if !zipped {
+ return Ok(None);
+ }
+
+ Ok(Some(ZipArchive::new(file).map_err(|error| {
+ StorageError::ImportFormat(error.to_string())
+ })?))
+}
+
+fn read_recipe(archive: &mut ZipArchive) -> Result, StorageError> {
+ if archive.by_name(RECIPE_ENTRY).is_err() {
+ return Ok(None);
+ }
+
+ let raw = entry(archive, RECIPE_ENTRY)?;
+ let recipe: Recipe = serde_json::from_slice(&raw)
+ .map_err(|error| StorageError::ImportFormat(format!("unreadable recipe: {error}")))?;
+
+ Ok(Some(recipe))
+}
+
+fn entry(archive: &mut ZipArchive, name: &str) -> Result, StorageError> {
+ let mut entry = archive
+ .by_name(name)
+ .map_err(|_| StorageError::ImportFormat(format!("no {name} in the archive")))?;
+
+ let mut bytes = Vec::new();
+ entry
+ .read_to_end(&mut bytes)
+ .map_err(|error| file_error(name, &error))?;
+
+ Ok(bytes)
+}
+
+/// The attachment bytes of an import, handed over one at a time.
+///
+/// ⚠️ `Debug` says nothing about the key it may hold, for the same reason `Vault`'s does.
+pub enum Payload {
+ /// A `.json` export: it carried no attachments.
+ Empty,
+ Archive {
+ archive: Box>,
+ /// `None` for a plaintext archive; the export's own key for a protected one.
+ vault: Option,
+ },
+}
+
+impl std::fmt::Debug for Payload {
+ fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::Empty => formatter.write_str("Payload::Empty"),
+ Self::Archive { vault, .. } => formatter.write_str(if vault.is_some() {
+ "Payload::Archive(protected)"
+ } else {
+ "Payload::Archive"
+ }),
+ }
+ }
+}
+
+impl Payload {
+ /// `None` when the archive names the record but does not carry its bytes, and `None`
+ /// too when it carries them under a key that will not open them — a caller cannot act
+ /// on the difference, and the import reports both as missing.
+ pub fn take(&mut self, stored_name: &str) -> Option> {
+ let Self::Archive { archive, vault } = self else {
+ return None;
+ };
+
+ let mut entry = archive
+ .by_name(&format!("{ATTACHMENTS_ENTRY}/{stored_name}"))
+ .ok()?;
+ let mut bytes = Vec::new();
+ entry.read_to_end(&mut bytes).ok()?;
+
+ match vault {
+ Some(vault) => vault.open_bytes(&bytes).ok(),
+ None => Some(bytes),
+ }
+ }
}
/// ⚠️ Same directory as the target, or the rename crosses volumes and stops being atomic.
@@ -58,6 +352,20 @@ mod tests {
use super::*;
use crate::notes::fixtures::note as sample;
use crate::spaces::model::Space;
+ use crate::vault::key::Cost;
+
+ fn library() -> Vault {
+ Vault::derive(
+ "the library",
+ b"0123456789abcdef",
+ Cost {
+ memory_kib: 64,
+ passes: 1,
+ lanes: 1,
+ },
+ )
+ .unwrap()
+ }
fn bundle() -> Bundle {
Bundle {
@@ -69,6 +377,18 @@ mod tests {
pinned: false,
}],
notes: vec![sample()],
+ attachments: Vec::new(),
+ }
+ }
+
+ fn record() -> Attachment {
+ Attachment {
+ id: "a-1".to_string(),
+ note_id: sample().id,
+ file_name: "capture.png".to_string(),
+ mime_type: "image/png".to_string(),
+ byte_size: 4,
+ created_at: sample().created_at,
}
}
@@ -79,10 +399,18 @@ mod tests {
directory
}
+ /// The attachment as it really sits beside the database: sealed under the library.
+ fn seal_beside(directory: &Path, vault: &Vault, bytes: &[u8]) {
+ let sealed = vault.seal_bytes(bytes).unwrap();
+ std::fs::write(directory.join(record().stored_name()), sealed).unwrap();
+ }
+
/// A staging file one directory away would make the rename cross volumes.
#[test]
fn the_staging_file_sits_next_to_its_target() {
- let target = std::env::temp_dir().join("documents").join("library.json");
+ let target = std::env::temp_dir()
+ .join("documents")
+ .join("library.devbox");
let staged = staging_path(&target.to_string_lossy());
@@ -92,7 +420,7 @@ mod tests {
#[test]
fn two_exports_of_the_same_target_never_stage_the_same_file() {
- let target = std::env::temp_dir().join("library.json");
+ let target = std::env::temp_dir().join("library.devbox");
let first = staging_path(&target.to_string_lossy());
let second = staging_path(&target.to_string_lossy());
@@ -103,9 +431,16 @@ mod tests {
#[test]
fn an_export_leaves_no_staging_file_behind() {
let directory = scratch();
- let target = directory.join("library.json");
+ let target = directory.join("library.devbox");
- write(&target.to_string_lossy(), &bundle()).unwrap();
+ write(
+ &target.to_string_lossy(),
+ &bundle(),
+ &directory,
+ &library(),
+ None,
+ )
+ .unwrap();
let left: Vec<_> = std::fs::read_dir(&directory)
.unwrap()
@@ -113,41 +448,283 @@ mod tests {
.map(|entry| entry.file_name())
.collect();
- assert_eq!(left, ["library.json"]);
+ assert_eq!(left, ["library.devbox"]);
std::fs::remove_dir_all(&directory).ok();
}
#[test]
fn exporting_over_an_existing_file_replaces_it_whole() {
let directory = scratch();
- let target = directory.join("library.json");
+ let target = directory.join("library.devbox");
std::fs::write(&target, "previous export, longer than what replaces it").unwrap();
- write(&target.to_string_lossy(), &bundle()).unwrap();
-
- let written = std::fs::read_to_string(&target).unwrap();
- assert!(written.starts_with('{'));
- assert!(!written.contains("previous export"));
+ write(
+ &target.to_string_lossy(),
+ &bundle(),
+ &directory,
+ &library(),
+ None,
+ )
+ .unwrap();
+
+ let written = std::fs::read(&target).unwrap();
+ assert_eq!(written[..4], ZIP_MAGIC);
std::fs::remove_dir_all(&directory).ok();
}
#[test]
fn an_export_to_an_unreachable_directory_reports_rather_than_panicking() {
- let error = write("/no/such/directory/library.json", &bundle()).unwrap_err();
+ let directory = scratch();
+
+ let error = write(
+ "/no/such/directory/library.devbox",
+ &bundle(),
+ &directory,
+ &library(),
+ None,
+ )
+ .unwrap_err();
assert!(matches!(error.code, crate::error::ErrorCode::FileAccess));
+ std::fs::remove_dir_all(&directory).ok();
}
#[test]
fn a_written_bundle_reads_back_as_itself() {
+ let directory = scratch();
+ let target = directory.join("library.devbox");
+
+ write(
+ &target.to_string_lossy(),
+ &bundle(),
+ &directory,
+ &library(),
+ None,
+ )
+ .unwrap();
+ let (read_back, _) = read(&target.to_string_lossy(), None).unwrap();
+
+ assert_eq!(read_back.bundle.notes.len(), 1);
+ assert_eq!(read_back.bundle.spaces[0].name, "Personal");
+ std::fs::remove_dir_all(&directory).ok();
+ }
+
+ /// The point of the archive: the bytes travel with the record.
+ #[test]
+ fn an_attachment_travels_with_its_note() {
+ let directory = scratch();
+ let target = directory.join("library.devbox");
+ let vault = library();
+ seal_beside(&directory, &vault, b"\x89PNG");
+
+ let mut exported = bundle();
+ exported.attachments = vec![record()];
+ let report = write(
+ &target.to_string_lossy(),
+ &exported,
+ &directory,
+ &vault,
+ None,
+ )
+ .unwrap();
+
+ assert_eq!(report.attachments, 1);
+ assert!(!report.protected);
+
+ let (read_back, mut payload) = read(&target.to_string_lossy(), None).unwrap();
+ assert_eq!(read_back.bundle.attachments.len(), 1);
+ assert_eq!(
+ payload.take(&record().stored_name()),
+ Some(b"\x89PNG".to_vec())
+ );
+ std::fs::remove_dir_all(&directory).ok();
+ }
+
+ /// ⚠️ A record whose file has gone missing must not fail the export.
+ #[test]
+ fn a_record_whose_file_is_gone_leaves_the_export_rather_than_failing_it() {
+ let directory = scratch();
+ let target = directory.join("library.devbox");
+
+ let mut exported = bundle();
+ exported.attachments = vec![record()];
+ let report = write(
+ &target.to_string_lossy(),
+ &exported,
+ &directory,
+ &library(),
+ None,
+ )
+ .unwrap();
+
+ assert_eq!(report.attachments, 0);
+ assert_eq!(report.notes, 1);
+ std::fs::remove_dir_all(&directory).ok();
+ }
+
+ /// ⚠️ New DevBox reads what old DevBox wrote: a `.json` export predates the archive.
+ #[test]
+ fn a_json_export_from_before_the_archive_still_imports() {
let directory = scratch();
let target = directory.join("library.json");
+ let json = serde_json::to_string_pretty(&bundle()).unwrap();
+ std::fs::write(&target, json).unwrap();
- write(&target.to_string_lossy(), &bundle()).unwrap();
- let read_back = read(&target.to_string_lossy()).unwrap();
+ let (read_back, payload) = read(&target.to_string_lossy(), None).unwrap();
assert_eq!(read_back.bundle.notes.len(), 1);
- assert_eq!(read_back.bundle.spaces[0].name, "Personal");
+ assert!(matches!(payload, Payload::Empty));
+ std::fs::remove_dir_all(&directory).ok();
+ }
+
+ /// ⚠️ The whole point of protecting an export: the file most likely to leave the
+ /// machine was the one carrying everything in the clear.
+ #[test]
+ fn a_protected_export_carries_none_of_the_notes_in_the_clear() {
+ let directory = scratch();
+ let target = directory.join("library.devbox");
+ let vault = library();
+ seal_beside(&directory, &vault, b"a screenshot of something");
+
+ let mut exported = bundle();
+ exported.notes[0].content = "psql -h prod -W hunter2".to_string();
+ exported.attachments = vec![record()];
+
+ let report = write(
+ &target.to_string_lossy(),
+ &exported,
+ &directory,
+ &vault,
+ Some("a shared phrase"),
+ )
+ .unwrap();
+ assert!(report.protected);
+
+ let raw = std::fs::read(&target).unwrap();
+ let haystack = String::from_utf8_lossy(&raw);
+ assert!(!haystack.contains("hunter2"));
+ assert!(!haystack.contains("Personal"));
+ assert!(!haystack.contains("a screenshot of"));
+
+ std::fs::remove_dir_all(&directory).ok();
+ }
+
+ #[test]
+ fn a_protected_export_reads_back_whole_with_its_phrase() {
+ let directory = scratch();
+ let target = directory.join("library.devbox");
+ let vault = library();
+ seal_beside(&directory, &vault, b"\x89PNG");
+
+ let mut exported = bundle();
+ exported.attachments = vec![record()];
+ write(
+ &target.to_string_lossy(),
+ &exported,
+ &directory,
+ &vault,
+ Some("a shared phrase"),
+ )
+ .unwrap();
+
+ let (read_back, mut payload) =
+ read(&target.to_string_lossy(), Some("a shared phrase")).unwrap();
+
+ assert_eq!(read_back.bundle.notes.len(), 1);
+ assert_eq!(
+ payload.take(&record().stored_name()),
+ Some(b"\x89PNG".to_vec())
+ );
+ std::fs::remove_dir_all(&directory).ok();
+ }
+
+ /// ⚠️ Offered without one, it asks rather than failing: nothing can know a file is
+ /// protected until something has looked inside it.
+ #[test]
+ fn a_protected_export_asks_for_a_phrase_rather_than_failing() {
+ let directory = scratch();
+ let target = directory.join("library.devbox");
+
+ write(
+ &target.to_string_lossy(),
+ &bundle(),
+ &directory,
+ &library(),
+ Some("a shared phrase"),
+ )
+ .unwrap();
+
+ let error = read(&target.to_string_lossy(), None).unwrap_err();
+ assert_eq!(error.code, crate::error::ErrorCode::PassphraseRequired);
+ assert!(is_protected(&target.to_string_lossy()).unwrap());
+
+ std::fs::remove_dir_all(&directory).ok();
+ }
+
+ #[test]
+ fn the_wrong_phrase_is_refused_rather_than_read_as_nonsense() {
+ let directory = scratch();
+ let target = directory.join("library.devbox");
+
+ write(
+ &target.to_string_lossy(),
+ &bundle(),
+ &directory,
+ &library(),
+ Some("a shared phrase"),
+ )
+ .unwrap();
+
+ let error = read(&target.to_string_lossy(), Some("the wrong one")).unwrap_err();
+
+ assert_eq!(error.code, crate::error::ErrorCode::WrongPassphrase);
+ std::fs::remove_dir_all(&directory).ok();
+ }
+
+ /// ⚠️ Asked before an import starts, so it must not pay for the bundle: a file whose
+ /// payload could not be parsed at all still answers the question.
+ #[test]
+ fn whether_a_file_is_protected_is_answered_without_reading_the_bundle() {
+ let directory = scratch();
+ let target = directory.join("library.devbox");
+ write(
+ &target.to_string_lossy(),
+ &bundle(),
+ &directory,
+ &library(),
+ None,
+ )
+ .unwrap();
+
+ // The entry is there and is nonsense; only the recipe decides the answer.
+ let mut rewritten = ZipWriter::new(std::fs::File::create(&target).unwrap());
+ rewritten.start_file(BUNDLE_ENTRY, deflated()).unwrap();
+ rewritten.write_all(b"not json at all").unwrap();
+ rewritten.finish().unwrap();
+
+ assert!(!is_protected(&target.to_string_lossy()).unwrap());
+ // And the import that follows is what says the file is unreadable.
+ assert!(read(&target.to_string_lossy(), None).is_err());
+
+ std::fs::remove_dir_all(&directory).ok();
+ }
+
+ /// An ordinary export needs no phrase, and must not be made to ask for one.
+ #[test]
+ fn an_unprotected_export_is_not_reported_as_protected() {
+ let directory = scratch();
+ let target = directory.join("library.devbox");
+
+ write(
+ &target.to_string_lossy(),
+ &bundle(),
+ &directory,
+ &library(),
+ None,
+ )
+ .unwrap();
+
+ assert!(!is_protected(&target.to_string_lossy()).unwrap());
std::fs::remove_dir_all(&directory).ok();
}
}
diff --git a/src-tauri/src/transfer/model.rs b/src-tauri/src/transfer/model.rs
index b73a60e..10f609f 100644
--- a/src-tauri/src/transfer/model.rs
+++ b/src-tauri/src/transfer/model.rs
@@ -9,6 +9,7 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use specta::Type;
+use crate::attachments::model::Attachment;
use crate::error::{StorageError, ValidationError};
use crate::notes::checklist::{self, NoteKind};
use crate::notes::language::Language;
@@ -29,6 +30,11 @@ pub struct Bundle {
pub exported_at: DateTime,
pub spaces: Vec,
pub notes: Vec,
+ /// The records only — the bytes are entries of the archive, keyed by
+ /// [`crate::attachments::model::stored_name`]. ⚠️ `default` so a `.json` export written
+ /// before the archive existed still parses.
+ #[serde(default)]
+ pub attachments: Vec,
}
#[derive(Debug, Clone, Copy, Serialize, Type)]
@@ -36,6 +42,13 @@ pub struct Bundle {
pub struct ExportReport {
pub notes: u32,
pub spaces: u32,
+ /// What actually went into the archive. A record whose file has gone missing is left
+ /// out rather than failing the export.
+ pub attachments: u32,
+ /// ⚠️ `false` means the file is readable by anyone who has it — every note, every
+ /// screenshot. The interface says which of the two it wrote, because the file is the
+ /// one thing here most likely to leave the machine.
+ pub protected: bool,
}
/// `skipped`: notes already present or whose space is missing from the file — an import
@@ -49,6 +62,10 @@ pub struct ImportReport {
/// Imported with a `language` or `kind` this build does not know brought down to the
/// default. Counted so the loss is said rather than discovered.
pub notes_degraded: u32,
+ pub attachments_imported: u32,
+ /// Records the archive named but did not carry. Counted rather than swallowed: the
+ /// note arrives with a thumbnail that will never load, and only this says why.
+ pub attachments_missing: u32,
}
/// A bundle read from a file, and the ids [`read_bundle`] had to degrade.
@@ -350,6 +367,7 @@ mod tests {
pinned: false,
}],
notes: vec![sample()],
+ attachments: Vec::new(),
};
let read = read_bundle(&serde_json::to_string(&bundle).unwrap()).unwrap();
diff --git a/src-tauri/src/transfer/protect.rs b/src-tauri/src/transfer/protect.rs
new file mode 100644
index 0000000..8ae64f9
--- /dev/null
+++ b/src-tauri/src/transfer/protect.rs
@@ -0,0 +1,162 @@
+//! The passphrase an export can be protected with.
+//!
+//! ⚠️ A different key from the library's, always. The library key is derived from the
+//! passphrase typed at launch and never leaves this machine; an export is meant to reach
+//! another one, so it carries its own salt and is opened by whatever phrase the user gave
+//! it. That is also what lets a file be sent to someone without handing them the keys to
+//! the library it came from.
+//!
+//! ⚠️ An unprotected export is still written, and is still plaintext. Refusing to write
+//! one would break the portability the exchange format exists for — what the interface
+//! owes the user is to say which of the two they are about to produce.
+
+use serde::{Deserialize, Serialize};
+
+use crate::error::StorageError;
+use crate::vault::key::{Cost, SALT_BYTES, Vault, fresh_salt};
+use base64::Engine;
+use base64::engine::general_purpose::STANDARD as BASE64;
+
+/// Bumped when a protected file written today would stop being readable.
+const FORMAT_VERSION: u32 = 1;
+
+/// What a reader needs to derive the same key, and nothing more. Written in the clear
+/// beside the sealed payload: a salt is not a secret, and a cost has to be read before
+/// anything can be derived.
+#[derive(Debug, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Recipe {
+ pub version: u32,
+ pub algorithm: String,
+ pub memory_kib: u32,
+ pub passes: u32,
+ pub lanes: u32,
+ pub salt: String,
+}
+
+/// A fresh key for one export, and the recipe that reproduces it.
+pub fn seal_with(passphrase: &str) -> Result<(Vault, Recipe), StorageError> {
+ let cost = Cost::default();
+ let salt = fresh_salt()?;
+ let vault = Vault::derive(passphrase, &salt, cost)?;
+
+ Ok((
+ vault,
+ Recipe {
+ version: FORMAT_VERSION,
+ algorithm: "argon2id".to_string(),
+ memory_kib: cost.memory_kib,
+ passes: cost.passes,
+ lanes: cost.lanes,
+ salt: BASE64.encode(salt),
+ },
+ ))
+}
+
+/// ⚠️ Answers [`StorageError::WrongPassphrase`] on a phrase that does not open the
+/// payload, and the caller has no way to tell that from a corrupt file — which is
+/// deliberate. The recipe carries no check value of its own: the payload's own
+/// authentication tag is the check, so there is nothing extra to brute-force against.
+pub fn open_with(passphrase: &str, recipe: &Recipe) -> Result {
+ if recipe.version > FORMAT_VERSION {
+ return Err(StorageError::ImportFormat(format!(
+ "protected with format version {}, this version of DevBox reads up to {FORMAT_VERSION}",
+ recipe.version
+ )));
+ }
+ if recipe.algorithm != "argon2id" {
+ return Err(StorageError::ImportFormat(format!(
+ "protected with \"{}\", which this version of DevBox cannot reproduce",
+ recipe.algorithm
+ )));
+ }
+
+ let salt = BASE64
+ .decode(&recipe.salt)
+ .map_err(|_| StorageError::ImportFormat("the salt is not base64".to_string()))?;
+ if salt.len() != SALT_BYTES {
+ return Err(StorageError::ImportFormat(
+ "the salt is the wrong size".to_string(),
+ ));
+ }
+
+ Vault::derive(
+ passphrase,
+ &salt,
+ Cost {
+ memory_kib: recipe.memory_kib,
+ passes: recipe.passes,
+ lanes: recipe.lanes,
+ },
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn a_payload_sealed_for_an_export_opens_with_the_same_phrase() {
+ let (vault, recipe) = seal_with("a shared phrase").unwrap();
+ let sealed = vault.seal_bytes(b"the bundle").unwrap();
+
+ let reader = open_with("a shared phrase", &recipe).unwrap();
+
+ assert_eq!(reader.open_bytes(&sealed).unwrap(), b"the bundle");
+ }
+
+ #[test]
+ fn another_phrase_will_not_open_it() {
+ let (vault, recipe) = seal_with("a shared phrase").unwrap();
+ let sealed = vault.seal_bytes(b"the bundle").unwrap();
+
+ let reader = open_with("the wrong phrase", &recipe).unwrap();
+
+ assert!(reader.open_bytes(&sealed).is_err());
+ }
+
+ /// ⚠️ Two exports of the same library under the same phrase must not share a key: a
+ /// fresh salt is what stops one opened file from opening every other.
+ #[test]
+ fn two_exports_under_one_phrase_do_not_share_a_key() {
+ let (first, first_recipe) = seal_with("a shared phrase").unwrap();
+ let (_, second_recipe) = seal_with("a shared phrase").unwrap();
+
+ assert_ne!(first_recipe.salt, second_recipe.salt);
+
+ let sealed = first.seal_bytes(b"the bundle").unwrap();
+ let other = open_with("a shared phrase", &second_recipe).unwrap();
+ assert!(other.open_bytes(&sealed).is_err());
+ }
+
+ #[test]
+ fn a_recipe_from_a_newer_version_says_so() {
+ let (_, mut recipe) = seal_with("a shared phrase").unwrap();
+ recipe.version = FORMAT_VERSION + 1;
+
+ let error = open_with("a shared phrase", &recipe).unwrap_err();
+
+ assert!(format!("{error}").contains("reads up to"));
+ }
+
+ #[test]
+ fn an_unknown_derivation_is_named_rather_than_guessed_at() {
+ let (_, mut recipe) = seal_with("a shared phrase").unwrap();
+ recipe.algorithm = "scrypt".to_string();
+
+ let error = open_with("a shared phrase", &recipe).unwrap_err();
+
+ assert!(format!("{error}").contains("scrypt"));
+ }
+
+ /// The recipe is what travels in the clear, so it must carry nothing that matters.
+ #[test]
+ fn the_recipe_holds_no_phrase_and_no_key() {
+ let (_, recipe) = seal_with("correct horse battery staple").unwrap();
+
+ let written = serde_json::to_string(&recipe).unwrap();
+
+ assert!(!written.contains("correct horse"));
+ assert!(written.contains("argon2id"));
+ }
+}
diff --git a/src-tauri/src/vault.rs b/src-tauri/src/vault.rs
new file mode 100644
index 0000000..03e474c
--- /dev/null
+++ b/src-tauri/src/vault.rs
@@ -0,0 +1,268 @@
+//! Encryption at rest: the key, what it seals, and the one gate that opens the library.
+//!
+//! ⚠️ The passphrase is never stored, anywhere, deliberately: this is the bargain a
+//! password manager makes, not a keychain's. Losing it loses the library, and an export
+//! is the only copy that does not depend on it.
+
+#![allow(clippy::needless_pass_by_value)]
+
+pub mod file;
+pub mod key;
+pub mod migrate;
+
+use serde::Serialize;
+use specta::Type;
+use tauri::{AppHandle, Manager, State};
+
+use zeroize::Zeroize;
+
+use crate::db::{self, Db};
+use crate::error::{AppError, StorageError, ValidationError};
+use key::Cost;
+
+/// Short enough to be typed at every launch, long enough to be worth deriving from.
+const MINIMUM_LENGTH: usize = 8;
+
+/// What the front end renders before it renders anything else.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Type)]
+#[serde(rename_all = "camelCase")]
+pub enum VaultState {
+ /// A library that has never been encrypted: the first launch asks for a passphrase
+ /// twice and creates one.
+ Absent,
+ /// A key file is there and the passphrase has not been given yet.
+ Locked,
+ /// ⚠️ Held in Rust, never in the front end: a page reload must not ask again for a
+ /// library this process already has open — which is also what keeps `reopenSession`
+ /// working in the end-to-end suite.
+ Unlocked,
+}
+
+#[tauri::command]
+#[specta::specta]
+pub fn vault_state(app: AppHandle, db: State<'_, Db>) -> Result {
+ if db.lock().map_err(|_| StorageError::Unavailable)?.is_some() {
+ return Ok(VaultState::Unlocked);
+ }
+
+ let directory = app.path().app_data_dir().map_err(storage)?;
+
+ Ok(if file::exists(&directory) {
+ VaultState::Locked
+ } else {
+ VaultState::Absent
+ })
+}
+
+/// The first launch. ⚠️ Refuses a library that already has a key file rather than
+/// replacing it: that file is the only way into the notes beside it.
+#[tauri::command(async)]
+#[specta::specta]
+pub fn create_vault(
+ mut passphrase: String,
+ app: AppHandle,
+ db: State<'_, Db>,
+) -> Result<(), AppError> {
+ // ⚠️ Wiped before this returns, whatever it returns. The string arrives owned from the
+ // IPC payload, so this is the last reference to it — and a passphrase left in freed
+ // memory is a passphrase in a crash dump.
+ let result = create_with(&passphrase, &app, &db);
+ passphrase.zeroize();
+
+ result
+}
+
+fn create_with(passphrase: &str, app: &AppHandle, db: &State<'_, Db>) -> Result<(), AppError> {
+ validate(passphrase)?;
+
+ let directory = app.path().app_data_dir().map_err(storage)?;
+ std::fs::create_dir_all(&directory).map_err(|error| storage_msg(&error.to_string()))?;
+
+ let vault = file::create(&directory, passphrase, Cost::default())?;
+
+ open_library(app, db, vault)?;
+
+ // ⚠️ Between opening and sweeping, never after. The orphan-file sweep reads attachment
+ // records, and on a library that predates the passphrase those are still in the clear —
+ // it would fail to open every one of them and log a warning for nothing.
+ seal_what_was_there(app, db)?;
+
+ crate::sweep(app);
+
+ Ok(())
+}
+
+/// ⚠️ The rows first, in one transaction, and the files after it commits. A file write
+/// does not roll back — a file left readable is recoverable, a row sealed twice is not.
+fn seal_what_was_there(app: &AppHandle, db: &State<'_, Db>) -> Result<(), AppError> {
+ let stored_names = {
+ let mut connection = crate::db::lock(db)?;
+ let done = {
+ let (connection, vault) = connection.split();
+ migrate::seal_existing(connection, vault)?
+ };
+
+ if done.is_empty() {
+ return Ok(());
+ }
+
+ log::info!(
+ "Sealed an existing library: {} note(s), {} space(s), {} item(s), {} value(s), {} attachment record(s)",
+ done.notes,
+ done.spaces,
+ done.items,
+ done.values,
+ done.attachments
+ );
+
+ crate::attachments::store::all_stored_names(&mut connection)?
+ };
+
+ let directory = crate::attachments::directory(app)?;
+ let connection = crate::db::lock(db)?;
+ let vault = connection.vault();
+
+ for name in stored_names {
+ let path = directory.join(&name);
+ // ⚠️ Best effort, one file at a time, and never fatal: a library whose notes are
+ // sealed is worth keeping even if one screenshot resisted. The alternative is
+ // refusing to start over a file nobody may ever open.
+ if let Err(error) = crate::attachments::sealed::seal_in_place(vault, &path) {
+ log::warn!("Attachment {name} left as it was: {error}");
+ }
+ }
+
+ Ok(())
+}
+
+/// ⚠️ Deliberately slow: deriving the key is the whole defence against someone trying
+/// passphrases against a copied file. It is `(async)` for the same reason — a second on the
+/// main thread would freeze the window over every attempt.
+#[tauri::command(async)]
+#[specta::specta]
+pub fn unlock_vault(
+ mut passphrase: String,
+ app: AppHandle,
+ db: State<'_, Db>,
+) -> Result<(), AppError> {
+ // ⚠️ Wiped before this returns, whatever it returns — see `create_vault`.
+ let result = unlock_with(&passphrase, &app, &db);
+ passphrase.zeroize();
+
+ result
+}
+
+fn unlock_with(passphrase: &str, app: &AppHandle, db: &State<'_, Db>) -> Result<(), AppError> {
+ let directory = app.path().app_data_dir().map_err(storage)?;
+ let vault = file::unlock(&directory, passphrase)?;
+
+ open_library(app, db, vault)?;
+ crate::sweep(app);
+
+ Ok(())
+}
+
+/// A new phrase over the same library, from the preferences panel.
+///
+/// ⚠️ Not a re-encryption: the key the notes are sealed with is the one being rewrapped,
+/// so nothing in the database moves and the library stays open on the key it already had.
+/// The consequence is worth knowing — this answers a phrase somebody else learned, never
+/// a key somebody else got hold of.
+#[tauri::command(async)]
+#[specta::specta]
+pub fn change_passphrase(
+ mut current: String,
+ mut next: String,
+ app: AppHandle,
+ db: State<'_, Db>,
+) -> Result<(), AppError> {
+ // ⚠️ Wiped before this returns, whatever it returns — see `create_vault`.
+ let result = change_with(¤t, &next, &app, &db);
+ current.zeroize();
+ next.zeroize();
+
+ result
+}
+
+fn change_with(
+ current: &str,
+ next: &str,
+ app: &AppHandle,
+ db: &State<'_, Db>,
+) -> Result<(), AppError> {
+ validate(next)?;
+
+ // ⚠️ Asked and released rather than held: the two derivations below cost a second
+ // each, and keeping the connection for them would freeze every other command.
+ if db.lock().map_err(|_| StorageError::Unavailable)?.is_none() {
+ return Err(StorageError::Locked.into());
+ }
+
+ let directory = app.path().app_data_dir().map_err(storage)?;
+ file::change_passphrase(&directory, current, next, Cost::default())?;
+
+ Ok(())
+}
+
+/// Opens the library under the key and hands it to the rest of the application. The
+/// sweeps are the caller's to run, because a first launch has to seal what is there first.
+fn open_library(app: &AppHandle, db: &State<'_, Db>, vault: key::Vault) -> Result<(), AppError> {
+ let directory = app.path().app_data_dir().map_err(storage)?;
+ let library = db::open(&directory.join(db::DB_FILE_NAME), vault)?;
+
+ {
+ let mut held = db.lock().map_err(|_| StorageError::Unavailable)?;
+ // ⚠️ A second unlock would drop the library the first one opened, and with it any
+ // connection state. The front gates on `vault_state`, so this only catches a race.
+ if held.is_none() {
+ *held = Some(library);
+ }
+ }
+
+ Ok(())
+}
+
+/// ⚠️ A length, and nothing else. A rule about digits and symbols pushes people towards
+/// one memorable pattern, and the cost of guessing is Argon2id's to carry.
+fn validate(passphrase: &str) -> Result<(), ValidationError> {
+ if passphrase.chars().count() < MINIMUM_LENGTH {
+ return Err(ValidationError::new(
+ "passphrase",
+ "a passphrase of at least 8 characters",
+ ));
+ }
+
+ Ok(())
+}
+
+fn storage(error: tauri::Error) -> StorageError {
+ StorageError::Vault(error.to_string())
+}
+
+fn storage_msg(detail: &str) -> StorageError {
+ StorageError::Vault(detail.to_string())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn a_passphrase_too_short_to_be_worth_deriving_is_refused() {
+ assert!(validate("short").is_err());
+ assert!(validate("1234567").is_err());
+ }
+
+ #[test]
+ fn a_passphrase_of_the_minimum_length_is_accepted() {
+ assert!(validate("12345678").is_ok());
+ }
+
+ /// ⚠️ Counted in characters, not bytes: "clé-privée" is ten characters and twelve
+ /// bytes, and a byte count would accept a shorter one through an accent.
+ #[test]
+ fn the_length_is_counted_in_characters() {
+ assert!(validate("éàèùçâêîô").is_ok());
+ assert!(validate("éàèùç").is_err());
+ }
+}
diff --git a/src-tauri/src/vault/file.rs b/src-tauri/src/vault/file.rs
new file mode 100644
index 0000000..022727a
--- /dev/null
+++ b/src-tauri/src/vault/file.rs
@@ -0,0 +1,409 @@
+//! The key file, beside the database.
+//!
+//! ⚠️ Outside the library on purpose: it carries what is needed to derive the key that
+//! opens it, so it has to be readable before anything else can be. What it holds is a
+//! salt, the cost, and the library's own key **sealed under the phrase** — never a key in
+//! the clear, and no separate check value: opening the wrapped key is the check.
+//!
+//! ⚠️ Losing this file loses the library, exactly as losing the passphrase does. An export
+//! is the only copy that does not depend on it.
+
+use std::path::{Path, PathBuf};
+
+use base64::Engine;
+use base64::engine::general_purpose::STANDARD as BASE64;
+use serde::{Deserialize, Serialize};
+use uuid::Uuid;
+
+use super::key::{Cost, SALT_BYTES, Vault, fresh_salt};
+use crate::error::StorageError;
+
+pub const FILE_NAME: &str = "vault.json";
+
+/// Bumped when a file written today would stop being readable. 2 wraps the library key
+/// under the phrase where 1 derived the library key from it — which is what lets the
+/// phrase change without touching a single note.
+const FORMAT_VERSION: u32 = 2;
+
+#[derive(Debug, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct KeyFile {
+ version: u32,
+ kdf: Kdf,
+ /// ⚠️ The library's key, sealed under the one derived from the passphrase. A wrong
+ /// phrase fails to open it, which is what stops an unlock from succeeding on a key
+ /// nobody can reproduce and sealing real notes under it.
+ key: String,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct Kdf {
+ algorithm: String,
+ memory_kib: u32,
+ passes: u32,
+ lanes: u32,
+ salt: String,
+}
+
+pub fn path_in(directory: &Path) -> PathBuf {
+ directory.join(FILE_NAME)
+}
+
+pub fn exists(directory: &Path) -> bool {
+ path_in(directory).is_file()
+}
+
+/// A library that has never been encrypted. ⚠️ Refuses to overwrite: the file that is
+/// there is the only way into the notes beside it.
+pub fn create(directory: &Path, passphrase: &str, cost: Cost) -> Result {
+ let path = path_in(directory);
+ if path.exists() {
+ return Err(StorageError::Vault(
+ "this library already has a key file".to_string(),
+ ));
+ }
+
+ let vault = Vault::random()?;
+ write_wrapped(&path, &vault, passphrase, cost)?;
+
+ Ok(vault)
+}
+
+/// A new phrase over the same library. ⚠️ Nothing is re-encrypted: the key the notes are
+/// sealed with does not change, only what wraps it — so this cannot half-succeed and
+/// leave some notes unreadable, and it costs one derivation rather than a full rewrite.
+pub fn change_passphrase(
+ directory: &Path,
+ current: &str,
+ next: &str,
+ cost: Cost,
+) -> Result<(), StorageError> {
+ let vault = unlock(directory, current)?;
+
+ write_wrapped(&path_in(directory), &vault, next, cost)
+}
+
+/// ⚠️ A fresh salt every time, change included: two phrases must not share a derivation,
+/// or knowing one would say something about the other.
+fn write_wrapped(
+ path: &Path,
+ vault: &Vault,
+ passphrase: &str,
+ cost: Cost,
+) -> Result<(), StorageError> {
+ let salt = fresh_salt()?;
+ let wrapping = Vault::derive(passphrase, &salt, cost)?;
+
+ write_atomically(
+ path,
+ &KeyFile {
+ version: FORMAT_VERSION,
+ kdf: Kdf {
+ algorithm: "argon2id".to_string(),
+ memory_kib: cost.memory_kib,
+ passes: cost.passes,
+ lanes: cost.lanes,
+ salt: BASE64.encode(salt),
+ },
+ key: BASE64.encode(vault.wrapped_with(&wrapping)?),
+ },
+ )
+}
+
+/// ⚠️ Answers [`StorageError::WrongPassphrase`] and nothing more detailed: which of the
+/// two the caller got wrong is not something to help with.
+pub fn unlock(directory: &Path, passphrase: &str) -> Result {
+ let path = path_in(directory);
+ let json = std::fs::read_to_string(&path)
+ .map_err(|error| StorageError::Vault(format!("{}: {error}", path.display())))?;
+
+ let file: KeyFile = serde_json::from_str(&json)
+ .map_err(|error| StorageError::Vault(format!("unreadable key file: {error}")))?;
+
+ if file.version > FORMAT_VERSION {
+ return Err(StorageError::Vault(format!(
+ "key file version {}, this version of DevBox reads up to {FORMAT_VERSION}",
+ file.version
+ )));
+ }
+ if file.kdf.algorithm != "argon2id" {
+ return Err(StorageError::Vault(format!(
+ "key derived with \"{}\", which this version of DevBox cannot reproduce",
+ file.kdf.algorithm
+ )));
+ }
+
+ let salt = BASE64
+ .decode(&file.kdf.salt)
+ .map_err(|_| StorageError::Vault("the salt is not base64".to_string()))?;
+ if salt.len() != SALT_BYTES {
+ return Err(StorageError::Vault(
+ "the salt is the wrong size".to_string(),
+ ));
+ }
+
+ let cost = Cost {
+ memory_kib: file.kdf.memory_kib,
+ passes: file.kdf.passes,
+ lanes: file.kdf.lanes,
+ };
+ let wrapping = Vault::derive(passphrase, &salt, cost)?;
+ let wrapped = BASE64
+ .decode(&file.key)
+ .map_err(|_| StorageError::Vault("the wrapped key is not base64".to_string()))?;
+
+ Vault::unwrapped_with(&wrapping, &wrapped).map_err(|_| StorageError::WrongPassphrase)
+}
+
+/// ⚠️ Staged then renamed. A key file half-written is a library nobody opens again, and
+/// a plain write truncates before it fills.
+fn write_atomically(path: &Path, file: &KeyFile) -> Result<(), StorageError> {
+ let json = serde_json::to_string_pretty(file)
+ .map_err(|error| StorageError::Vault(error.to_string()))?;
+
+ let staged = path.with_file_name(format!(".{FILE_NAME}.{}.tmp", Uuid::new_v4()));
+ std::fs::write(&staged, json)
+ .map_err(|error| StorageError::Vault(format!("{}: {error}", staged.display())))?;
+
+ if let Err(error) = std::fs::rename(&staged, path) {
+ let _ = std::fs::remove_file(&staged);
+ return Err(StorageError::Vault(format!("{}: {error}", path.display())));
+ }
+
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// ⚠️ Cheap parameters: the real ones cost about a second a derivation, and these tests derive
+ /// a dozen times. What they assert on is the file, not Argon2id's strength.
+ fn cheap() -> Cost {
+ Cost {
+ memory_kib: 64,
+ passes: 1,
+ lanes: 1,
+ }
+ }
+
+ fn scratch() -> PathBuf {
+ let directory = std::env::temp_dir().join(format!("devbox-vault-{}", Uuid::new_v4()));
+ std::fs::create_dir_all(&directory).unwrap();
+
+ directory
+ }
+
+ #[test]
+ fn a_created_vault_opens_again_with_the_same_passphrase() {
+ let directory = scratch();
+
+ let created = create(&directory, "correct horse", cheap()).unwrap();
+ let sealed = created.seal("a note").unwrap();
+
+ let reopened = unlock(&directory, "correct horse").unwrap();
+ assert_eq!(reopened.open(&sealed).unwrap(), "a note");
+
+ std::fs::remove_dir_all(&directory).ok();
+ }
+
+ /// ⚠️ Without the wrapped key to open, this would succeed and every later write
+ /// would seal real notes under a key nobody can reproduce.
+ #[test]
+ fn a_wrong_passphrase_is_refused_rather_than_accepted_quietly() {
+ let directory = scratch();
+ create(&directory, "correct horse", cheap()).unwrap();
+
+ let error = unlock(&directory, "battery staple").unwrap_err();
+
+ assert!(matches!(error, StorageError::WrongPassphrase));
+ std::fs::remove_dir_all(&directory).ok();
+ }
+
+ /// The key it carries is sealed; the phrase that opens it is nowhere.
+ #[test]
+ fn the_key_file_holds_no_passphrase_and_no_key_in_the_clear() {
+ let directory = scratch();
+ let vault = create(&directory, "correct horse", cheap()).unwrap();
+
+ let written = std::fs::read_to_string(path_in(&directory)).unwrap();
+ let sealed = vault.seal("a note").unwrap();
+
+ assert!(!written.contains("correct horse"));
+ assert!(written.contains("argon2id"));
+ // Nothing in the file opens what the library sealed — only the phrase does.
+ let file: serde_json::Value = serde_json::from_str(&written).unwrap();
+ let wrapped = file["key"].as_str().unwrap().to_string();
+ assert!(unlock(&directory, &wrapped).is_err());
+ assert_eq!(
+ unlock(&directory, "correct horse")
+ .unwrap()
+ .open(&sealed)
+ .unwrap(),
+ "a note"
+ );
+
+ std::fs::remove_dir_all(&directory).ok();
+ }
+
+ /// ⚠️ The point of wrapping a random key rather than deriving one: the notes stay
+ /// sealed exactly as they were, and a change cannot half-rewrite a library.
+ #[test]
+ fn a_changed_passphrase_opens_the_notes_the_old_one_sealed() {
+ let directory = scratch();
+ let sealed = create(&directory, "correct horse", cheap())
+ .unwrap()
+ .seal("a note")
+ .unwrap();
+
+ change_passphrase(&directory, "correct horse", "battery staple", cheap()).unwrap();
+
+ let reopened = unlock(&directory, "battery staple").unwrap();
+ assert_eq!(reopened.open(&sealed).unwrap(), "a note");
+
+ std::fs::remove_dir_all(&directory).ok();
+ }
+
+ #[test]
+ fn the_old_passphrase_stops_opening_the_library() {
+ let directory = scratch();
+ create(&directory, "correct horse", cheap()).unwrap();
+
+ change_passphrase(&directory, "correct horse", "battery staple", cheap()).unwrap();
+
+ assert!(matches!(
+ unlock(&directory, "correct horse").unwrap_err(),
+ StorageError::WrongPassphrase
+ ));
+ std::fs::remove_dir_all(&directory).ok();
+ }
+
+ /// ⚠️ Refused *before* anything is written: a change that took a wrong current phrase
+ /// on trust would lock the library behind a phrase nobody chose.
+ #[test]
+ fn a_change_that_cannot_name_the_current_passphrase_writes_nothing() {
+ let directory = scratch();
+ create(&directory, "correct horse", cheap()).unwrap();
+ let before = std::fs::read_to_string(path_in(&directory)).unwrap();
+
+ let error = change_passphrase(&directory, "not it", "battery staple", cheap()).unwrap_err();
+
+ assert!(matches!(error, StorageError::WrongPassphrase));
+ assert_eq!(
+ std::fs::read_to_string(path_in(&directory)).unwrap(),
+ before
+ );
+ assert!(unlock(&directory, "correct horse").is_ok());
+
+ std::fs::remove_dir_all(&directory).ok();
+ }
+
+ /// Two phrases over one library must not share a derivation.
+ #[test]
+ fn a_change_draws_a_fresh_salt() {
+ let directory = scratch();
+ create(&directory, "correct horse", cheap()).unwrap();
+ let before: serde_json::Value =
+ serde_json::from_str(&std::fs::read_to_string(path_in(&directory)).unwrap()).unwrap();
+
+ change_passphrase(&directory, "correct horse", "battery staple", cheap()).unwrap();
+
+ let after: serde_json::Value =
+ serde_json::from_str(&std::fs::read_to_string(path_in(&directory)).unwrap()).unwrap();
+ assert_ne!(before["kdf"]["salt"], after["kdf"]["salt"]);
+ assert_ne!(before["key"], after["key"]);
+
+ std::fs::remove_dir_all(&directory).ok();
+ }
+
+ /// The cost travels with the file, so raising the default later does not lock an
+ /// existing library out.
+ #[test]
+ fn a_library_reopens_at_the_cost_it_was_written_with() {
+ let directory = scratch();
+ let odd = Cost {
+ memory_kib: 96,
+ passes: 3,
+ lanes: 1,
+ };
+ create(&directory, "correct horse", odd).unwrap();
+
+ // `unlock` reads the parameters rather than assuming today's defaults.
+ assert!(unlock(&directory, "correct horse").is_ok());
+ std::fs::remove_dir_all(&directory).ok();
+ }
+
+ /// ⚠️ Overwriting would throw away the only way into the notes sitting beside it.
+ #[test]
+ fn creating_over_an_existing_key_file_is_refused() {
+ let directory = scratch();
+ create(&directory, "first", cheap()).unwrap();
+
+ assert!(create(&directory, "second", cheap()).is_err());
+ // And the first passphrase still works.
+ assert!(unlock(&directory, "first").is_ok());
+
+ std::fs::remove_dir_all(&directory).ok();
+ }
+
+ #[test]
+ fn a_key_file_from_a_newer_version_says_so_rather_than_failing_on_serde() {
+ let directory = scratch();
+ create(&directory, "correct horse", cheap()).unwrap();
+
+ let path = path_in(&directory);
+ let mut json: serde_json::Value =
+ serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
+ json["version"] = serde_json::json!(FORMAT_VERSION + 1);
+ std::fs::write(&path, json.to_string()).unwrap();
+
+ let error = unlock(&directory, "correct horse").unwrap_err();
+ assert!(format!("{error}").contains("reads up to"));
+
+ std::fs::remove_dir_all(&directory).ok();
+ }
+
+ #[test]
+ fn an_unknown_derivation_is_named_rather_than_guessed_at() {
+ let directory = scratch();
+ create(&directory, "correct horse", cheap()).unwrap();
+
+ let path = path_in(&directory);
+ let mut json: serde_json::Value =
+ serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
+ json["kdf"]["algorithm"] = serde_json::json!("scrypt");
+ std::fs::write(&path, json.to_string()).unwrap();
+
+ let error = unlock(&directory, "correct horse").unwrap_err();
+ assert!(format!("{error}").contains("scrypt"));
+
+ std::fs::remove_dir_all(&directory).ok();
+ }
+
+ #[test]
+ fn a_library_with_no_key_file_says_it_has_none() {
+ let directory = scratch();
+
+ assert!(!exists(&directory));
+ create(&directory, "correct horse", cheap()).unwrap();
+ assert!(exists(&directory));
+
+ std::fs::remove_dir_all(&directory).ok();
+ }
+
+ #[test]
+ fn creating_leaves_no_staging_file_behind() {
+ let directory = scratch();
+ create(&directory, "correct horse", cheap()).unwrap();
+
+ let left: Vec<_> = std::fs::read_dir(&directory)
+ .unwrap()
+ .filter_map(Result::ok)
+ .map(|entry| entry.file_name())
+ .collect();
+
+ assert_eq!(left, [FILE_NAME]);
+ std::fs::remove_dir_all(&directory).ok();
+ }
+}
diff --git a/src-tauri/src/vault/key.rs b/src-tauri/src/vault/key.rs
new file mode 100644
index 0000000..d8503e3
--- /dev/null
+++ b/src-tauri/src/vault/key.rs
@@ -0,0 +1,304 @@
+//! Deriving the key from a passphrase, and sealing a value with it.
+//!
+//! ⚠️ AES-256-GCM hides the content of a value, never its **length**: the ciphertext is as
+//! long as the plaintext. Someone holding the file learns how big each note is, and that a
+//! title is empty. That is inherent to the construction and is not worth padding around.
+//!
+//! ⚠️ The nonce is 96 bits of randomness, fresh per seal. Reusing one under the same key
+//! breaks GCM outright, so nothing here ever takes a nonce from the caller.
+
+use aes_gcm::aead::{Aead, KeyInit};
+use aes_gcm::{Aes256Gcm, Key};
+use argon2::{Algorithm, Argon2, Params, Version};
+use base64::Engine;
+use base64::engine::general_purpose::STANDARD as BASE64;
+use zeroize::Zeroizing;
+
+use crate::error::StorageError;
+
+/// 96 bits, what GCM is specified around.
+const NONCE_BYTES: usize = 12;
+/// 128 bits: the salt only has to be unique per library, never secret.
+pub(crate) const SALT_BYTES: usize = 16;
+
+/// What `derive` costs, and what an attacker guessing passphrases pays per guess.
+///
+/// ⚠️ These travel **in the key file**, not as constants read at derivation: raising them
+/// in a later version must not lock every existing library out. A file carries the
+/// parameters it was written with, and only a deliberate re-key changes them.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct Cost {
+ /// KiB of memory. The parameter that actually hurts a GPU.
+ pub memory_kib: u32,
+ pub passes: u32,
+ pub lanes: u32,
+}
+
+impl Default for Cost {
+ /// ⚠️ Well above OWASP's floor of 19 MiB and two passes, deliberately. This is paid
+ /// **once per launch**, while the user is still lifting their hands off the keyboard,
+ /// and it is the only thing standing between a copied library and someone working
+ /// through a wordlist. Memory is the parameter that hurts a GPU, so it carries most
+ /// of the weight.
+ ///
+ /// Raising it again later locks nobody out: the cost travels in the key file, and a
+ /// library reopens at whatever it was written with. Measured at ~1.2 s here.
+ fn default() -> Self {
+ Self {
+ memory_kib: 64 * 1024,
+ passes: 3,
+ lanes: 1,
+ }
+ }
+}
+
+/// The key, and nothing else. ⚠️ Held in a `Zeroizing` so it is wiped when the vault is
+/// dropped rather than left in freed memory for whatever reads it next.
+pub struct Vault {
+ key: Zeroizing<[u8; 32]>,
+}
+
+impl std::fmt::Debug for Vault {
+ /// ⚠️ Deliberately says nothing: a key that reaches a log is a key that is gone.
+ fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ formatter.write_str("Vault(…)")
+ }
+}
+
+impl Vault {
+ /// The key the library's values are actually sealed with — random, never derived.
+ ///
+ /// ⚠️ This is what makes changing the passphrase a hundred bytes of rewriting instead
+ /// of re-encrypting every note and every attachment: the phrase only ever protects
+ /// this key, and a new phrase wraps the same one again. The cost is the other side of
+ /// that coin — whoever gets hold of this key keeps access across a change, so a
+ /// changed passphrase answers a leaked *phrase*, never a leaked key.
+ pub fn random() -> Result {
+ let mut key = Zeroizing::new([0u8; 32]);
+ getrandom::fill(key.as_mut())
+ .map_err(|error| StorageError::Vault(format!("no randomness: {error}")))?;
+
+ Ok(Self { key })
+ }
+
+ /// Seals this key under another one, for the key file to hold.
+ pub fn wrapped_with(&self, wrapping: &Self) -> Result, StorageError> {
+ wrapping.seal_bytes(self.key.as_ref())
+ }
+
+ /// ⚠️ The other direction, and the only check there is: a phrase that does not open
+ /// the wrapped key is the wrong phrase, said by the authentication tag rather than by
+ /// a known value sealed beside it.
+ pub fn unwrapped_with(wrapping: &Self, wrapped: &[u8]) -> Result {
+ let opened = Zeroizing::new(wrapping.open_bytes(wrapped)?);
+ let key: [u8; 32] = opened
+ .as_slice()
+ .try_into()
+ .map_err(|_| StorageError::Vault("the wrapped key is the wrong size".to_string()))?;
+
+ Ok(Self {
+ key: Zeroizing::new(key),
+ })
+ }
+
+ /// ⚠️ Slow on purpose — this is the whole defence against someone trying passphrases
+ /// against a copied file. It is paid once, at unlock, never per value.
+ pub fn derive(passphrase: &str, salt: &[u8], cost: Cost) -> Result {
+ let params = Params::new(cost.memory_kib, cost.passes, cost.lanes, Some(32))
+ .map_err(|error| StorageError::Vault(format!("key parameters: {error}")))?;
+
+ let mut key = Zeroizing::new([0u8; 32]);
+ Argon2::new(Algorithm::Argon2id, Version::V0x13, params)
+ .hash_password_into(passphrase.as_bytes(), salt, key.as_mut())
+ .map_err(|error| StorageError::Vault(format!("key derivation: {error}")))?;
+
+ Ok(Self { key })
+ }
+
+ fn cipher(&self) -> Aes256Gcm {
+ Aes256Gcm::new(&Key::::from(*self.key))
+ }
+
+ /// `nonce ++ ciphertext ++ tag`, raw. What a file holds — a column holds the base64 of
+ /// this, because the columns are TEXT.
+ pub fn seal_bytes(&self, plaintext: &[u8]) -> Result, StorageError> {
+ let mut nonce = [0u8; NONCE_BYTES];
+ getrandom::fill(&mut nonce)
+ .map_err(|error| StorageError::Vault(format!("no randomness: {error}")))?;
+
+ let sealed = self
+ .cipher()
+ .encrypt((&nonce).into(), plaintext)
+ .map_err(|_| StorageError::Vault("could not seal a value".to_string()))?;
+
+ let mut joined = Vec::with_capacity(NONCE_BYTES + sealed.len());
+ joined.extend_from_slice(&nonce);
+ joined.extend_from_slice(&sealed);
+
+ Ok(joined)
+ }
+
+ /// ⚠️ Whole, never streamed: GCM only authenticates a message once all of it has been
+ /// seen, and handing back bytes before the tag is checked would defeat the point.
+ pub fn open_bytes(&self, sealed: &[u8]) -> Result, StorageError> {
+ if sealed.len() <= NONCE_BYTES {
+ return Err(StorageError::Vault(
+ "too short to have been sealed".to_string(),
+ ));
+ }
+
+ let (nonce, body) = sealed.split_at(NONCE_BYTES);
+ let nonce: &[u8; NONCE_BYTES] = nonce.try_into().expect("a checked length");
+
+ self.cipher()
+ .decrypt(nonce.into(), body)
+ .map_err(|_| StorageError::Vault("it would not open".to_string()))
+ }
+
+ /// The same bytes, base64. The columns are TEXT, so what goes in one has to survive
+ /// being read back as a string.
+ pub fn seal(&self, plaintext: &str) -> Result {
+ Ok(BASE64.encode(self.seal_bytes(plaintext.as_bytes())?))
+ }
+
+ /// ⚠️ Fails on a value that was not sealed with this key, and that is the point: the
+ /// tag is what tells a wrong passphrase from a tampered file. Neither is recoverable,
+ /// so neither is guessed at.
+ pub fn open(&self, sealed: &str) -> Result {
+ let raw = BASE64
+ .decode(sealed)
+ .map_err(|_| StorageError::Vault("a value is not base64".to_string()))?;
+
+ String::from_utf8(self.open_bytes(&raw)?).map_err(|_| {
+ StorageError::Vault("a value opened to something that is not text".to_string())
+ })
+ }
+}
+
+/// A salt for a library that has none yet.
+pub fn fresh_salt() -> Result<[u8; SALT_BYTES], StorageError> {
+ let mut salt = [0u8; SALT_BYTES];
+ getrandom::fill(&mut salt)
+ .map_err(|error| StorageError::Vault(format!("no randomness: {error}")))?;
+
+ Ok(salt)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// ⚠️ Cheap parameters, and only here: deriving at the real cost in every test would
+ /// add minutes to `cargo test` to prove nothing the real parameters prove better.
+ fn cheap() -> Cost {
+ Cost {
+ memory_kib: 64,
+ passes: 1,
+ lanes: 1,
+ }
+ }
+
+ fn vault() -> Vault {
+ Vault::derive("a passphrase", b"0123456789abcdef", cheap()).unwrap()
+ }
+
+ #[test]
+ fn a_sealed_value_opens_back_to_itself() {
+ let vault = vault();
+
+ let sealed = vault.seal("psql -h prod.internal -U admin").unwrap();
+
+ assert_eq!(
+ vault.open(&sealed).unwrap(),
+ "psql -h prod.internal -U admin"
+ );
+ }
+
+ #[test]
+ fn an_empty_value_survives_the_round_trip() {
+ let vault = vault();
+
+ let sealed = vault.seal("").unwrap();
+
+ assert_eq!(vault.open(&sealed).unwrap(), "");
+ }
+
+ #[test]
+ fn accents_and_emoji_come_back_whole() {
+ let vault = vault();
+ let text = "étape de déploiement ⚡ 日本語";
+
+ assert_eq!(vault.open(&vault.seal(text).unwrap()).unwrap(), text);
+ }
+
+ /// The same text twice must not produce the same ciphertext, or the file would say
+ /// which notes are identical.
+ #[test]
+ fn sealing_the_same_text_twice_gives_two_different_values() {
+ let vault = vault();
+
+ assert_ne!(vault.seal("same").unwrap(), vault.seal("same").unwrap());
+ }
+
+ #[test]
+ fn the_sealed_form_carries_none_of_the_plaintext() {
+ let vault = vault();
+
+ let sealed = vault.seal("hunter2").unwrap();
+
+ assert!(!sealed.contains("hunter2"));
+ }
+
+ /// ⚠️ What tells a wrong passphrase from a right one. Without it an unlock would
+ /// succeed and the library would read as gibberish.
+ #[test]
+ fn a_value_will_not_open_under_another_passphrase() {
+ let sealed = vault().seal("secret").unwrap();
+ let other = Vault::derive("another passphrase", b"0123456789abcdef", cheap()).unwrap();
+
+ assert!(other.open(&sealed).is_err());
+ }
+
+ #[test]
+ fn the_same_passphrase_under_another_salt_is_another_key() {
+ let sealed = vault().seal("secret").unwrap();
+ let elsewhere = Vault::derive("a passphrase", b"fedcba9876543210", cheap()).unwrap();
+
+ assert!(elsewhere.open(&sealed).is_err());
+ }
+
+ /// ⚠️ GCM authenticates: a flipped byte is refused rather than decrypted into
+ /// plausible-looking nonsense.
+ #[test]
+ fn a_tampered_value_is_refused_rather_than_opened() {
+ let vault = vault();
+ let sealed = vault.seal("the original").unwrap();
+
+ let mut raw = BASE64.decode(&sealed).unwrap();
+ let last = raw.len() - 1;
+ raw[last] ^= 0x01;
+
+ assert!(vault.open(&BASE64.encode(raw)).is_err());
+ }
+
+ #[test]
+ fn anything_that_is_not_a_sealed_value_is_refused() {
+ let vault = vault();
+
+ assert!(vault.open("not base64 at all !!").is_err());
+ assert!(vault.open("").is_err());
+ assert!(vault.open(&BASE64.encode([0u8; 4])).is_err());
+ }
+
+ /// Two salts drawn in a row must differ, or every library would share a key.
+ #[test]
+ fn a_fresh_salt_is_not_the_previous_one() {
+ assert_ne!(fresh_salt().unwrap(), fresh_salt().unwrap());
+ }
+
+ /// ⚠️ A key that reaches a log is a key that is gone.
+ #[test]
+ fn a_vault_never_prints_its_key() {
+ assert_eq!(format!("{:?}", vault()), "Vault(…)");
+ }
+}
diff --git a/src-tauri/src/vault/migrate.rs b/src-tauri/src/vault/migrate.rs
new file mode 100644
index 0000000..b7babdd
--- /dev/null
+++ b/src-tauri/src/vault/migrate.rs
@@ -0,0 +1,238 @@
+//! Turning a library that was written in the clear into a sealed one.
+//!
+//! ⚠️ Run once, from [`super::create_vault`], on a database that already exists. Every
+//! sealed column is read as it stands and written back sealed, in **one transaction** —
+//! a half-sealed library opens into a mixture nothing can tell apart, since a value that
+//! will not open is refused rather than degraded.
+//!
+//! ⚠️ The attachment files are sealed after the transaction commits, and not inside it:
+//! a file write does not roll back. A file left in the clear is readable and recoverable;
+//! a row sealed twice is neither.
+
+use diesel::prelude::*;
+
+use crate::db::schema::{
+ attachments, global_placeholders, note_items, note_placeholders, notes, spaces,
+};
+use crate::error::StorageError;
+use crate::vault::key::Vault;
+
+/// How many rows carried plaintext, so a caller can say what happened.
+#[derive(Debug, Default, PartialEq, Eq)]
+pub struct Sealed {
+ pub notes: usize,
+ pub spaces: usize,
+ pub items: usize,
+ pub values: usize,
+ pub attachments: usize,
+}
+
+impl Sealed {
+ pub fn is_empty(&self) -> bool {
+ *self == Self::default()
+ }
+}
+
+/// ⚠️ Not idempotent, and it cannot be: a sealed value is bytes like any other, so there
+/// is no telling one apart from a plaintext body that happens to be base64. The guard is
+/// the key file — [`super::file::create`] refuses a library that already has one, and
+/// this only ever runs on the launch that creates it.
+pub fn seal_existing(
+ connection: &mut SqliteConnection,
+ vault: &Vault,
+) -> Result {
+ connection.transaction(|connection| {
+ let mut done = Sealed::default();
+
+ for (id, title, content, source) in notes::table
+ .select((notes::id, notes::title, notes::content, notes::source))
+ .load::<(String, String, String, String)>(connection)?
+ {
+ diesel::update(notes::table.find(&id))
+ .set((
+ notes::title.eq(vault.seal(&title)?),
+ notes::content.eq(vault.seal(&content)?),
+ notes::source.eq(vault.seal(&source)?),
+ ))
+ .execute(connection)?;
+ done.notes += 1;
+ }
+
+ for (id, name) in spaces::table
+ .select((spaces::id, spaces::name))
+ .load::<(String, String)>(connection)?
+ {
+ diesel::update(spaces::table.find(&id))
+ .set(spaces::name.eq(vault.seal(&name)?))
+ .execute(connection)?;
+ done.spaces += 1;
+ }
+
+ // ⚠️ Keyed on `(note_id, position)` and `(note_id, name)`, so the update has to
+ // name both halves — `find` takes a single-column key and these have none.
+ for (note_id, position, text) in note_items::table
+ .select((note_items::note_id, note_items::position, note_items::text))
+ .load::<(String, i32, String)>(connection)?
+ {
+ diesel::update(
+ note_items::table
+ .filter(note_items::note_id.eq(¬e_id))
+ .filter(note_items::position.eq(position)),
+ )
+ .set(note_items::text.eq(vault.seal(&text)?))
+ .execute(connection)?;
+ done.items += 1;
+ }
+
+ for (note_id, name, value) in note_placeholders::table
+ .select((
+ note_placeholders::note_id,
+ note_placeholders::name,
+ note_placeholders::value,
+ ))
+ .load::<(String, String, String)>(connection)?
+ {
+ diesel::update(
+ note_placeholders::table
+ .filter(note_placeholders::note_id.eq(¬e_id))
+ .filter(note_placeholders::name.eq(&name)),
+ )
+ .set(note_placeholders::value.eq(vault.seal(&value)?))
+ .execute(connection)?;
+ done.values += 1;
+ }
+
+ for (name, value) in global_placeholders::table
+ .select((global_placeholders::name, global_placeholders::value))
+ .load::<(String, String)>(connection)?
+ {
+ diesel::update(global_placeholders::table.find(&name))
+ .set(global_placeholders::value.eq(vault.seal(&value)?))
+ .execute(connection)?;
+ done.values += 1;
+ }
+
+ for (id, file_name) in attachments::table
+ .select((attachments::id, attachments::file_name))
+ .load::<(String, String)>(connection)?
+ {
+ diesel::update(attachments::table.find(&id))
+ .set(attachments::file_name.eq(vault.seal(&file_name)?))
+ .execute(connection)?;
+ done.attachments += 1;
+ }
+
+ Ok(done)
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::db;
+ use crate::notes::store as note_store;
+ use crate::spaces::store as space_store;
+
+ fn plaintext_library() -> db::Library {
+ // ⚠️ Built through the sealing stores, then un-sealed by hand below: there is no
+ // way left in the crate to write a plaintext row, which is the point.
+ db::open_in_memory().expect("a library")
+ }
+
+ /// Writes the columns back as they read, which is what a library from before
+ /// encryption looks like.
+ fn unseal_in_place(library: &mut db::Library) {
+ let (connection, vault) = library.split();
+ let rows = notes::table
+ .select((notes::id, notes::title, notes::content, notes::source))
+ .load::<(String, String, String, String)>(connection)
+ .unwrap();
+ let opened: Vec<_> = rows
+ .into_iter()
+ .map(|(id, title, content, source)| {
+ (
+ id,
+ vault.open(&title).unwrap(),
+ vault.open(&content).unwrap(),
+ vault.open(&source).unwrap(),
+ )
+ })
+ .collect();
+ for (id, title, content, source) in opened {
+ diesel::update(notes::table.find(&id))
+ .set((
+ notes::title.eq(title),
+ notes::content.eq(content),
+ notes::source.eq(source),
+ ))
+ .execute(connection)
+ .unwrap();
+ }
+
+ let spaces_rows = spaces::table
+ .select((spaces::id, spaces::name))
+ .load::<(String, String)>(connection)
+ .unwrap();
+ let opened: Vec<_> = spaces_rows
+ .into_iter()
+ .map(|(id, name)| (id, vault.open(&name).unwrap()))
+ .collect();
+ for (id, name) in opened {
+ diesel::update(spaces::table.find(&id))
+ .set(spaces::name.eq(name))
+ .execute(connection)
+ .unwrap();
+ }
+ }
+
+ #[test]
+ fn a_plaintext_library_reads_back_whole_once_sealed() {
+ let mut library = plaintext_library();
+ let space = space_store::create(&mut library, "Secrets").unwrap().id;
+ let draft = crate::notes::model::NoteDraft {
+ space_id: space.clone(),
+ title: "AWS prod".to_string(),
+ language: crate::notes::language::Language::Txt,
+ content: "hunter2".to_string(),
+ source: String::new(),
+ tags: Vec::new(),
+ pinned: false,
+ lifecycle: crate::notes::model::NoteLifecycle::Permanent,
+ kind: crate::notes::checklist::NoteKind::Snippet,
+ items: Vec::new(),
+ };
+ let created = note_store::create(
+ &mut library,
+ draft,
+ crate::notes::fixtures::at(crate::notes::fixtures::NOW),
+ )
+ .unwrap()
+ .id;
+ unseal_in_place(&mut library);
+
+ let done = {
+ let (connection, vault) = library.split();
+ seal_existing(connection, vault).unwrap()
+ };
+
+ assert_eq!(done.notes, 1);
+ assert_eq!(done.spaces, 1);
+
+ let read_back = note_store::by_ids(&mut library, &[created]).unwrap();
+ assert_eq!(read_back[0].title, "AWS prod");
+ assert_eq!(read_back[0].content, "hunter2");
+ assert_eq!(space_store::list(&mut library).unwrap()[0].name, "Secrets");
+ }
+
+ #[test]
+ fn an_empty_library_seals_nothing_and_says_so() {
+ let mut library = plaintext_library();
+
+ let done = {
+ let (connection, vault) = library.split();
+ seal_existing(connection, vault).unwrap()
+ };
+
+ assert!(done.is_empty());
+ }
+}
diff --git a/src-tauri/tests/ipc_contract.rs b/src-tauri/tests/ipc_contract.rs
index d74cfc0..8589573 100644
--- a/src-tauri/tests/ipc_contract.rs
+++ b/src-tauri/tests/ipc_contract.rs
@@ -426,6 +426,8 @@ fn an_import_report_names_what_it_skipped() {
notes_imported: 2,
notes_skipped: 3,
notes_degraded: 4,
+ attachments_imported: 1,
+ attachments_missing: 0,
})
.unwrap();
@@ -448,6 +450,7 @@ fn an_export_bundle_reads_back_the_notes_it_wrote() {
pinned: false,
}],
notes: vec![sample()],
+ attachments: Vec::new(),
};
let json = serde_json::to_string(&bundle).unwrap();
diff --git a/src-tauri/tests/notes.rs b/src-tauri/tests/notes.rs
index 0395d34..243db6a 100644
--- a/src-tauri/tests/notes.rs
+++ b/src-tauri/tests/notes.rs
@@ -1,6 +1,6 @@
use std::collections::BTreeMap;
-use diesel::SqliteConnection;
+use devbox_lib::db::Library;
use diesel::prelude::*;
use chrono::{DateTime, Utc};
@@ -22,7 +22,7 @@ use devbox_lib::spaces::store as spaces;
/// No such shortcut exists in production code: it would invite re-filtering on the
/// front end.
-fn list(connection: &mut SqliteConnection) -> Result, StorageError> {
+fn list(connection: &mut Library) -> Result, StorageError> {
fetch(
connection,
&NotesQuery {
@@ -51,15 +51,12 @@ fn t1() -> DateTime {
at("2026-07-25T10:00:00.000Z")
}
-fn query(
- connection: &mut SqliteConnection,
- request: &NotesQuery,
-) -> Result {
+fn query(connection: &mut Library, request: &NotesQuery) -> Result {
let (notes, facets) = fetch(connection, request)?;
Ok(view::build(notes, facets, request))
}
-fn space(connection: &mut SqliteConnection, name: &str) -> String {
+fn space(connection: &mut Library, name: &str) -> String {
spaces::create(connection, name).unwrap().id
}
@@ -352,7 +349,7 @@ fn emptying_the_list_leaves_no_row_behind() {
assert_eq!(
note_items::table
.count()
- .get_result::(&mut connection)
+ .get_result::(connection.db())
.unwrap(),
0
);
@@ -400,7 +397,7 @@ fn purging_removes_the_note_and_its_items_for_good() {
assert_eq!(
note_items::table
.count()
- .get_result::(&mut connection)
+ .get_result::(connection.db())
.unwrap(),
0
);
@@ -508,7 +505,7 @@ fn deleting_takes_the_note_off_the_canvas_without_destroying_it() {
assert!(list(&mut connection).unwrap().is_empty());
let kept_tags = note_tags::table
.count()
- .get_result::(&mut connection)
+ .get_result::(connection.db())
.unwrap();
assert_eq!(kept_tags, 2);
@@ -563,7 +560,7 @@ fn purging_removes_the_note_and_its_tags_for_good() {
assert!(list_trashed(&mut connection).unwrap().is_empty());
let orphan_tags = note_tags::table
.count()
- .get_result::(&mut connection)
+ .get_result::(connection.db())
.unwrap();
assert_eq!(orphan_tags, 0);
}
@@ -727,7 +724,7 @@ fn purging_removes_the_note_and_its_values_for_good() {
assert_eq!(
note_placeholders::table
.count()
- .get_result::(&mut connection)
+ .get_result::(connection.db())
.unwrap(),
0
);
@@ -1669,7 +1666,7 @@ fn deleting_a_space_takes_its_notes_with_it() {
create(&mut connection, draft(&space_id), t0()).unwrap();
diesel::delete(spaces_table::table.find(&space_id))
- .execute(&mut connection)
+ .execute(connection.db())
.unwrap();
assert!(list(&mut connection).unwrap().is_empty());
@@ -1683,7 +1680,7 @@ fn a_stored_date_that_is_out_of_format_is_reported_rather_than_guessed() {
diesel::update(devbox_lib::db::schema::notes::table.find(&created.id))
.set(devbox_lib::db::schema::notes::created_at.eq("pas une date"))
- .execute(&mut connection)
+ .execute(connection.db())
.unwrap();
let error = list(&mut connection).unwrap_err();
@@ -1708,8 +1705,73 @@ fn a_stored_date_always_carries_its_milliseconds() {
let stored: String = devbox_lib::db::schema::notes::table
.find(&created.id)
.select(devbox_lib::db::schema::notes::updated_at)
- .first(&mut connection)
+ .first(connection.db())
.unwrap();
assert_eq!(stored, "2026-07-25T09:00:00.000Z");
}
+
+/// ⚠️ The point of the whole thing, and the only test that reads the file rather than the
+/// API: a note written through the store must not be findable by grepping the database.
+#[test]
+fn a_note_is_not_readable_in_the_file_it_was_written_to() {
+ use devbox_lib::db;
+
+ let directory = std::env::temp_dir().join(format!(
+ "devbox-sealed-{}",
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_nanos()
+ ));
+ std::fs::create_dir_all(&directory).unwrap();
+ let path = directory.join("sealed.sqlite3");
+
+ let secret = "psql -h prod.internal -U admin -W hunter2";
+ {
+ let mut library = db::open(&path, db::test_vault().unwrap()).unwrap();
+ let space = spaces::create(&mut library, "Secrets").unwrap().id;
+ let mut seeded = draft(&space);
+ seeded.title = "AWS prod credentials".to_string();
+ seeded.content = secret.to_string();
+ seeded.tags = vec!["prod".to_string()];
+ create(&mut library, seeded, t0()).unwrap();
+
+ // A variable for the whole corpus is where a host name or a token ends up, so it
+ // is sealed like anything else a reader would want.
+ let mut globals = std::collections::BTreeMap::new();
+ globals.insert("host".to_string(), "prod.internal".to_string());
+ devbox_lib::notes::store::replace_global_placeholder_values(&mut library, &globals)
+ .unwrap();
+ }
+
+ let raw = std::fs::read(&path).unwrap();
+ let haystack = String::from_utf8_lossy(&raw);
+
+ assert!(!haystack.contains(secret), "the body is in the clear");
+ assert!(
+ !haystack.contains("AWS prod credentials"),
+ "the title is in the clear"
+ );
+ assert!(
+ !haystack.contains("Secrets"),
+ "the space name is in the clear"
+ );
+ assert!(
+ !haystack.contains("prod.internal"),
+ "a global variable's value is in the clear"
+ );
+ // ⚠️ Tags and the names of variables are deliberately not sealed: the facet, the
+ // filter and the lookup all touch them in SQL. This asserts the decision rather than
+ // an accident.
+ assert!(
+ haystack.contains("prod"),
+ "a tag is expected to stay readable"
+ );
+ assert!(
+ haystack.contains("host"),
+ "a variable's name is expected to stay readable"
+ );
+
+ std::fs::remove_dir_all(&directory).ok();
+}
diff --git a/src-tauri/tests/spaces.rs b/src-tauri/tests/spaces.rs
index df2662e..9f09cce 100644
--- a/src-tauri/tests/spaces.rs
+++ b/src-tauri/tests/spaces.rs
@@ -1,4 +1,4 @@
-use diesel::SqliteConnection;
+use devbox_lib::db::Library;
use diesel::prelude::*;
use devbox_lib::db::open_in_memory;
@@ -10,7 +10,7 @@ const T0: &str = "2026-07-25T09:00:00.000Z";
/// A note written straight into the database: going through `notes::create` would
/// drag its own rules in.
-fn note_in(connection: &mut SqliteConnection, space_id: &str) {
+fn note_in(connection: &mut Library, space_id: &str) {
diesel::insert_into(notes::table)
.values((
notes::id.eq("n-1"),
@@ -24,11 +24,11 @@ fn note_in(connection: &mut SqliteConnection, space_id: &str) {
notes::updated_at.eq(T0),
notes::lifecycle_kind.eq("permanent"),
))
- .execute(connection)
+ .execute(connection.db())
.unwrap();
}
-fn names(connection: &mut SqliteConnection) -> Vec {
+fn names(connection: &mut Library) -> Vec {
list(connection)
.unwrap()
.into_iter()
@@ -207,7 +207,7 @@ fn deleting_a_space_moves_its_notes_to_the_target() {
let space_id = notes::table
.find("n-1")
.select(notes::space_id)
- .first::(&mut connection)
+ .first::(connection.db())
.unwrap();
assert_eq!(space_id, refuge.id);
assert_eq!(list(&mut connection).unwrap().len(), 1);
@@ -225,7 +225,7 @@ fn moving_notes_out_of_a_deleted_space_does_not_touch_their_timestamps() {
let updated_at = notes::table
.find("n-1")
.select(notes::updated_at)
- .first::(&mut connection)
+ .first::(connection.db())
.unwrap();
assert_eq!(updated_at, T0);
}
@@ -264,7 +264,7 @@ fn deleting_into_an_unknown_space_changes_nothing() {
assert_eq!(
notes::table
.count()
- .get_result::(&mut connection)
+ .get_result::(connection.db())
.unwrap(),
1
);
diff --git a/src-tauri/tests/transfer.rs b/src-tauri/tests/transfer.rs
index cbe8313..9b5421f 100644
--- a/src-tauri/tests/transfer.rs
+++ b/src-tauri/tests/transfer.rs
@@ -1,14 +1,18 @@
use chrono::{DateTime, Utc};
-use diesel::SqliteConnection;
+use devbox_lib::db::Library;
+use devbox_lib::attachments::model::Attachment;
+use devbox_lib::attachments::store as attachments;
use devbox_lib::db::{iso8601, open_in_memory};
use devbox_lib::notes::checklist::NoteKind;
use devbox_lib::notes::language::Language;
use devbox_lib::notes::model::{NoteDraft, NoteLifecycle};
use devbox_lib::notes::store as notes;
use devbox_lib::spaces::store as spaces;
-use devbox_lib::transfer::bundle::{collect, merge};
-use devbox_lib::transfer::model::{self, Bundle, IncomingBundle};
+use devbox_lib::transfer::bundle::{collect, merge as merge_bundle};
+use devbox_lib::transfer::file;
+use devbox_lib::transfer::file::Payload;
+use devbox_lib::transfer::model::{self, Bundle, ImportReport, IncomingBundle};
fn t0() -> DateTime {
iso8601::parse("2026-07-25T09:00:00.000Z").unwrap()
@@ -29,8 +33,21 @@ fn draft(space_id: &str, title: &str) -> NoteDraft {
}
}
+/// No attachment travels in these scenarios — the archive itself is covered in
+/// `transfer::file`. `Payload::Empty` hands over no bytes, so the directory is never
+/// written to.
+fn merge(connection: &mut Library, bundle: IncomingBundle) -> ImportReport {
+ merge_bundle(
+ connection,
+ bundle,
+ &mut Payload::Empty,
+ &std::env::temp_dir(),
+ )
+ .unwrap()
+}
+
/// A populated database, the way a user would have one.
-fn library() -> SqliteConnection {
+fn library() -> Library {
let mut connection = open_in_memory().unwrap();
let personal = spaces::create(&mut connection, "Personal").unwrap().id;
let boulot = spaces::create(&mut connection, "Boulot").unwrap().id;
@@ -40,7 +57,7 @@ fn library() -> SqliteConnection {
connection
}
-fn exported(connection: &mut SqliteConnection) -> Bundle {
+fn exported(connection: &mut Library) -> Bundle {
let all = notes::all(connection, None).unwrap();
collect(connection, all).unwrap()
}
@@ -65,7 +82,7 @@ fn a_library_moves_whole_to_another_machine() {
let bundle = round_tripped(&exported(&mut source));
let mut target = open_in_memory().unwrap();
- let report = merge(&mut target, bundle).unwrap();
+ let report = merge(&mut target, bundle);
assert_eq!(report.notes_imported, 2);
assert_eq!(report.spaces_created, 2);
@@ -99,8 +116,8 @@ fn importing_the_same_file_twice_adds_nothing_the_second_time() {
let file = exported(&mut source);
let mut target = open_in_memory().unwrap();
- merge(&mut target, round_tripped(&file)).unwrap();
- let second = merge(&mut target, round_tripped(&file)).unwrap();
+ merge(&mut target, round_tripped(&file));
+ let second = merge(&mut target, round_tripped(&file));
assert_eq!(second.notes_imported, 0);
assert_eq!(second.notes_skipped, 2);
@@ -113,7 +130,7 @@ fn reimporting_into_the_base_it_came_from_changes_nothing() {
let mut library = library();
let bundle = round_tripped(&exported(&mut library));
- let report = merge(&mut library, bundle).unwrap();
+ let report = merge(&mut library, bundle);
assert_eq!(report.notes_imported, 0);
assert_eq!(report.notes_skipped, 2);
@@ -128,7 +145,7 @@ fn a_space_of_the_same_name_is_reused_rather_than_duplicated() {
let mut target = open_in_memory().unwrap();
spaces::create(&mut target, "PERSONAL").unwrap();
- let report = merge(&mut target, bundle).unwrap();
+ let report = merge(&mut target, bundle);
assert_eq!(report.spaces_created, 1);
assert_eq!(spaces::list(&mut target).unwrap().len(), 2);
@@ -141,7 +158,7 @@ fn a_note_whose_space_is_missing_from_the_file_is_skipped_not_misfiled() {
incoming.bundle.spaces.clear();
let mut target = open_in_memory().unwrap();
- let report = merge(&mut target, incoming).unwrap();
+ let report = merge(&mut target, incoming);
assert_eq!(report.notes_imported, 0);
assert_eq!(report.notes_skipped, 2);
@@ -169,8 +186,7 @@ fn a_note_in_an_unknown_language_arrives_without_taking_the_file_down() {
let report = merge(
&mut target,
written_by_a_newer_version(&file, "language", "from-the-future"),
- )
- .unwrap();
+ );
assert_eq!(report.notes_imported, 2);
assert_eq!(report.notes_degraded, 1);
@@ -193,8 +209,7 @@ fn a_note_of_an_unknown_kind_is_degraded_the_same_way() {
let report = merge(
&mut target,
written_by_a_newer_version(&file, "kind", "from-the-future"),
- )
- .unwrap();
+ );
assert_eq!(report.notes_imported, 2);
assert_eq!(report.notes_degraded, 1);
@@ -217,15 +232,171 @@ fn a_degraded_note_is_counted_once_and_not_again_on_a_second_import() {
merge(
&mut target,
written_by_a_newer_version(&file, "language", "from-the-future"),
- )
- .unwrap();
+ );
let second = merge(
&mut target,
written_by_a_newer_version(&file, "language", "from-the-future"),
+ );
+
+ assert_eq!(second.notes_imported, 0);
+ assert_eq!(second.notes_degraded, 0);
+}
+
+/// The attachment as it really sits beside the database: sealed under the library key,
+/// which is what the export has to open before it can write the file out.
+fn seal_beside(directory: &std::path::Path, library: &Library, record: &Attachment, bytes: &[u8]) {
+ let sealed = library.vault().seal_bytes(bytes).unwrap();
+ std::fs::write(directory.join(record.stored_name()), sealed).unwrap();
+}
+
+/// `create` takes the pair, the way an import inside a transaction does.
+fn attach(
+ library: &mut Library,
+ record: &Attachment,
+) -> Result<(), devbox_lib::error::StorageError> {
+ let (db, vault) = library.split();
+ attachments::create(db, vault, record)
+}
+
+/// A directory of its own per scenario: these write real files beside a real archive.
+fn scratch() -> std::path::PathBuf {
+ let stamp = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_nanos();
+ let directory = std::env::temp_dir().join(format!("devbox-transfer-{stamp}"));
+ std::fs::create_dir_all(&directory).unwrap();
+
+ directory
+}
+
+fn capture(note_id: &str) -> Attachment {
+ Attachment {
+ id: format!("a-{note_id}"),
+ note_id: note_id.to_string(),
+ file_name: "capture.png".to_string(),
+ mime_type: "image/png".to_string(),
+ byte_size: 4,
+ created_at: t0(),
+ }
+}
+
+/// ⚠️ The point of the archive. A screenshot is a file beside the database, so an export
+/// that carried only the rows handed over notes whose thumbnails would never load — and
+/// nothing said so.
+#[test]
+fn an_attachment_travels_with_the_library() {
+ let directory = scratch();
+ let source_files = directory.join("source");
+ let target_files = directory.join("target");
+ std::fs::create_dir_all(&source_files).unwrap();
+ std::fs::create_dir_all(&target_files).unwrap();
+
+ let mut source = library();
+ let note_id = notes::all(&mut source, None).unwrap()[0].id.clone();
+ let record = capture(¬e_id);
+ seal_beside(&source_files, &source, &record, b"\x89PNG");
+ attach(&mut source, &record).unwrap();
+
+ let target_path = directory
+ .join("library.devbox")
+ .to_string_lossy()
+ .to_string();
+ let packed = exported(&mut source);
+ let written = file::write(&target_path, &packed, &source_files, source.vault(), None).unwrap();
+ assert_eq!(written.attachments, 1);
+
+ let mut target = open_in_memory().unwrap();
+ let (incoming, mut payload) = file::read(&target_path, None).unwrap();
+ let report = merge_bundle(&mut target, incoming, &mut payload, &target_files).unwrap();
+
+ assert_eq!(report.attachments_imported, 1);
+ assert_eq!(report.attachments_missing, 0);
+ assert_eq!(attachments::list(&mut target, ¬e_id).unwrap().len(), 1);
+ assert_eq!(
+ std::fs::read(target_files.join(record.stored_name())).unwrap(),
+ b"\x89PNG"
+ );
+
+ std::fs::remove_dir_all(&directory).ok();
+}
+
+/// Re-importing the same archive adds nothing, attachments included: the notes are
+/// skipped, so their files have nowhere to land twice.
+#[test]
+fn importing_the_same_archive_twice_restores_the_attachment_once() {
+ let directory = scratch();
+ let files = directory.join("files");
+ std::fs::create_dir_all(&files).unwrap();
+
+ let mut source = library();
+ let note_id = notes::all(&mut source, None).unwrap()[0].id.clone();
+ let record = capture(¬e_id);
+ seal_beside(&files, &source, &record, b"\x89PNG");
+ attach(&mut source, &record).unwrap();
+
+ let target_path = directory
+ .join("library.devbox")
+ .to_string_lossy()
+ .to_string();
+ file::write(
+ &target_path,
+ &exported(&mut source),
+ &files,
+ source.vault(),
+ None,
)
.unwrap();
+ let mut target = open_in_memory().unwrap();
+ let (first, mut payload) = file::read(&target_path, None).unwrap();
+ merge_bundle(&mut target, first, &mut payload, &files).unwrap();
+
+ let (again, mut payload) = file::read(&target_path, None).unwrap();
+ let second = merge_bundle(&mut target, again, &mut payload, &files).unwrap();
+
assert_eq!(second.notes_imported, 0);
- assert_eq!(second.notes_degraded, 0);
+ assert_eq!(second.attachments_imported, 0);
+ assert_eq!(attachments::list(&mut target, ¬e_id).unwrap().len(), 1);
+
+ std::fs::remove_dir_all(&directory).ok();
+}
+
+/// ⚠️ A record whose bytes the archive does not carry is counted, never swallowed: the
+/// note arrives with a preview that will stay empty, and the report is what explains it.
+#[test]
+fn an_attachment_the_archive_does_not_carry_is_reported() {
+ let directory = scratch();
+ let files = directory.join("files");
+ std::fs::create_dir_all(&files).unwrap();
+
+ let mut source = library();
+ let note_id = notes::all(&mut source, None).unwrap()[0].id.clone();
+ // The record exists, its file never did: the export writes the row and no entry.
+ attach(&mut source, &capture(¬e_id)).unwrap();
+
+ let target_path = directory
+ .join("library.devbox")
+ .to_string_lossy()
+ .to_string();
+ let written = file::write(
+ &target_path,
+ &exported(&mut source),
+ &files,
+ source.vault(),
+ None,
+ )
+ .unwrap();
+ assert_eq!(written.attachments, 0);
+
+ let mut target = open_in_memory().unwrap();
+ let (incoming, mut payload) = file::read(&target_path, None).unwrap();
+ let report = merge_bundle(&mut target, incoming, &mut payload, &files).unwrap();
+
+ assert_eq!(report.notes_imported, 2);
+ assert_eq!(report.attachments_missing, 1);
+ assert_eq!(report.attachments_imported, 0);
+
+ std::fs::remove_dir_all(&directory).ok();
}
diff --git a/src/app/app.component.html b/src/app/app.component.html
index 4508a5b..c92bd21 100644
--- a/src/app/app.component.html
+++ b/src/app/app.component.html
@@ -2,6 +2,16 @@
-
+
+ @if (vault.isUnlocked()) {
+
+ } @else if (vault.state() !== null) {
+
+ }
+
diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts
index 9adf3f2..c4086c1 100644
--- a/src/app/app.component.spec.ts
+++ b/src/app/app.component.spec.ts
@@ -6,6 +6,10 @@ import { ErrorBannerComponent } from '@banners/error-banner/error-banner.compone
import { TitlebarComponent } from '@titlebar/titlebar.component';
import { UpdatePromptComponent } from '@banners/update-prompt/update-prompt.component';
import { provideAppTesting } from '@testing/testing.providers';
+import { VaultGateComponent } from './vault-gate/vault-gate.component';
+import { VaultStore } from '@core/state/vault.store';
+import { VaultRepository } from '@core/data/vault.repository';
+import { FakeVaultRepository } from '@testing/fake-vault-repository';
import { AppComponent } from './app.component';
describe('AppComponent', () => {
@@ -22,13 +26,46 @@ describe('AppComponent', () => {
fixture.autoDetectChanges();
});
+ /** The shell asks Rust before the first render; a spec says what the answer was. */
+ async function withVault(state: 'absent' | 'locked' | 'unlocked'): Promise {
+ (TestBed.inject(VaultRepository) as unknown as FakeVaultRepository).answer = state;
+ await TestBed.inject(VaultStore).load();
+ await fixture.whenStable();
+ }
+
it('renders the persistent chrome: titlebar, global error banner and update prompt', () => {
expect(fixture.debugElement.query(By.directive(TitlebarComponent))).not.toBeNull();
expect(fixture.debugElement.query(By.directive(ErrorBannerComponent))).not.toBeNull();
expect(fixture.debugElement.query(By.directive(UpdatePromptComponent))).not.toBeNull();
});
- it('hosts features through the router outlet rather than importing them directly', () => {
+ it('hosts features through the router outlet rather than importing them directly', async () => {
+ await withVault('unlocked');
+
expect(fixture.debugElement.query(By.directive(RouterOutlet))).not.toBeNull();
});
+
+ /**
+ * ⚠️ The outlet is not merely hidden while the library is locked: it is not created.
+ * The canvas queries notes the moment it mounts, and there would be nothing to answer.
+ */
+ it('puts the gate in front of the outlet while the library is locked', async () => {
+ await withVault('locked');
+
+ expect(fixture.debugElement.query(By.directive(VaultGateComponent))).not.toBeNull();
+ expect(fixture.debugElement.query(By.directive(RouterOutlet))).toBeNull();
+ });
+
+ /** A library that has never been encrypted asks for a passphrase to be chosen. */
+ it('asks for a passphrase to be created on a library that has none', async () => {
+ await withVault('absent');
+
+ expect(fixture.debugElement.query(By.directive(VaultGateComponent))).not.toBeNull();
+ });
+
+ /** Before the answer lands, neither: the shell would flash a screen it is replacing. */
+ it('renders neither until Rust has answered', () => {
+ expect(fixture.debugElement.query(By.directive(VaultGateComponent))).toBeNull();
+ expect(fixture.debugElement.query(By.directive(RouterOutlet))).toBeNull();
+ });
});
diff --git a/src/app/app.component.ts b/src/app/app.component.ts
index 2c339c7..12d39dd 100644
--- a/src/app/app.component.ts
+++ b/src/app/app.component.ts
@@ -1,9 +1,12 @@
-import { ChangeDetectionStrategy, Component } from '@angular/core';
+import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { ErrorBannerComponent } from '@banners/error-banner/error-banner.component';
import { StatusToastComponent } from '@banners/status-toast/status-toast.component';
import { TitlebarComponent } from '@titlebar/titlebar.component';
import { UpdatePromptComponent } from '@banners/update-prompt/update-prompt.component';
+import { PassphrasePromptComponent } from './passphrase-prompt/passphrase-prompt.component';
+import { VaultGateComponent } from './vault-gate/vault-gate.component';
+import { VaultStore } from '@core/state/vault.store';
@Component({
selector: 'app-root',
@@ -13,9 +16,13 @@ import { UpdatePromptComponent } from '@banners/update-prompt/update-prompt.comp
StatusToastComponent,
RouterOutlet,
UpdatePromptComponent,
+ VaultGateComponent,
+ PassphrasePromptComponent,
],
templateUrl: './app.component.html',
styleUrl: './app.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
-export class AppComponent {}
+export class AppComponent {
+ protected readonly vault = inject(VaultStore);
+}
diff --git a/src/app/app.config.ts b/src/app/app.config.ts
index e1366f0..43a2c15 100644
--- a/src/app/app.config.ts
+++ b/src/app/app.config.ts
@@ -20,6 +20,7 @@ import { SettingsStore } from '@core/services/settings/settings.store';
import { GlobalShortcutsService } from '@core/services/shortcuts/global-shortcuts.service';
import { TrayService } from '@core/services/tray/tray.service';
import { UpdateStore } from '@core/services/updates/update.store';
+import { VaultStore } from '@core/state/vault.store';
import { WindowBehaviorService } from '@core/services/window/window-behavior.service';
export const appConfig: ApplicationConfig = {
@@ -54,6 +55,7 @@ export const appConfig: ApplicationConfig = {
const shortcuts = inject(GlobalShortcutsService);
const windowBehavior = inject(WindowBehaviorService);
const autostart = inject(AutostartService);
+ const vault = inject(VaultStore);
await preferences.hydrate();
// ⚠️ Before the first render, and before `locale.restore()`, which reads the
@@ -64,6 +66,10 @@ export const appConfig: ApplicationConfig = {
// earlier would push the default language and a setting the user had changed.
tray.start();
+ // ⚠️ Before the first render: the shell renders nothing at all until this answers,
+ // rather than flashing a canvas it is about to replace with an unlock screen.
+ await vault.load();
+
shortcuts.start();
windowBehavior.start();
// Not awaited: asking the system must not delay the first render.
diff --git a/src/app/core/data/transfer.repository.ts b/src/app/core/data/transfer.repository.ts
index 1d92123..1214148 100644
--- a/src/app/core/data/transfer.repository.ts
+++ b/src/app/core/data/transfer.repository.ts
@@ -6,17 +6,30 @@ import { ExportReport, ImportReport } from '../model/note.model';
/** The file is written and read back on the Rust side; the front only picks a path. */
@Injectable({ providedIn: 'root' })
export class TransferRepository {
- /** A `null` `spaceId` exports the whole corpus. */
- async export(path: string, spaceId: string | null): Promise {
- return unwrap('export_notes', await commands.exportNotes(path, spaceId));
+ /**
+ * A `null` `spaceId` exports the whole corpus. ⚠️ A `null` `passphrase` writes the file
+ * in the clear — the library's own key protects what is on this machine, never what
+ * leaves it.
+ */
+ async export(path: string, spaceId: string | null, passphrase: string | null): Promise {
+ return unwrap('export_notes', await commands.exportNotes(path, spaceId, passphrase));
}
- async exportSelection(path: string, ids: readonly string[]): Promise {
- return unwrap('export_selection', await commands.exportSelection(path, [...ids]));
+ async exportSelection(
+ path: string,
+ ids: readonly string[],
+ passphrase: string | null,
+ ): Promise {
+ return unwrap('export_selection', await commands.exportSelection(path, [...ids], passphrase));
}
- async import(path: string): Promise {
- return unwrap('import_notes', await commands.importNotes(path));
+ async import(path: string, passphrase: string | null): Promise {
+ return unwrap('import_notes', await commands.importNotes(path, passphrase));
+ }
+
+ /** Asked before the import, so the phrase can be requested rather than demanded twice. */
+ async isProtected(path: string): Promise