mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
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:
1 parent
ca0039400e
commit
7646ecc397
5 files changed
+62
-24
No files matched your search
@@ -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:
|
sixty. The device folder ends up as a copy of the data directory:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
narl-diary/
|
narl-diary/ the device — Proton allows only folders in a device root
|
||||||
RESTORE.txt what this is, and how to put it back
|
data/ the diary's data directory, copied
|
||||||
diary.db a VACUUM INTO snapshot — consistent, no write-ahead log
|
RESTORE.txt what this is, and how to put it back
|
||||||
uploads/<uuid> every uploaded file, under the name the database knows
|
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
|
`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
|
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
|
once and never rewritten. Restoring is copying the contents of `data/`
|
||||||
directory — no tool in between, which is the point.
|
back into an empty data directory — no tool in between, which is the point.
|
||||||
|
|
||||||
| command | what it does |
|
| command | what it does |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
|
|||||||
@@ -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
@@ -161,10 +161,16 @@ impl Backup {
|
|||||||
Ok(Some(summary))
|
Ok(Some(summary))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The two folders the mirror writes into: the device's root, and the
|
/// The two folders the mirror writes into: `data/` on the device, and
|
||||||
/// `uploads/` folder inside it. Both are registered on first use and then
|
/// `data/uploads/` inside it. Both are registered on first use and then
|
||||||
/// remembered.
|
/// 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
|
/// Remembering them is the point. Resolving either by name means listing a
|
||||||
/// folder's children and decrypting every name in it, and Proton asks
|
/// folder's children and decrypting every name in it, and Proton asks
|
||||||
/// third-party clients not to traverse the tree repeatedly for things that
|
/// 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)> {
|
async fn device_folders(&self, client: &ProtonDriveClient) -> Result<(NodeUid, NodeUid)> {
|
||||||
let name = self.config.backup.device_name.clone();
|
let name = self.config.backup.device_name.clone();
|
||||||
let cached_name = self.state_get("device_name").await?;
|
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? {
|
let root = match self.cached_node(client, "device_root", fresh).await? {
|
||||||
Some(uid) => uid,
|
Some(uid) => uid,
|
||||||
@@ -185,17 +191,30 @@ impl Backup {
|
|||||||
self.state_set("device_uid", device.uid.as_str()).await?;
|
self.state_set("device_uid", device.uid.as_str()).await?;
|
||||||
self.state_set("device_root", &device.root_folder_uid.to_string())
|
self.state_set("device_root", &device.root_folder_uid.to_string())
|
||||||
.await?;
|
.await?;
|
||||||
// A new device root cannot hold the old uploads folder.
|
// Folders remembered inside the old root mean nothing in a new
|
||||||
self.state_set("device_uploads", "").await?;
|
// one, whichever of them still resolves.
|
||||||
|
fresh = true;
|
||||||
device.root_folder_uid
|
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? {
|
let uploads = match self.cached_node(client, "device_uploads", fresh).await? {
|
||||||
Some(uid) => uid,
|
Some(uid) => uid,
|
||||||
None => {
|
None => {
|
||||||
let uid = client
|
let uid = client
|
||||||
.create_folder_path(&root, "uploads")
|
.create_folder_path(&data, "uploads")
|
||||||
.await
|
.await
|
||||||
.context("could not create the uploads folder on the device")?;
|
.context("could not create the uploads folder on the device")?;
|
||||||
self.state_set("device_uploads", &uid.to_string()).await?;
|
self.state_set("device_uploads", &uid.to_string()).await?;
|
||||||
@@ -204,7 +223,7 @@ impl Backup {
|
|||||||
};
|
};
|
||||||
|
|
||||||
self.set(move |s| s.device = Some(name.clone()));
|
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.
|
/// A remembered node, if it is still remembered and still there.
|
||||||
|
|||||||
+13
-10
@@ -3,10 +3,11 @@
|
|||||||
//! The remote layout is deliberately the local one, not a rendering of it:
|
//! The remote layout is deliberately the local one, not a rendering of it:
|
||||||
//!
|
//!
|
||||||
//! ```text
|
//! ```text
|
||||||
//! narl-diary/ the device's sync root
|
//! narl-diary/ the device's sync root — folders only, per Proton
|
||||||
//! RESTORE.txt what this is and how to put it back
|
//! data/ the diary's data directory, copied
|
||||||
//! diary.db a consistent snapshot, one revision per change
|
//! RESTORE.txt what this is and how to put it back
|
||||||
//! uploads/<uuid> every uploaded file, under the name the database knows
|
//! 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
|
//! so restoring is copying two things into an empty data directory, with no
|
||||||
@@ -33,7 +34,9 @@ pub struct Summary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const RESTORE: &str = "\
|
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
|
diary.db the whole diary: entries, media metadata, share tokens
|
||||||
uploads/<uuid> every uploaded file; diary.db gives each one its real name
|
uploads/<uuid> every uploaded file; diary.db gives each one its real name
|
||||||
@@ -55,7 +58,7 @@ pub async fn run(
|
|||||||
db: &SqlitePool,
|
db: &SqlitePool,
|
||||||
config: &Config,
|
config: &Config,
|
||||||
client: &ProtonDriveClient,
|
client: &ProtonDriveClient,
|
||||||
root: &NodeUid,
|
data_folder: &NodeUid,
|
||||||
uploads_folder: &NodeUid,
|
uploads_folder: &NodeUid,
|
||||||
) -> Result<Summary> {
|
) -> Result<Summary> {
|
||||||
let mut mirror = Mirror {
|
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
|
// Written once, and only ever re-read by a human who has lost everything
|
||||||
// else — so it goes up before the data it explains.
|
// else — so it goes up before the data it explains.
|
||||||
mirror
|
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?;
|
.await?;
|
||||||
|
|
||||||
mirror.database(config, root).await?;
|
mirror.database(config, data_folder).await?;
|
||||||
mirror.media(config, uploads_folder).await?;
|
mirror.media(config, uploads_folder).await?;
|
||||||
|
|
||||||
if config.backup.prune {
|
if config.backup.prune {
|
||||||
@@ -94,7 +97,7 @@ impl Mirror<'_> {
|
|||||||
/// write-ahead log already folded in. Copying the file by hand while the
|
/// 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
|
/// diary is being written to would produce something that may or may not
|
||||||
/// open.
|
/// 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");
|
let snapshot = config.data_dir.join(".diary-backup.db");
|
||||||
// A snapshot left behind by a killed run would fail the next one:
|
// A snapshot left behind by a killed run would fail the next one:
|
||||||
// VACUUM INTO refuses to write over an existing file.
|
// VACUUM INTO refuses to write over an existing file.
|
||||||
@@ -107,7 +110,7 @@ impl Mirror<'_> {
|
|||||||
.context("could not snapshot the database")?;
|
.context("could not snapshot the database")?;
|
||||||
|
|
||||||
let result = self
|
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;
|
.await;
|
||||||
|
|
||||||
let _ = tokio::fs::remove_file(&snapshot).await;
|
let _ = tokio::fs::remove_file(&snapshot).await;
|
||||||
|
|||||||
+8
-1
@@ -36,7 +36,14 @@ async fn main() -> Result<()> {
|
|||||||
dotenvy::dotenv().ok();
|
dotenvy::dotenv().ok();
|
||||||
|
|
||||||
tracing_subscriber::registry()
|
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())
|
.with(tracing_subscriber::fmt::layer())
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user