fix: keep the mirror out of the device root

Proton refuses a file directly in a device's root folder — 422 "Cannot
create file at the root of a device" — so the first real backup died on
RESTORE.txt, before it had written anything. The mirror now lives in a
`data/` folder on the device, which is both what Proton permits and the
honest name for what it holds: the contents of DIARY_DATA_DIR.

Every remote path changed with it, so the bookkeeping describing the old
layout is dropped and rebuilt on the next run. A device root that
already collected an empty `uploads/` folder from the failed attempt
keeps it; it is not ours to guess about, and deleting it is one click.

Also quiet rPGP's packet-header warnings by default. They fire on every
key Proton wrote slightly differently to how rPGP re-serialises it —
cosmetic, once per unlock, and loud enough to bury the backup's own log.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D5Gk8FPkRXu1e6Nj2BCRE1
This commit is contained in:
nvrlandClaude Opus 5 committed 2026-09-05 13:39:41 +02:00
1 parent ca0039400e
commit 7646ecc397
5 files changed
+62 -24

No files matched your search

+7 -6
View File
@@ -114,16 +114,17 @@ it has been quiet for `DIARY_BACKUP_DEBOUNCE_SEC` the mirror runs, with
sixty. The device folder ends up as a copy of the data directory:
```text
narl-diary/
RESTORE.txt what this is, and how to put it back
diary.db a VACUUM INTO snapshot — consistent, no write-ahead log
uploads/<uuid> every uploaded file, under the name the database knows
narl-diary/ the device — Proton allows only folders in a device root
data/ the diary's data directory, copied
RESTORE.txt what this is, and how to put it back
diary.db a VACUUM INTO snapshot — consistent, no write-ahead log
uploads/<uuid> every uploaded file, under the name the database knows
```
`diary.db` becomes a new revision each time, so Proton Drive keeps the older
ones and a mistake that was mirrored can still be undone. Uploads are written
once and never rewritten. Restoring is copying both back into an empty data
directory — no tool in between, which is the point.
once and never rewritten. Restoring is copying the contents of `data/`
back into an empty data directory — no tool in between, which is the point.
| command | what it does |
| --- | --- |
+8
View File
@@ -0,0 +1,8 @@
-- A device's root folder rejects files: Proton answers 422 "Cannot create file
-- at the root of a device". Everything therefore moved one level down, into a
-- `data/` folder, which changed every remote path. The bookkeeping describes a
-- layout that no longer exists, so it is dropped and the mirror rebuilds itself
-- on the next run — an upload again, not a loss.
DELETE FROM backup_files;
DELETE FROM backup_state;
+26 -7
View File
@@ -161,10 +161,16 @@ impl Backup {
Ok(Some(summary))
}
/// The two folders the mirror writes into: the device's root, and the
/// `uploads/` folder inside it. Both are registered on first use and then
/// The two folders the mirror writes into: `data/` on the device, and
/// `data/uploads/` inside it. Both are registered on first use and then
/// remembered.
///
/// Nothing is written to the device's own root, because Proton refuses it —
/// a device root holds folders, and a file there is answered with 422
/// "Cannot create file at the root of a device". `data/` is that folder,
/// and it is also the honest name for what it holds: the diary's data
/// directory, copied.
///
/// Remembering them is the point. Resolving either by name means listing a
/// folder's children and decrypting every name in it, and Proton asks
/// third-party clients not to traverse the tree repeatedly for things that
@@ -175,7 +181,7 @@ impl Backup {
async fn device_folders(&self, client: &ProtonDriveClient) -> Result<(NodeUid, NodeUid)> {
let name = self.config.backup.device_name.clone();
let cached_name = self.state_get("device_name").await?;
let fresh = cached_name.as_deref() != Some(name.as_str());
let mut fresh = cached_name.as_deref() != Some(name.as_str());
let root = match self.cached_node(client, "device_root", fresh).await? {
Some(uid) => uid,
@@ -185,17 +191,30 @@ impl Backup {
self.state_set("device_uid", device.uid.as_str()).await?;
self.state_set("device_root", &device.root_folder_uid.to_string())
.await?;
// A new device root cannot hold the old uploads folder.
self.state_set("device_uploads", "").await?;
// Folders remembered inside the old root mean nothing in a new
// one, whichever of them still resolves.
fresh = true;
device.root_folder_uid
}
};
let data = match self.cached_node(client, "device_data", fresh).await? {
Some(uid) => uid,
None => {
let uid = client
.create_folder_path(&root, "data")
.await
.context("could not create the data folder on the device")?;
self.state_set("device_data", &uid.to_string()).await?;
uid
}
};
let uploads = match self.cached_node(client, "device_uploads", fresh).await? {
Some(uid) => uid,
None => {
let uid = client
.create_folder_path(&root, "uploads")
.create_folder_path(&data, "uploads")
.await
.context("could not create the uploads folder on the device")?;
self.state_set("device_uploads", &uid.to_string()).await?;
@@ -204,7 +223,7 @@ impl Backup {
};
self.set(move |s| s.device = Some(name.clone()));
Ok((root, uploads))
Ok((data, uploads))
}
/// A remembered node, if it is still remembered and still there.
+13 -10
View File
@@ -3,10 +3,11 @@
//! The remote layout is deliberately the local one, not a rendering of it:
//!
//! ```text
//! narl-diary/ the device's sync root
//! RESTORE.txt what this is and how to put it back
//! diary.db a consistent snapshot, one revision per change
//! uploads/<uuid> every uploaded file, under the name the database knows
//! narl-diary/ the device's sync root — folders only, per Proton
//! data/ the diary's data directory, copied
//! RESTORE.txt what this is and how to put it back
//! diary.db a consistent snapshot, one revision per change
//! uploads/<uuid> every uploaded file, under the name the database knows
//! ```
//!
//! so restoring is copying two things into an empty data directory, with no
@@ -33,7 +34,9 @@ pub struct Summary {
}
const RESTORE: &str = "\
This is a Proton Drive mirror of a ~/diary server.
This is a Proton Drive mirror of a ~/diary server. It is the contents of the
server's data directory, one folder down from the device root because Proton
does not allow files directly in it.
diary.db the whole diary: entries, media metadata, share tokens
uploads/<uuid> every uploaded file; diary.db gives each one its real name
@@ -55,7 +58,7 @@ pub async fn run(
db: &SqlitePool,
config: &Config,
client: &ProtonDriveClient,
root: &NodeUid,
data_folder: &NodeUid,
uploads_folder: &NodeUid,
) -> Result<Summary> {
let mut mirror = Mirror {
@@ -67,10 +70,10 @@ pub async fn run(
// Written once, and only ever re-read by a human who has lost everything
// else — so it goes up before the data it explains.
mirror
.bytes(root, "RESTORE.txt", "RESTORE.txt", "text/plain", RESTORE.as_bytes())
.bytes(data_folder, "RESTORE.txt", "RESTORE.txt", "text/plain", RESTORE.as_bytes())
.await?;
mirror.database(config, root).await?;
mirror.database(config, data_folder).await?;
mirror.media(config, uploads_folder).await?;
if config.backup.prune {
@@ -94,7 +97,7 @@ impl Mirror<'_> {
/// write-ahead log already folded in. Copying the file by hand while the
/// diary is being written to would produce something that may or may not
/// open.
async fn database(&mut self, config: &Config, root: &NodeUid) -> Result<()> {
async fn database(&mut self, config: &Config, data_folder: &NodeUid) -> Result<()> {
let snapshot = config.data_dir.join(".diary-backup.db");
// A snapshot left behind by a killed run would fail the next one:
// VACUUM INTO refuses to write over an existing file.
@@ -107,7 +110,7 @@ impl Mirror<'_> {
.context("could not snapshot the database")?;
let result = self
.file(root, "diary.db", "diary.db", "application/vnd.sqlite3", &snapshot)
.file(data_folder, "diary.db", "diary.db", "application/vnd.sqlite3", &snapshot)
.await;
let _ = tokio::fs::remove_file(&snapshot).await;
+8 -1
View File
@@ -36,7 +36,14 @@ async fn main() -> Result<()> {
dotenvy::dotenv().ok();
tracing_subscriber::registry()
.with(EnvFilter::try_from_env("DIARY_LOG").unwrap_or_else(|_| EnvFilter::new("info")))
// rPGP warns on every key it re-serialises differently to how Proton
// wrote it — a cosmetic packet-header difference, once per unlock, that
// otherwise buries the backup's own log lines. `DIARY_LOG` overrides
// all of this when something needs looking at.
.with(
EnvFilter::try_from_env("DIARY_LOG")
.unwrap_or_else(|_| EnvFilter::new("info,pgp=error")),
)
.with(tracing_subscriber::fmt::layer())
.init();