diff --git a/README.md b/README.md index d287df2..dd9310e 100644 --- a/README.md +++ b/README.md @@ -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/ 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/ 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 | | --- | --- | diff --git a/migrations/0004_backup_layout.sql b/migrations/0004_backup_layout.sql new file mode 100644 index 0000000..e312c16 --- /dev/null +++ b/migrations/0004_backup_layout.sql @@ -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; diff --git a/src/backup/mod.rs b/src/backup/mod.rs index 159a952..ba2a948 100644 --- a/src/backup/mod.rs +++ b/src/backup/mod.rs @@ -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. diff --git a/src/backup/sync.rs b/src/backup/sync.rs index 5d061a4..9e34741 100644 --- a/src/backup/sync.rs +++ b/src/backup/sync.rs @@ -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/ 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/ 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/ 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 { 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; diff --git a/src/main.rs b/src/main.rs index 626a190..f4b42eb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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();