Compare commits

..
Author SHA1 Message Date
nvrl 07d4ee42d9 Merge branch 'main' into fix/sync-unreadable-subfolder 2026-10-08 20:59:21 +02:00
nvrl 0ac5e2e4fe Merge pull request #31 from narrrl/feature/windows-migration
Feature/windows migration
2026-10-08 20:57:31 +02:00
nvrl 7eb6aa0640 fix(fuse): skip unreadable folders in sync, never evict local-only files
An unreadable subfolder, such as a container's root-owned cache, failed
every pass of its synced folder, so nothing else in the tree reached
Drive (#33, B196). The scan now leaves such a path out, together with an
entry that vanished mid-scan and a name that is not UTF-8, and the pass
drops everything under it from classification, the wipe guard and the
push pass's deletions. It is never read as deleted and never downloaded
into. An unreadable or absent root still fails the pass. The activity
feed names unreadable paths once each time the set changes.

Two related bugs on the way to online-only:

- The push pass read deletions from the raw baseline, so a synced path
  that later became ignored was trashed on Drive (B197).
- The switch evicted everything once the folder was idle, including
  ignored paths such as .git/, symlinks, unreadable folders and files
  changed after the pass, none of which are on Drive (B198). It now
  refuses and names the path.

Closes #33
2026-10-08 20:45:54 +02:00
Nils Pukropp c0d211238b chore(aur): update packages to 3.1.2 2026-10-08 13:55:27 +00:00
nvrl 07c9b675d3 chore(deps) updated sdk to 0.8.1 2026-10-08 15:25:44 +02:00
nvrl f82343b844 Merge pull request #30 from narrrl/fix/secureblue-captcha
Fix/secureblue captcha
2026-10-08 15:25:05 +02:00
nvrl 3e3dd7db41 feat(fuse,core,cli,gui): move another computer's backup to this one
A user switching from a Windows PC to Linux had two half answers:
adopting the old device makes this machine back up as it, and restoring
its folders keeps them in the old device's tree. Neither puts the data
under this machine's own device.

Device roots are separate shares on the main volume, so each top-level
folder of the other device can move under this device's root in one
server-side move. Nothing is uploaded again, and the other device stays
registered, without those folders.

- core: `Request::MigrateDevice { uid }` and
  `Db::sync_folder_set_share`
- fuse: `Core::migrate_device` moves every folder, suffixes a name
  already taken here with the other device's name, keeps going past a
  failed folder, and re-points sync folders that already tracked a
  moved folder at the new share
- cli: `pdfs devices migrate UID [--yes]` confirms, moves, then runs
  the ordinary restore picker
- gui: "Move to This Computer…" on another computer's row, followed by
  the restore picker
- i18n: translate the new strings in every catalog
- docs: CLI.md, USER_GUIDE.md, RECOVERY.md, ARCHITECTURE.md, changelog
2026-10-08 13:34:28 +02:00
Nils Pukropp d09e10dc7d chore(aur): update packages to 3.1.1 2026-10-08 09:49:25 +00:00
nvrl 8d0351124c chore(release): 3.1.2 2026-10-08 11:31:18 +02:00
nvrl a888e0bb28 fix(gui): run the CAPTCHA page in its own process (B195)
On secureblue, WebKit cannot start the sandbox for the verification page:
bubblewrap is kept from the journal socket, xdg-dbus-proxy fails, and
WebKit ends its process with g_error. The webview lived in pdfs-app, so
the whole app crashed after the sign-in (#29).

- gui: `pdfs-app --human-verification` shows the page in its own window,
  reads the URL on stdin and prints the token on stdout; its logs go to
  stderr
- gui: a child that dies or fails without a token ends the sign-in with a
  message that the page could not be opened, instead of "cancelled"
- i18n: translate the new message in every catalog
- docs: B195 in the bug ledger, the cause and a one-time workaround in
  TROUBLESHOOTING.md, the child process in ARCHITECTURE.md, changelog
2026-10-08 10:25:14 +02:00
nvrl c7fc85e943 fix(gui): create the state dir before the tray and app use it (B194)
On a fresh install only the daemon and the CLI created
~/.local/state/proton-drive-linux. The app starts the tray before the
first sign-in, so the tray failed to bind its single-instance socket and
exited, and the Takeout page could not remember staged archives (#29).

- gui: pdfs-tray and pdfs-app call AppDirs::ensure at start, which also
  makes the directories owner-only
- docs: B194 in the bug ledger, changelog entry
2026-10-08 10:25:04 +02:00
nvrl 5bfae0fee1 docs(changelog): add the 3.1.1 entry
3.1.1 shipped the client identity change without a changelog entry. Also
add the missing release links for 3.1.0 and 3.1.1.
2026-10-08 10:24:07 +02:00
nvrl edd62c5f7a chore(release): 3.1.1 2026-10-07 17:01:32 +02:00
nvrl 5c1530b4b9 fix(core,gui): send the release version as the client identity
Every release sent the fixed `external-drive-linux@0.1.0-alpha`. If Proton
ever retires that version, every release fails sign-in at once and no update
can fix it.

- core: APP_VERSION and USER_AGENT follow CARGO_PKG_VERSION
  (`external-drive-linux@<version>-stable`, `proton-drive-linux/<version>`)
- core: `app_version` and `user_agent` in config.json are optional overrides;
  the fixed values older releases stored there read as unset and leave the
  file on the next save, a hand-set value is kept
- gui: the About dialog shows the release, its debug info the identity
  actually sent
- docs: describe the new default and the migration
2026-10-07 17:01:32 +02:00
Nils Pukropp ce324510de chore(aur): update packages to 3.1.0 2026-10-07 10:53:15 +00:00
nvrl 274bfd0ca2 Merge pull request #27 from narrrl/feature/two-password-mode
Feature/two password mode
2026-10-07 12:47:05 +02:00
44 changed files with 3704 additions and 1871 deletions

No files matched your search

Generated
+22 -22
View File
@@ -166,7 +166,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
@@ -177,7 +177,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [ dependencies = [
"anstyle", "anstyle",
"once_cell_polyfill", "once_cell_polyfill",
"windows-sys 0.61.2", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
@@ -1254,7 +1254,7 @@ dependencies = [
"libc", "libc",
"option-ext", "option-ext",
"redox_users", "redox_users",
"windows-sys 0.61.2", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
@@ -1433,7 +1433,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.61.2", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
@@ -1896,7 +1896,7 @@ dependencies = [
"gobject-sys", "gobject-sys",
"libc", "libc",
"system-deps 9.0.0", "system-deps 9.0.0",
"windows-sys 0.61.2", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
@@ -3100,7 +3100,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
@@ -3474,7 +3474,7 @@ checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
[[package]] [[package]]
name = "pdfs-cli" name = "pdfs-cli"
version = "3.1.0" version = "3.1.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
@@ -3490,7 +3490,7 @@ dependencies = [
[[package]] [[package]]
name = "pdfs-core" name = "pdfs-core"
version = "3.1.0" version = "3.1.2"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"base64 0.23.1", "base64 0.23.1",
@@ -3516,7 +3516,7 @@ dependencies = [
[[package]] [[package]]
name = "pdfs-fuse" name = "pdfs-fuse"
version = "3.1.0" version = "3.1.2"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"base64 0.23.1", "base64 0.23.1",
@@ -3538,7 +3538,7 @@ dependencies = [
[[package]] [[package]]
name = "pdfs-gui" name = "pdfs-gui"
version = "3.1.0" version = "3.1.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-channel", "async-channel",
@@ -3778,9 +3778,9 @@ dependencies = [
[[package]] [[package]]
name = "proton-drive-rs" name = "proton-drive-rs"
version = "0.8.0" version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5bde475139afb5fc2649621e1d283e243f9df2bb412f08802cdf3f4ffaf97c44" checksum = "014165ed0f2d3847a4b8956ca94a82ba16c82f2d5c7fa139c3541c15150dba21"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"base64 0.23.1", "base64 0.23.1",
@@ -3805,9 +3805,9 @@ dependencies = [
[[package]] [[package]]
name = "proton-sdk" name = "proton-sdk"
version = "0.8.0" version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf32d0a03f7d13bd9bdcc8c9503e35e9df9318ca9f2a100dcf06cf50cb7ac8c" checksum = "9e16bf5592b7947708dd754defeaee7a58e9fa35988a6ea345b5b9a93765fd81"
dependencies = [ dependencies = [
"aes-gcm 0.11.1", "aes-gcm 0.11.1",
"async-trait", "async-trait",
@@ -3926,7 +3926,7 @@ dependencies = [
"once_cell", "once_cell",
"socket2", "socket2",
"tracing", "tracing",
"windows-sys 0.61.2", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
@@ -4374,7 +4374,7 @@ dependencies = [
"errno", "errno",
"libc", "libc",
"linux-raw-sys", "linux-raw-sys",
"windows-sys 0.61.2", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
@@ -4431,7 +4431,7 @@ dependencies = [
"security-framework", "security-framework",
"security-framework-sys", "security-framework-sys",
"webpki-root-certs", "webpki-root-certs",
"windows-sys 0.61.2", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
@@ -4764,7 +4764,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.61.2", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
@@ -4932,10 +4932,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [ dependencies = [
"fastrand", "fastrand",
"getrandom 0.4.3", "getrandom 0.3.4",
"once_cell", "once_cell",
"rustix", "rustix",
"windows-sys 0.61.2", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
@@ -5266,7 +5266,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [ dependencies = [
"memoffset", "memoffset",
"tempfile", "tempfile",
"windows-sys 0.61.2", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
@@ -5590,7 +5590,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
+3 -3
View File
@@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"] members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
[workspace.package] [workspace.package]
version = "3.1.0" version = "3.1.2"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
authors = ["Nils Pukropp <contact@narl.io>"] authors = ["Nils Pukropp <contact@narl.io>"]
@@ -15,8 +15,8 @@ keywords = ["proton", "drive", "fuse", "sync", "linux"]
categories = ["filesystem", "command-line-utilities"] categories = ["filesystem", "command-line-utilities"]
[workspace.dependencies] [workspace.dependencies]
proton-sdk = { version = "0.8.0" } proton-sdk = { version = "0.8.1" }
proton-drive-rs = { version = "0.8.0" } proton-drive-rs = { version = "0.8.1" }
# Internal crates. # Internal crates.
pdfs-core = { path = "crates/pdfs-core" } pdfs-core = { path = "crates/pdfs-core" }
+27
View File
@@ -628,6 +628,15 @@ enum DeviceCmd {
#[arg(long, conflicts_with = "uid")] #[arg(long, conflicts_with = "uid")]
clear: bool, clear: bool,
}, },
/// Move another computer's folders into this computer's backup and sync
/// them here. The other computer stays registered, without those folders.
Migrate {
/// Device uid (from `devices list`).
uid: String,
/// Skip the confirmation and accept every proposed local path.
#[arg(long)]
yes: bool,
},
} }
#[derive(Subcommand)] #[derive(Subcommand)]
@@ -967,6 +976,24 @@ fn cmd_devices(action: DeviceCmd) -> Result<()> {
let uid = if clear { None } else { uid }; let uid = if clear { None } else { uid };
ok_or_bail(control_request(CtlRequest::AdoptDevice { uid })?)? ok_or_bail(control_request(CtlRequest::AdoptDevice { uid })?)?
} }
DeviceCmd::Migrate { uid, yes } => {
if !yes {
println!(
"Every folder that device backs up moves into this computer's backup.\n\
The device stays registered, without those folders. Stop Proton Drive\n\
on that computer first, or it sees its folders disappear."
);
confirm("Move them here?")?;
}
let response = control_request(CtlRequest::MigrateDevice { uid })?;
// A script gets the one reply and runs `sync restore` itself.
if emit_json(&response)? {
return Ok(());
}
ok_or_bail(response)?;
// The folders are this device's now, so the plain restore offers them.
cmd_sync_restore(yes, None)?
}
} }
Ok(()) Ok(())
} }
+4 -1
View File
@@ -109,7 +109,10 @@ fn client_config() -> ProtonClientConfiguration {
let (app_version, user_agent) = match AppDirs::new() { let (app_version, user_agent) = match AppDirs::new() {
Ok(dirs) => { Ok(dirs) => {
let config = dirs.load_config(); let config = dirs.load_config();
(config.app_version, config.user_agent) (
config.resolved_app_version().to_string(),
config.resolved_user_agent().to_string(),
)
} }
Err(_) => (APP_VERSION.to_string(), USER_AGENT.to_string()), Err(_) => (APP_VERSION.to_string(), USER_AGENT.to_string()),
}; };
+106 -34
View File
@@ -3,7 +3,7 @@
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use directories::ProjectDirs; use directories::ProjectDirs;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Deserializer, Serialize};
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use crate::menu::PromptConfig; use crate::menu::PromptConfig;
@@ -12,8 +12,19 @@ use crate::syncignore::DEFAULT_IGNORE_PATTERNS;
/// Proton requires `external-drive-{name}@{semver}-{channel}` (channel ∈ /// Proton requires `external-drive-{name}@{semver}-{channel}` (channel ∈
/// stable/beta/alpha); a malformed value trips the 422 anti-abuse path. /// stable/beta/alpha); a malformed value trips the 422 anti-abuse path.
pub const APP_VERSION: &str = "external-drive-linux@0.1.0-alpha"; /// Follows the release so Proton can tell releases apart and retire old ones
pub const USER_AGENT: &str = "proton-drive-linux/0.1.0"; /// without retiring every one.
pub const APP_VERSION: &str = concat!(
"external-drive-linux@",
env!("CARGO_PKG_VERSION"),
"-stable"
);
pub const USER_AGENT: &str = concat!("proton-drive-linux/", env!("CARGO_PKG_VERSION"));
/// The fixed identity every release up to 3.1.0 wrote into `config.json`. Read
/// back as "unset" so those configs follow the release instead of pinning it.
const LEGACY_APP_VERSION: &str = "external-drive-linux@0.1.0-alpha";
const LEGACY_USER_AGENT: &str = "proton-drive-linux/0.1.0";
/// Keyring service name; one entry per credential kind keyed by username. /// Keyring service name; one entry per credential kind keyed by username.
pub const KEYRING_SERVICE: &str = "proton-drive-linux"; pub const KEYRING_SERVICE: &str = "proton-drive-linux";
@@ -76,10 +87,24 @@ fn resolve_sweep_mode(env: Option<&str>, stored: Option<SweepMode>) -> SweepMode
} }
/// Configuration structure allowing the user to customize client identification headers. /// Configuration structure allowing the user to customize client identification headers.
#[derive(Serialize, Deserialize, Debug, Clone)] #[derive(Serialize, Deserialize, Debug, Clone, Default)]
pub struct AppConfig { pub struct AppConfig {
pub app_version: String, /// Override for [`APP_VERSION`]. `None` means "use the release's own";
pub user_agent: String, /// the old fixed default reads as `None`, so it leaves the file on the
/// next save.
#[serde(
default,
deserialize_with = "identity_override",
skip_serializing_if = "Option::is_none"
)]
pub app_version: Option<String>,
/// Override for [`USER_AGENT`], handled like `app_version`.
#[serde(
default,
deserialize_with = "identity_override",
skip_serializing_if = "Option::is_none"
)]
pub user_agent: Option<String>,
/// Soft cap on the on-disk content cache, in bytes (`0` = unlimited). /// Soft cap on the on-disk content cache, in bytes (`0` = unlimited).
/// `None` means "use [`DEFAULT_CACHE_BUDGET_BYTES`]"; the Settings page /// `None` means "use [`DEFAULT_CACHE_BUDGET_BYTES`]"; the Settings page
/// writes an explicit value here. Defaulted for configs predating the field. /// writes an explicit value here. Defaulted for configs predating the field.
@@ -216,31 +241,28 @@ impl FileSort {
} }
} }
impl Default for AppConfig { /// Reads an `app_version` or `user_agent` override, dropping the fixed
fn default() -> Self { /// identity older releases stored as the default.
Self { fn identity_override<'de, D: Deserializer<'de>>(
app_version: APP_VERSION.to_string(), deserializer: D,
user_agent: USER_AGENT.to_string(), ) -> std::result::Result<Option<String>, D::Error> {
cache_budget: None, let value = Option::<String>::deserialize(deserializer)?;
upload_limit: None, Ok(value.filter(|v| v != LEGACY_APP_VERSION && v != LEGACY_USER_AGENT))
download_limit: None,
mountpoint: None,
ignore_patterns: None,
device_uid: None,
conflict_sweep: None,
open_with: None,
prompt: None,
proton_theme: None,
files_view: FilesView::default(),
tray_hidden: false,
language: None,
online_map: false,
local_first: None,
}
}
} }
impl AppConfig { impl AppConfig {
/// The `x-pm-appversion` sent to Proton: the user's override, or
/// [`APP_VERSION`].
pub fn resolved_app_version(&self) -> &str {
self.app_version.as_deref().unwrap_or(APP_VERSION)
}
/// The `User-Agent` sent to Proton: the user's override, or
/// [`USER_AGENT`].
pub fn resolved_user_agent(&self) -> &str {
self.user_agent.as_deref().unwrap_or(USER_AGENT)
}
/// The effective cache budget in bytes: the user's explicit choice, or /// The effective cache budget in bytes: the user's explicit choice, or
/// [`DEFAULT_CACHE_BUDGET_BYTES`] when unset. /// [`DEFAULT_CACHE_BUDGET_BYTES`] when unset.
pub fn resolved_cache_budget(&self) -> u64 { pub fn resolved_cache_budget(&self) -> u64 {
@@ -571,8 +593,8 @@ mod tests {
#[test] #[test]
fn test_app_config_serialization() { fn test_app_config_serialization() {
let config = AppConfig { let config = AppConfig {
app_version: "external-drive-test-client@1.0.0".to_string(), app_version: Some("external-drive-test-client@1.0.0".to_string()),
user_agent: "test-agent/1.0".to_string(), user_agent: Some("test-agent/1.0".to_string()),
cache_budget: Some(1234), cache_budget: Some(1234),
upload_limit: Some(500_000), upload_limit: Some(500_000),
download_limit: None, download_limit: None,
@@ -596,8 +618,11 @@ mod tests {
}; };
let json = serde_json::to_string(&config).unwrap(); let json = serde_json::to_string(&config).unwrap();
let decoded: AppConfig = serde_json::from_str(&json).unwrap(); let decoded: AppConfig = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.app_version, "external-drive-test-client@1.0.0"); assert_eq!(
assert_eq!(decoded.user_agent, "test-agent/1.0"); decoded.resolved_app_version(),
"external-drive-test-client@1.0.0"
);
assert_eq!(decoded.resolved_user_agent(), "test-agent/1.0");
assert_eq!(decoded.proton_theme, Some(true)); assert_eq!(decoded.proton_theme, Some(true));
assert!(decoded.tray_hidden); assert!(decoded.tray_hidden);
assert!(decoded.online_map); assert!(decoded.online_map);
@@ -628,8 +653,55 @@ mod tests {
#[test] #[test]
fn test_default_app_config() { fn test_default_app_config() {
let default_config = AppConfig::default(); let default_config = AppConfig::default();
assert_eq!(default_config.app_version, APP_VERSION); assert_eq!(default_config.resolved_app_version(), APP_VERSION);
assert_eq!(default_config.user_agent, USER_AGENT); assert_eq!(default_config.resolved_user_agent(), USER_AGENT);
}
#[test]
fn the_client_identity_follows_the_release() {
let version = env!("CARGO_PKG_VERSION");
assert_eq!(
APP_VERSION,
format!("external-drive-linux@{version}-stable")
);
assert_eq!(USER_AGENT, format!("proton-drive-linux/{version}"));
}
#[test]
fn an_old_config_with_the_fixed_identity_follows_the_release() {
// Every config written up to 3.1.0 holds these two values, so they must
// not pin the identity once Proton retires 0.1.0.
let json = format!(
r#"{{"app_version":"{LEGACY_APP_VERSION}","user_agent":"{LEGACY_USER_AGENT}","tray_hidden":true}}"#
);
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.resolved_app_version(), APP_VERSION);
assert_eq!(decoded.resolved_user_agent(), USER_AGENT);
assert!(decoded.tray_hidden, "the rest of the config survives");
let saved = serde_json::to_string(&decoded).unwrap();
assert!(!saved.contains("app_version"), "{saved}");
assert!(!saved.contains("user_agent"), "{saved}");
}
#[test]
fn a_hand_set_client_identity_is_kept() {
let json = r#"{"app_version":"external-drive-linux@9.9.9-beta","user_agent":"custom/1"}"#;
let decoded: AppConfig = serde_json::from_str(json).unwrap();
assert_eq!(
decoded.resolved_app_version(),
"external-drive-linux@9.9.9-beta"
);
assert_eq!(decoded.resolved_user_agent(), "custom/1");
let saved = serde_json::to_string(&decoded).unwrap();
assert!(saved.contains("external-drive-linux@9.9.9-beta"), "{saved}");
}
#[test]
fn a_config_without_a_client_identity_loads() {
let decoded: AppConfig = serde_json::from_str("{}").unwrap();
assert_eq!(decoded.resolved_app_version(), APP_VERSION);
assert_eq!(decoded.resolved_user_agent(), USER_AGENT);
} }
#[test] #[test]
+10
View File
@@ -446,6 +446,13 @@ pub enum Request {
/// of registering a duplicate. `uid: None` clears the pin. /// of registering a duplicate. `uid: None` clears the pin.
/// Replies with [`Response::Ok`]. /// Replies with [`Response::Ok`].
AdoptDevice { uid: Option<String> }, AdoptDevice { uid: Option<String> },
/// Move every folder another device backs up into this machine's own
/// device, as server-side moves on the main volume. The other device stays
/// registered, without those folders. Sync folders here that already track
/// one of them follow it to its new share. Nothing is downloaded: the moved
/// folders are then offered by [`Request::ListRestorableFolders`].
/// Replies with [`Response::Ok`].
MigrateDevice { uid: String },
// ---- device folder sync ---------------------------------- // ---- device folder sync ----------------------------------
/// Add a local folder to this machine's device, uploading its tree and /// Add a local folder to this machine's device, uploading its tree and
@@ -3082,6 +3089,9 @@ mod tests {
uid: Some("dev-1".into()), uid: Some("dev-1".into()),
}, },
Request::AdoptDevice { uid: None }, Request::AdoptDevice { uid: None },
Request::MigrateDevice {
uid: "dev-2".into(),
},
Request::ListRestorableFolders, Request::ListRestorableFolders,
Request::RestoreSyncFolders { Request::RestoreSyncFolders {
items: vec![RestoreItem { items: vec![RestoreItem {
+11
View File
@@ -139,6 +139,17 @@ impl Db {
Ok(()) Ok(())
} }
/// Re-point a synced folder at the share that now holds its remote folder,
/// after the folder moved to another device without changing its uid.
pub fn sync_folder_set_share(&self, id: i64, share_id: &str) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"UPDATE sync_folder SET remote_share_id = ?2 WHERE id = ?1",
params![id, share_id],
)?;
Ok(())
}
/// Queue (or, with `None`, withdraw) a mode the folder should move to once it /// Queue (or, with `None`, withdraw) a mode the folder should move to once it
/// is safe to switch. /// is safe to switch.
pub fn sync_folder_set_pending_mode(&self, id: i64, mode: Option<&str>) -> Result<()> { pub fn sync_folder_set_pending_mode(&self, id: i64, mode: Option<&str>) -> Result<()> {
+22
View File
@@ -4940,6 +4940,28 @@ fn pending_mode_is_queued_until_the_mode_is_reached() {
assert_eq!(folder.pending_mode, None); assert_eq!(folder.pending_mode, None);
} }
#[test]
fn moved_sync_folder_points_at_its_new_share() {
let db = Db::open_in_memory().unwrap();
let id = db
.sync_folder_add("/tmp/pdfs-moved-share", "vol~folder", "windows-share")
.unwrap();
let other = db
.sync_folder_add("/tmp/pdfs-unmoved-share", "vol~other", "windows-share")
.unwrap();
db.sync_folder_set_share(id, "linux-share").unwrap();
let folder = db.sync_folder_get(id).unwrap().unwrap();
assert_eq!(folder.remote_share_id, "linux-share");
// A move keeps the uid, so nothing but the share changes.
assert_eq!(folder.remote_uid, "vol~folder");
assert_eq!(
db.sync_folder_get(other).unwrap().unwrap().remote_share_id,
"windows-share"
);
}
#[test] #[test]
fn mode_commit_clears_only_the_intent_it_satisfies() { fn mode_commit_clears_only_the_intent_it_satisfies() {
let db = Db::open_in_memory().unwrap(); let db = Db::open_in_memory().unwrap();
+5 -1
View File
@@ -369,7 +369,7 @@ fn touched_topics(request: &CtlRequest) -> &'static [Topic] {
RefreshScope::Photos { .. } => &[], RefreshScope::Photos { .. } => &[],
}, },
R::RenameDevice { .. } | R::DeleteDevice { .. } => &[Topic::Devices], R::RenameDevice { .. } | R::DeleteDevice { .. } => &[Topic::Devices],
R::AdoptDevice { .. } => &[Topic::Devices, Topic::Locations], R::AdoptDevice { .. } | R::MigrateDevice { .. } => &[Topic::Devices, Topic::Locations],
R::AddSyncFolder { .. } R::AddSyncFolder { .. }
| R::RemoveSyncFolder { .. } | R::RemoveSyncFolder { .. }
| R::SetSyncFolderMode { .. } | R::SetSyncFolderMode { .. }
@@ -1297,6 +1297,10 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
Ok(message) => CtlResponse::Ok { message }, Ok(message) => CtlResponse::Ok { message },
Err(e) => CtlResponse::error(e), Err(e) => CtlResponse::error(e),
}, },
Ok(CtlRequest::MigrateDevice { uid }) => match core.migrate_device(&uid) {
Ok(message) => CtlResponse::Ok { message },
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::AddSyncFolder { local_path }) => { Ok(CtlRequest::AddSyncFolder { local_path }) => {
// Registering the device and uploading a folder tree far outlasts the // Registering the device and uploading a folder tree far outlasts the
// socket read timeout, so ack immediately and work on a background // socket read timeout, so ack immediately and work on a background
+176 -9
View File
@@ -19,6 +19,7 @@ use pdfs_core::control::{
}; };
use pdfs_core::db::{StoredDevice, StoredSyncFolder}; use pdfs_core::db::{StoredDevice, StoredSyncFolder};
use pdfs_core::mounts::MountMode; use pdfs_core::mounts::MountMode;
use pdfs_core::profile::PROFILE_DIR_NAME;
use pdfs_core::{CoreError, CoreResult}; use pdfs_core::{CoreError, CoreResult};
use proton_drive_rs::proton_sdk::ids::{DeviceUid, NodeUid}; use proton_drive_rs::proton_sdk::ids::{DeviceUid, NodeUid};
use proton_drive_rs::{DeviceType, Node}; use proton_drive_rs::{DeviceType, Node};
@@ -114,6 +115,22 @@ pub(crate) fn set_adopted_device_uid(uid: Option<&str>) -> CoreResult<()> {
.map_err(|e| CoreError::internal(format!("write config: {e}"))) .map_err(|e| CoreError::internal(format!("write config: {e}")))
} }
/// The name a folder moved in from `device` takes when `name` is already
/// taken under this device's root, or `None` when it can keep its own.
fn migrated_name(name: &str, device: &str, taken: &[String]) -> Option<String> {
let free = |candidate: &str| !taken.iter().any(|t| t == candidate);
if free(name) {
return None;
}
let base = format!("{name} ({device})");
if free(&base) {
return Some(base);
}
(2..)
.map(|n| format!("{base} {n}"))
.find(|candidate| free(candidate))
}
impl Core { impl Core {
// ---- devices ---------------------------------------------------------- // ---- devices ----------------------------------------------------------
@@ -260,6 +277,107 @@ impl Core {
}) })
} }
/// Move every folder another device backs up into this machine's own
/// device, so this machine carries on backing them up as itself. The other
/// device stays registered, without those folders.
///
/// Device roots all live on the main volume, so each folder is one
/// server-side move: nothing is downloaded or uploaded, and no quota is
/// used twice. A folder whose name is taken here keeps its content under
/// "<name> (<device>)". A failed folder does not stop the others, and a
/// second run picks up whatever is still left on the other device.
///
/// Nothing is synced to this disk here. The moved folders are now this
/// device's, so the ordinary restore offers them. Only folders that were
/// already synced here from the other device are touched: their uid
/// survives the move but their share does not.
pub(crate) fn migrate_device(&self, uid: &str) -> CoreResult<String> {
let source = self.restore_source(Some(uid))?;
let target = self.ensure_device()?;
if source.uid == target.uid {
return Err(CoreError::invalid(format!(
"{} is this computer's own device",
source.name
)));
}
let bad_root = |d: &StoredDevice| {
parse_uid(&d.root_uid)
.ok_or_else(|| CoreError::internal(format!("bad device root uid: {}", d.root_uid)))
};
let (source_root, target_root) = (bad_root(&source)?, bad_root(&target)?);
if source_root.volume_id != target_root.volume_id {
return Err(CoreError::invalid(
"the two devices are on different Proton Drive volumes; copy it instead",
));
}
let mut taken: Vec<String> = self
.device_root_nodes(&target_root)?
.into_iter()
.filter(|n| !n.trashed)
.map(|n| n.name)
.collect();
let synced = self
.db
.sync_folder_list()
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
let mut moved = 0usize;
let mut renamed: Vec<String> = Vec::new();
let mut left: Vec<String> = Vec::new();
let mut failed: Vec<String> = Vec::new();
for node in self.device_root_nodes(&source_root)? {
if node.trashed || node.name == PROFILE_DIR_NAME {
continue;
}
// A device root only ever holds folders (B68); anything else was
// put there by hand and is left for the user to deal with.
if !node.is_folder() {
left.push(node.name);
continue;
}
let new_name = migrated_name(&node.name, &source.name, &taken);
if let Err(e) = self.move_rename_remote(&node.uid, &target_root, new_name.as_deref()) {
warn!(name = %node.name, error = %e, "migrate: could not move folder");
failed.push(format!("{}: {e}", node.name));
continue;
}
let landed = new_name.clone().unwrap_or_else(|| node.name.clone());
if new_name.is_some() {
renamed.push(format!("{} -> {landed}", node.name));
}
taken.push(landed);
moved += 1;
let uid = node.uid.to_string();
for folder in synced.iter().filter(|f| f.remote_uid == uid) {
if let Err(e) = self.db.sync_folder_set_share(folder.id, &target.share_id) {
warn!(id = folder.id, error = ?e, "migrate: could not re-point sync folder");
}
self.sync_now(Some(folder.id));
}
}
self.invalidate_children_of(&source_root);
self.invalidate_children_of(&target_root);
let mut message = format!("moved {moved} folder(s) from {}", source.name);
if !renamed.is_empty() {
message.push_str(&format!("; renamed: {}", renamed.join(", ")));
}
if !left.is_empty() {
message.push_str(&format!("; left behind: {}", left.join(", ")));
}
if !failed.is_empty() {
message.push_str(&format!("; failed: {}", failed.join(", ")));
}
info!(from = %source.name, to = %target.name, moved, failed = failed.len(),
"device migrated");
if moved == 0 && !failed.is_empty() {
return Err(CoreError::conflict(message));
}
Ok(message)
}
// ---- device folder sync ------------------------- // ---- device folder sync -------------------------
/// Auto-register (or recover) this machine as a Proton Drive Device, caching /// Auto-register (or recover) this machine as a Proton Drive Device, caching
@@ -360,21 +478,25 @@ impl Core {
root_uid: &NodeUid, root_uid: &NodeUid,
name: &str, name: &str,
) -> CoreResult<Option<NodeUid>> { ) -> CoreResult<Option<NodeUid>> {
Ok(self
.device_root_nodes(root_uid)?
.into_iter()
.find(|n| n.is_folder() && !n.trashed && n.name == name)
.map(|n| n.uid))
}
/// Everything directly under a device root, trashed nodes included.
fn device_root_nodes(&self, root_uid: &NodeUid) -> CoreResult<Vec<Node>> {
let uids = self let uids = self
.rt .rt
.block_on(self.drive.enumerate_folder_children_node_uids(root_uid)) .block_on(self.drive.enumerate_folder_children_node_uids(root_uid))
.map_err(|e| CoreError::from_api(&e, "list device root"))?; .map_err(|e| CoreError::from_api(&e, "list device root"))?;
if uids.is_empty() { if uids.is_empty() {
return Ok(None); return Ok(Vec::new());
} }
let nodes = self self.rt
.rt
.block_on(self.drive.enumerate_nodes(&uids)) .block_on(self.drive.enumerate_nodes(&uids))
.map_err(|e| CoreError::from_api(&e, "resolve device root children"))?; .map_err(|e| CoreError::from_api(&e, "resolve device root children"))
Ok(nodes
.into_iter()
.find(|n| n.is_folder() && !n.trashed && n.name == name)
.map(|n| n.uid))
} }
/// Add a local folder to this device's sync set: register the device if /// Add a local folder to this device's sync set: register the device if
@@ -842,6 +964,23 @@ impl Core {
if folder.state != "idle" { if folder.state != "idle" {
return Err(SwitchBlocked::NotNow); return Err(SwitchBlocked::NotNow);
} }
// `idle` only says the last pass applied everything it tried.
// It never tries an ignored path, a symlink or a folder it
// cannot read, it defers a file open for writing, and a file can
// change after it ends. The eviction below deletes all of those
// for good, so it waits until nothing here is missing from Drive
// (B198).
let baseline = self
.db
.sync_entries(id)
.map_err(|e| SwitchBlocked::Failed(format!("db: {e:?}")))?;
if let Some(reason) =
sync::mirror_tree_unsynced(&local, &baseline, &self.ignore_rules(&local))
{
return Err(SwitchBlocked::Failed(format!(
"going online-only would delete what is only on this computer: {reason}"
)));
}
let root_uid = parse_uid(&folder.remote_uid).ok_or_else(|| { let root_uid = parse_uid(&folder.remote_uid).ok_or_else(|| {
SwitchBlocked::Failed(format!("bad remote uid: {}", folder.remote_uid)) SwitchBlocked::Failed(format!("bad remote uid: {}", folder.remote_uid))
})?; })?;
@@ -1107,10 +1246,38 @@ mod tests {
use pdfs_core::db::StoredSyncFolder; use pdfs_core::db::StoredSyncFolder;
use super::{ use super::{
ApplicableMountMode, commit_after_teardown, pending_intent_matches, ApplicableMountMode, commit_after_teardown, migrated_name, pending_intent_matches,
restore_snapshot_matches, select_mode_transition, restore_snapshot_matches, select_mode_transition,
}; };
#[test]
fn migrated_folder_keeps_its_name_when_it_is_free() {
let taken = vec!["Pictures".to_string()];
assert_eq!(migrated_name("Documents", "DESKTOP-1", &taken), None);
}
#[test]
fn migrated_folder_is_named_after_its_device_on_a_clash() {
let taken = vec!["Documents".to_string()];
assert_eq!(
migrated_name("Documents", "DESKTOP-1", &taken).as_deref(),
Some("Documents (DESKTOP-1)")
);
}
#[test]
fn migrated_folder_is_numbered_when_the_device_name_is_taken_too() {
let taken = vec![
"Documents".to_string(),
"Documents (DESKTOP-1)".to_string(),
"Documents (DESKTOP-1) 2".to_string(),
];
assert_eq!(
migrated_name("Documents", "DESKTOP-1", &taken).as_deref(),
Some("Documents (DESKTOP-1) 3")
);
}
#[test] #[test]
fn unknown_mode_cannot_select_a_destructive_transition() { fn unknown_mode_cannot_select_a_destructive_transition() {
let mut unmounts = 0; let mut unmounts = 0;
+3
View File
@@ -744,6 +744,9 @@ struct Core {
/// uploading that same tree — the engine would upload files as they vanish /// uploading that same tree — the engine would upload files as they vanish
/// and then walk the FUSE mount as if it were local. /// and then walk the FUSE mount as if it were local.
sync_locks: Arc<Mutex<HashMap<i64, Arc<Mutex<()>>>>>, sync_locks: Arc<Mutex<HashMap<i64, Arc<Mutex<()>>>>>,
/// Per-sync-folder paths the last pass could not read, so the activity feed
/// says so when the set changes rather than on every poll (B196).
sync_unscanned: Arc<Mutex<HashMap<i64, Vec<String>>>>,
/// Nodes this daemon changed on the remote itself, and how many echoes of /// Nodes this daemon changed on the remote itself, and how many echoes of
/// those changes the event feed still owes us. /// those changes the event feed still owes us.
/// ///
+1
View File
@@ -475,6 +475,7 @@ pub(crate) fn mount_with(
sync_tx, sync_tx,
mounts: Arc::new(Mutex::new(SecondaryMountRegistry::default())), mounts: Arc::new(Mutex::new(SecondaryMountRegistry::default())),
sync_locks: Arc::new(Mutex::new(HashMap::new())), sync_locks: Arc::new(Mutex::new(HashMap::new())),
sync_unscanned: Arc::new(Mutex::new(HashMap::new())),
states: Arc::new(StateRegistry::default()), states: Arc::new(StateRegistry::default()),
}; };
// Before anything can queue work against it: the drain thread below reaches // Before anything can queue work against it: the drain thread below reaches
+542 -99
View File
@@ -59,8 +59,9 @@ pub(crate) use planner::base_name;
#[cfg(test)] #[cfg(test)]
use planner::conflict_path; use planner::conflict_path;
use planner::{ use planner::{
FilePlan, classification_order, conflict_path_with_suffix, filter_baseline, guard_local_wipe, FilePlan, Unscanned, classification_order, conflict_path_with_suffix, filter_baseline,
join_rel, parent_rel, plan_file, rel_to_path, remote_sig, unchanged_remote_size, guard_local_wipe, join_rel, local_deletions, parent_rel, plan_file, rel_to_path, remote_sig,
tracked_baseline, unchanged_remote_size,
}; };
/// What a [`Pending`] op did, so the reconcile can update shared state on the /// What a [`Pending`] op did, so the reconcile can update shared state on the
/// engine thread (never inside a task). /// engine thread (never inside a task).
@@ -187,6 +188,7 @@ impl Core {
"idle" "idle"
}; };
let _ = self.db.sync_folder_set_state(folder.id, state, now_secs()); let _ = self.db.sync_folder_set_state(folder.id, state, now_secs());
self.report_unscanned(folder.id, name, &outcome.unscanned);
// Summarise a pass that actually moved something. A poll over an // Summarise a pass that actually moved something. A poll over an
// unchanged folder does nothing and says nothing — otherwise the // unchanged folder does nothing and says nothing — otherwise the
// feed would fill with "synced, 0 changes" every two minutes. // feed would fill with "synced, 0 changes" every two minutes.
@@ -243,6 +245,37 @@ impl Core {
} }
} }
/// Tell the activity feed what a pass over this folder had to leave out
/// because it cannot be read, once each time that changes. Every other path
/// in the folder still synced, so this does not make the folder's state an
/// error; but a folder the user has no permission for is never going to
/// reach Drive, and they should hear that once rather than not at all
/// (B196).
fn report_unscanned(&self, folder_id: i64, name: &str, unscanned: &[String]) {
let previous = {
let mut last = self.sync_unscanned.lock();
if unscanned.is_empty() {
last.remove(&folder_id)
} else {
last.insert(folder_id, unscanned.to_vec())
}
};
if unscanned.is_empty() || previous.as_deref() == Some(unscanned) {
return;
}
const SHOWN: usize = 3;
let mut detail = unscanned[..unscanned.len().min(SHOWN)].join(", ");
if unscanned.len() > SHOWN {
detail.push_str(&format!(" and {} more", unscanned.len() - SHOWN));
}
self.log_activity(
ActivityKind::Sync,
format!("{} item(s) in {name} cannot be synced", unscanned.len()),
detail,
false,
);
}
/// Compile the ignore rules for a synced folder: the global patterns from /// Compile the ignore rules for a synced folder: the global patterns from
/// the config, plus any `.pdfsignore` at `local_root`. /// the config, plus any `.pdfsignore` at `local_root`.
/// ///
@@ -250,7 +283,7 @@ impl Core {
/// than to "ignore nothing" — the defaults exclude build and VCS trees, and /// than to "ignore nothing" — the defaults exclude build and VCS trees, and
/// silently uploading those because a config read failed is the outcome this /// silently uploading those because a config read failed is the outcome this
/// feature exists to prevent. /// feature exists to prevent.
fn ignore_rules(&self, local_root: &Path) -> IgnoreRules { pub(crate) fn ignore_rules(&self, local_root: &Path) -> IgnoreRules {
let globals = match pdfs_core::config::AppDirs::new() { let globals = match pdfs_core::config::AppDirs::new() {
Ok(dirs) => dirs.load_config().resolved_ignore_patterns(), Ok(dirs) => dirs.load_config().resolved_ignore_patterns(),
Err(e) => { Err(e) => {
@@ -313,15 +346,23 @@ impl Core {
self.progress_scan_total(folder_id, baseline.len()); self.progress_scan_total(folder_id, baseline.len());
let mut local: HashMap<String, LocalItem> = HashMap::new(); let mut local: HashMap<String, LocalItem> = HashMap::new();
let mut unscanned = Unscanned::default();
let writing = open_for_write_set(local_root); let writing = open_for_write_set(local_root);
self.walk_local( self.walk_local(
folder_id, local_root, local_root, rules, &writing, &mut local, folder_id,
local_root,
rules,
&writing,
&mut local,
&mut unscanned,
)?; )?;
// The guard compares against the paths this pass could still see: an // The guard compares against the paths this pass could still see: an
// ignored path is absent from `local` by rule, not by loss, and counting // ignored or unreadable path is absent from `local` by rule or by
// those as missing would trip the guard on every pass — wedging the // failure, not by loss, and counting those as missing would trip the
// folder's sync permanently — the moment a rule covers the whole tree. // guard on every pass — wedging the folder's sync permanently — the
guard_local_wipe(&filter_baseline(&baseline, rules), &local)?; // moment a rule covers the whole tree.
let tracked = tracked_baseline(&baseline, rules, &unscanned);
guard_local_wipe(&tracked, &local)?;
// The remote folder uids come from the baseline instead of a walk: every // The remote folder uids come from the baseline instead of a walk: every
// directory this device has synced recorded its uid there when it was // directory this device has synced recorded its uid there when it was
@@ -391,6 +432,7 @@ impl Core {
let mut outcome = Outcome { let mut outcome = Outcome {
deferred: local.values().filter(|item| item.open_for_write).count(), deferred: local.values().filter(|item| item.open_for_write).count(),
unscanned: unscanned.report(),
..Default::default() ..Default::default()
}; };
let mut order: Vec<&String> = local.keys().collect(); let mut order: Vec<&String> = local.keys().collect();
@@ -515,13 +557,8 @@ impl Core {
// showing the folder's remote contents — otherwise everything they deleted // showing the folder's remote contents — otherwise everything they deleted
// comes back the moment the switch lands. Shallowest first, skipping anything // comes back the moment the switch lands. Shallowest first, skipping anything
// under a folder already trashed (its children went with it). // under a folder already trashed (its children went with it).
let mut missing: Vec<&String> = baseline
.keys()
.filter(|rel| !local.contains_key(*rel))
.collect();
missing.sort_by_key(|p| p.matches('/').count());
let mut trashed: Vec<String> = Vec::new(); let mut trashed: Vec<String> = Vec::new();
for rel in missing { for rel in &local_deletions(&tracked, &local) {
if trashed if trashed
.iter() .iter()
.any(|dir| rel.starts_with(&format!("{dir}/"))) .any(|dir| rel.starts_with(&format!("{dir}/")))
@@ -586,12 +623,18 @@ impl Core {
self.progress_scan_total(folder_id, baseline.len() * 2); self.progress_scan_total(folder_id, baseline.len() * 2);
let mut local: HashMap<String, LocalItem> = HashMap::new(); let mut local: HashMap<String, LocalItem> = HashMap::new();
let mut unscanned = Unscanned::default();
let writing = open_for_write_set(local_root); let writing = open_for_write_set(local_root);
self.walk_local( self.walk_local(
folder_id, local_root, local_root, rules, &writing, &mut local, folder_id,
local_root,
rules,
&writing,
&mut local,
&mut unscanned,
)?; )?;
// See `push_pass`: the guard must not count rule-excluded paths as lost. // See `push_pass`: the guard must not count excluded paths as lost.
guard_local_wipe(&filter_baseline(&baseline, rules), &local)?; guard_local_wipe(&tracked_baseline(&baseline, rules, &unscanned), &local)?;
let mut remote: HashMap<String, RemoteItem> = HashMap::new(); let mut remote: HashMap<String, RemoteItem> = HashMap::new();
let mut remote_dirs: HashMap<String, NodeUid> = HashMap::new(); let mut remote_dirs: HashMap<String, NodeUid> = HashMap::new();
@@ -605,9 +648,15 @@ impl Core {
&baseline, &baseline,
)?; )?;
let order = classification_order(&local, &remote, &baseline, rules); let mut order = classification_order(&local, &remote, &baseline, rules);
// A path the scan could not read is no more a local deletion than an
// ignored one, and a remote path under it has nowhere to be written.
order.retain(|rel| !unscanned.covers(rel));
let mut outcome = Outcome::default(); let mut outcome = Outcome {
unscanned: unscanned.report(),
..Default::default()
};
// Folders to delete, collected here and removed deepest-first at the end // Folders to delete, collected here and removed deepest-first at the end
// so a parent is never removed before its children. // so a parent is never removed before its children.
let mut delete_local_dirs: Vec<String> = Vec::new(); let mut delete_local_dirs: Vec<String> = Vec::new();
@@ -873,19 +922,19 @@ impl Core {
Ok(outcome) Ok(outcome)
} }
/// Recursively walk a local directory into `out`, keyed by `/`-joined relative /// Recursively walk a local tree into `out`, keyed by `/`-joined relative
/// path. Symlinks and other special files are skipped. Reports each entry to the /// path. Symlinks and other special files are skipped, and paths that cannot
/// pass's scan progress. /// be read go to `unscanned`. Reports each entry to the pass's scan progress.
fn walk_local( fn walk_local(
&self, &self,
folder_id: i64, folder_id: i64,
root: &Path, root: &Path,
dir: &Path,
rules: &IgnoreRules, rules: &IgnoreRules,
writing: &HashSet<PathBuf>, writing: &HashSet<PathBuf>,
out: &mut HashMap<String, LocalItem>, out: &mut HashMap<String, LocalItem>,
unscanned: &mut Unscanned,
) -> Result<(), String> { ) -> Result<(), String> {
walk_local_tree(root, dir, rules, writing, out, &mut |rel| { walk_local_tree(root, rules, writing, out, unscanned, &mut |rel| {
self.progress_scanned(folder_id, base_name(rel)) self.progress_scanned(folder_id, base_name(rel))
}) })
} }
@@ -1582,51 +1631,120 @@ fn removed_locally(result: std::io::Result<()>) -> std::io::Result<()> {
} }
/// The body of [`Core::walk_local`], without a `Core`: recursively collect the /// The body of [`Core::walk_local`], without a `Core`: recursively collect the
/// local tree under `dir` into `out`, reporting each entry to `scanned`. /// local tree under `root` into `out`, reporting each entry to `scanned`.
/// ///
/// **Every failure here is fatal to the pass, deliberately.** A `read_dir` that /// **A path the scan cannot read is left out and recorded in `unscanned`, never
/// fails, an entry that cannot be stat-ed, a name that is not UTF-8 — each of /// silently dropped.** Every pass drops what `unscanned` covers from
/// them would, if skipped, leave `out` describing a *smaller* tree than the one /// classification, so a baseline row under it is neither a local deletion nor a
/// on disk. Reconciliation reads a path present in the baseline and absent from /// download target (`docs/BUGS.md` B55). That covers a subfolder the pass has no
/// the scan as "the user deleted this", and propagates that to Drive. So an /// permission for, an entry that vanished between the listing and its `stat`,
/// unreadable subdirectory would trash its remote counterpart, and the deeper /// and a name that is not UTF-8. Failing the whole pass on any of them, as this
/// the failure, the more it takes with it (`docs/BUGS.md` B55). Refusing the /// did before, left one root-owned folder stopping everything else in the tree
/// whole pass keeps the previous baseline and leaves the remote alone; the next /// from syncing, on every pass, for good (B196).
/// pass retries once the transient condition clears. ///
/// The root is the exception, and so is any other I/O error: an absent or
/// unreadable root fails the pass, since leaving the root out would leave
/// nothing in, and an error this cannot name a cause for is not one to guess
/// about.
/// ///
/// Split out from the method so exactly that can be tested against a real tree /// Split out from the method so exactly that can be tested against a real tree
/// with a real unreadable directory in it, which needs no `Core` at all. /// with a real unreadable directory in it, which needs no `Core` at all.
fn walk_local_tree( fn walk_local_tree(
root: &Path,
rules: &IgnoreRules,
writing: &HashSet<PathBuf>,
out: &mut HashMap<String, LocalItem>,
unscanned: &mut Unscanned,
scanned: &mut dyn FnMut(&str),
) -> Result<(), String> {
walk_local_dir(root, root, rules, writing, out, unscanned, scanned).map_err(|e| e.to_string())
}
/// Why [`walk_local_dir`] could not finish a folder.
enum ScanError {
/// An I/O error on `what`, which the caller may leave out of the pass.
Io { what: String, error: std::io::Error },
/// Anything else, which fails the pass.
Failed(String),
}
impl std::fmt::Display for ScanError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ScanError::Io { what, error } => write!(f, "{what}: {error}"),
ScanError::Failed(message) => f.write_str(message),
}
}
}
/// Leave `rel` out of the pass for `failure`, or hand it back when it is one
/// that fails the pass. Permission denied is reported to the user; an entry
/// that vanished mid-scan is not, since the next pass sees it settled.
fn leave_out(rel: &str, failure: ScanError, unscanned: &mut Unscanned) -> Result<(), ScanError> {
let ScanError::Io { what, error } = &failure else {
return Err(failure);
};
match error.kind() {
std::io::ErrorKind::PermissionDenied => {
warn!(%what, %error, "sync: cannot read; leaving it out of the pass");
unscanned.unreadable(rel.to_string(), &error.to_string());
Ok(())
}
std::io::ErrorKind::NotFound => {
debug!(%what, "sync: gone mid-scan; leaving it out of the pass");
unscanned.vanished(rel.to_string());
Ok(())
}
_ => Err(failure),
}
}
fn walk_local_dir(
root: &Path, root: &Path,
dir: &Path, dir: &Path,
rules: &IgnoreRules, rules: &IgnoreRules,
writing: &HashSet<PathBuf>, writing: &HashSet<PathBuf>,
out: &mut HashMap<String, LocalItem>, out: &mut HashMap<String, LocalItem>,
unscanned: &mut Unscanned,
scanned: &mut dyn FnMut(&str), scanned: &mut dyn FnMut(&str),
) -> Result<(), String> { ) -> Result<(), ScanError> {
let entries = std::fs::read_dir(dir).map_err(|e| format!("read {}: {e}", dir.display()))?; let io = |what: String| move |error| ScanError::Io { what, error };
let entries = std::fs::read_dir(dir).map_err(io(format!("read {}", dir.display())))?;
for entry in entries { for entry in entries {
let entry = entry.map_err(|e| format!("read entry in {}: {e}", dir.display()))?; let entry = entry.map_err(io(format!("read entry in {}", dir.display())))?;
let path = entry.path(); let path = entry.path();
let meta = std::fs::symlink_metadata(&path)
.map_err(|e| format!("stat {}: {e}", path.display()))?;
let stripped = path.strip_prefix(root).map_err(|e| { let stripped = path.strip_prefix(root).map_err(|e| {
format!( ScanError::Failed(format!(
"local path {} escaped sync root {}: {e}", "local path {} escaped sync root {}: {e}",
path.display(), path.display(),
root.display() root.display()
) ))
})?;
let rel = stripped.to_str().ok_or_else(|| {
format!(
"local path {} is not valid UTF-8; refusing a destructive sync pass",
path.display()
)
})?; })?;
let Some(rel) = stripped.to_str() else {
// Nothing can be keyed by this name: not the baseline, which only
// ever held paths this scan produced, and not Drive, whose names are
// UTF-8. Leaving it out cannot read as a deletion of anything.
warn!(path = %path.display(), "sync: name is not valid UTF-8; leaving it out of the pass");
unscanned.unreadable(
stripped.to_string_lossy().into_owned(),
"name is not valid UTF-8",
);
continue;
};
// Ignore our own in-flight download temp files. // Ignore our own in-flight download temp files.
if rel.contains(".pdfs-tmp-") { if rel.contains(".pdfs-tmp-") {
continue; continue;
} }
let meta = match std::fs::symlink_metadata(&path) {
Ok(meta) => meta,
// Gone since the listing, or in a folder that lists but does not
// allow `stat`. Either way this pass cannot say what it is.
Err(error) => {
let what = format!("stat {}", path.display());
leave_out(rel, ScanError::Io { what, error }, unscanned)?;
continue;
}
};
// Ignored paths are dropped here as well as from the classification // Ignored paths are dropped here as well as from the classification
// union — not for correctness (the union filter is what makes this // union — not for correctness (the union filter is what makes this
// safe) but so an ignored `node_modules/` is never descended into or // safe) but so an ignored `node_modules/` is never descended into or
@@ -1646,7 +1764,15 @@ fn walk_local_tree(
}, },
); );
scanned(rel); scanned(rel);
walk_local_tree(root, &path, rules, writing, out, scanned)?; if let Err(failure) =
walk_local_dir(root, &path, rules, writing, out, unscanned, scanned)
{
leave_out(rel, failure, unscanned)?;
// What the folder did list before failing goes with it: a
// half-read folder is as unknown as an unread one.
let prefix = format!("{rel}/");
out.retain(|k, _| k != rel && !k.starts_with(&prefix));
}
} else if meta.is_file() { } else if meta.is_file() {
// Check whether any process holds this file open for writing. // Check whether any process holds this file open for writing.
// The canonical path is used because /proc/*/fd links resolve // The canonical path is used because /proc/*/fd links resolve
@@ -1684,7 +1810,7 @@ pub(crate) fn mirror_subtree_unsynced(
baseline: &HashMap<String, StoredSyncEntry>, baseline: &HashMap<String, StoredSyncEntry>,
) -> Option<String> { ) -> Option<String> {
let mut seen = HashSet::new(); let mut seen = HashSet::new();
if let Some(reason) = unsynced_local(root, rel, baseline, &mut seen) { if let Some(reason) = unsynced_local(root, rel, baseline, None, &mut seen) {
return Some(reason); return Some(reason);
} }
let prefix = format!("{rel}/"); let prefix = format!("{rel}/");
@@ -1695,45 +1821,93 @@ pub(crate) fn mirror_subtree_unsynced(
.map(|r| format!("{r} was deleted here but not on Proton Drive yet")) .map(|r| format!("{r} was deleted here but not on Proton Drive yet"))
} }
/// [`mirror_subtree_unsynced`] for a whole mirror folder: why its local copy
/// holds something Proton Drive does not, or `None` when evicting it loses
/// nothing. The switch to on-demand asks this before it empties the folder.
///
/// A push pass that ends `idle` is not enough on its own. It never uploads an
/// ignored path, a symlink or a folder it cannot read, it defers a file open
/// for writing, and a file can change after it finishes; the eviction deleted
/// all of those for good (B198).
///
/// A tracked path deleted here but still in the baseline counts, since the
/// mount would show it again. An ignored one does not: ignoring never carries
/// a deletion to Drive, so its row stays in the baseline for good, and holding
/// the switch on it would hold it forever.
pub(crate) fn mirror_tree_unsynced(
root: &Path,
baseline: &HashMap<String, StoredSyncEntry>,
rules: &IgnoreRules,
) -> Option<String> {
let mut seen = HashSet::new();
if let Some(reason) = unsynced_local(root, "", baseline, Some(rules), &mut seen) {
return Some(reason);
}
let mut missing: Vec<String> = filter_baseline(baseline, rules)
.into_keys()
.filter(|r| !seen.contains(r.as_str()))
.collect();
missing.sort_unstable();
missing
.first()
.map(|r| format!("{r} was deleted here but not on Proton Drive yet"))
}
/// The walk behind both checks. `rules`, when given, only names an ignored
/// path as such in the reason; it is still something only this disk has.
fn unsynced_local( fn unsynced_local(
root: &Path, root: &Path,
rel: &str, rel: &str,
baseline: &HashMap<String, StoredSyncEntry>, baseline: &HashMap<String, StoredSyncEntry>,
rules: Option<&IgnoreRules>,
seen: &mut HashSet<String>, seen: &mut HashSet<String>,
) -> Option<String> { ) -> Option<String> {
let path = root.join(rel_to_path(rel)); let path = if rel.is_empty() {
let meta = match std::fs::symlink_metadata(&path) { root.to_path_buf()
Ok(meta) => meta, } else {
Err(e) => return Some(format!("cannot read {rel}: {e}")), root.join(rel_to_path(rel))
}; };
let Some(base) = baseline.get(rel) else { // The folder itself has no baseline row; only what is in it does.
return Some(format!("{rel} is not on Proton Drive yet")); if !rel.is_empty() {
}; let meta = match std::fs::symlink_metadata(&path) {
seen.insert(rel.to_string()); Ok(meta) => meta,
if meta.is_file() { Err(e) => return Some(format!("cannot read {rel}: {e}")),
return (!LocalSig::from(&meta).same_content(&LocalSig::from(base))) };
.then(|| format!("{rel} has changes that are not on Proton Drive yet")); let Some(base) = baseline.get(rel) else {
} if rules.is_some_and(|rules| rules.is_ignored(rel, meta.is_dir())) {
if !meta.is_dir() { return Some(format!(
return Some(format!("{rel} is not a regular file or folder")); "{rel} is excluded from sync, so it is only on this computer"
));
}
return Some(format!("{rel} is not on Proton Drive yet"));
};
seen.insert(rel.to_string());
if meta.is_file() {
return (!LocalSig::from(&meta).same_content(&LocalSig::from(base)))
.then(|| format!("{rel} has changes that are not on Proton Drive yet"));
}
if !meta.is_dir() {
return Some(format!("{rel} is not a regular file or folder"));
}
} }
let shown = if rel.is_empty() { "the folder" } else { rel };
let entries = match std::fs::read_dir(&path) { let entries = match std::fs::read_dir(&path) {
Ok(entries) => entries, Ok(entries) => entries,
Err(e) => return Some(format!("cannot read {rel}: {e}")), Err(e) => return Some(format!("cannot read {shown}: {e}")),
}; };
for entry in entries { for entry in entries {
let name = match entry { let name = match entry {
Ok(entry) => entry.file_name(), Ok(entry) => entry.file_name(),
Err(e) => return Some(format!("cannot read {rel}: {e}")), Err(e) => return Some(format!("cannot read {shown}: {e}")),
}; };
let Some(name) = name.to_str() else { let Some(name) = name.to_str() else {
return Some(format!("{rel} holds a name that is not valid UTF-8")); return Some(format!("{shown} holds a name that is not valid UTF-8"));
}; };
// A download the engine left half done; it is the engine's to clean up. // A download the engine left half done; it is the engine's to clean up.
if name.contains(".pdfs-tmp-") { if name.contains(".pdfs-tmp-") {
continue; continue;
} }
if let Some(reason) = unsynced_local(root, &format!("{rel}/{name}"), baseline, seen) { if let Some(reason) = unsynced_local(root, &join_rel(rel, name), baseline, rules, seen) {
return Some(reason); return Some(reason);
} }
} }
@@ -2154,7 +2328,15 @@ mod tests {
let writing = HashSet::new(); let writing = HashSet::new();
let mut local: HashMap<String, LocalItem> = HashMap::new(); let mut local: HashMap<String, LocalItem> = HashMap::new();
let mut progress = no_progress(); let mut progress = no_progress();
walk_local_tree(&root, &root, &rules, &writing, &mut local, &mut progress).unwrap(); walk_local_tree(
&root,
&rules,
&writing,
&mut local,
&mut Unscanned::default(),
&mut progress,
)
.unwrap();
assert!(local.is_empty(), "the replacement root is genuinely empty"); assert!(local.is_empty(), "the replacement root is genuinely empty");
for size in 1..=3 { for size in 1..=3 {
@@ -2170,7 +2352,15 @@ mod tests {
// remounted folder resumes rather than staying wedged. // remounted folder resumes rather than staying wedged.
std::fs::write(root.join("file0.txt"), b"back").unwrap(); std::fs::write(root.join("file0.txt"), b"back").unwrap();
let mut local: HashMap<String, LocalItem> = HashMap::new(); let mut local: HashMap<String, LocalItem> = HashMap::new();
walk_local_tree(&root, &root, &rules, &writing, &mut local, &mut progress).unwrap(); walk_local_tree(
&root,
&rules,
&writing,
&mut local,
&mut Unscanned::default(),
&mut progress,
)
.unwrap();
let baseline: HashMap<String, ()> = [("file0.txt".to_string(), ())].into_iter().collect(); let baseline: HashMap<String, ()> = [("file0.txt".to_string(), ())].into_iter().collect();
assert!(guard_local_wipe(&filter_baseline(&baseline, &rules), &local).is_ok()); assert!(guard_local_wipe(&filter_baseline(&baseline, &rules), &local).is_ok());
@@ -2475,33 +2665,176 @@ mod tests {
|_: &str| {} |_: &str| {}
} }
/// A directory the pass cannot read must fail the pass. Omitting it would /// Scan `root` with no rules, returning what it found and left out.
/// hand reconciliation a baseline-minus-subtree, which it reads as the user fn scan(root: &Path) -> Result<(HashMap<String, LocalItem>, Unscanned), String> {
/// having deleted that subtree and propagates to Drive (B55). let mut out = HashMap::new();
let mut unscanned = Unscanned::default();
let mut progress = no_progress();
walk_local_tree(
root,
&rules_for(&[]),
&HashSet::new(),
&mut out,
&mut unscanned,
&mut progress,
)?;
Ok((out, unscanned))
}
fn sorted_keys<V>(map: &HashMap<String, V>) -> Vec<&str> {
let mut keys: Vec<&str> = map.keys().map(String::as_str).collect();
keys.sort_unstable();
keys
}
/// B196, the report in #33: a folder the user may not read, such as a
/// container's root-owned cache, is left out of the pass and named, and
/// everything around it still scans. It used to fail the whole pass, so
/// nothing else in the tree ever synced again.
#[test] #[test]
fn an_unreadable_subdirectory_fails_the_scan_instead_of_shrinking_it() { fn an_unreadable_subdirectory_is_left_out_and_the_rest_scans() {
use std::os::unix::fs::PermissionsExt as _; use std::os::unix::fs::PermissionsExt as _;
let root = sync_test_dir("scan-eacces"); let root = sync_test_dir("scan-eacces");
std::fs::write(root.join("keep.txt"), b"a").unwrap(); std::fs::write(root.join("keep.txt"), b"a").unwrap();
let locked = root.join("locked"); std::fs::create_dir_all(root.join("docker/cache")).unwrap();
std::fs::write(root.join("docker/compose.yml"), b"c").unwrap();
let locked = root.join("docker/cache/locked");
std::fs::create_dir(&locked).unwrap(); std::fs::create_dir(&locked).unwrap();
std::fs::write(locked.join("inner.txt"), b"b").unwrap(); std::fs::write(locked.join("inner.txt"), b"b").unwrap();
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap(); std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
let rules = rules_for(&[]); let result = scan(&root);
let writing = HashSet::new();
let mut out = HashMap::new();
let mut progress = no_progress();
let result = walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut progress);
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o700)).unwrap(); std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o700)).unwrap();
std::fs::remove_dir_all(&root).unwrap(); std::fs::remove_dir_all(&root).unwrap();
let error = result.expect_err("an unreadable directory must abort the pass"); let (out, unscanned) = result.expect("one unreadable folder must not fail the pass");
assert!( assert_eq!(
error.contains("locked"), sorted_keys(&out),
"the error should name the subtree it could not read: {error}" vec!["docker", "docker/cache", "docker/compose.yml", "keep.txt"]
);
assert!(unscanned.covers("docker/cache/locked"));
assert!(unscanned.covers("docker/cache/locked/inner.txt"));
assert!(!unscanned.covers("docker/cache"));
let report = unscanned.report();
assert_eq!(report.len(), 1);
assert!(report[0].starts_with("docker/cache/locked ("), "{report:?}");
}
/// A folder with read but no search permission lists its names but cannot
/// `stat` them. Whatever it did list must go with it: a half-read folder
/// is as unknown as an unread one.
#[test]
fn a_listable_but_unsearchable_folder_is_left_out_whole() {
use std::os::unix::fs::PermissionsExt as _;
let root = sync_test_dir("scan-no-search");
std::fs::write(root.join("keep.txt"), b"a").unwrap();
let half = root.join("half");
std::fs::create_dir(&half).unwrap();
std::fs::write(half.join("one.txt"), b"1").unwrap();
std::fs::write(half.join("two.txt"), b"2").unwrap();
std::fs::set_permissions(&half, std::fs::Permissions::from_mode(0o400)).unwrap();
let result = scan(&root);
std::fs::set_permissions(&half, std::fs::Permissions::from_mode(0o700)).unwrap();
std::fs::remove_dir_all(&root).unwrap();
let (out, unscanned) = result.unwrap();
// Each entry fails its own `stat` and is left out by name.
assert_eq!(sorted_keys(&out), vec!["half", "keep.txt"]);
assert!(unscanned.covers("half/one.txt"));
assert!(unscanned.covers("half/two.txt"));
}
/// The other half of B196: what was left out is not a deletion, and not a
/// download target either. Its baseline rows leave the wipe guard's set
/// and a push pass's deletions, and its paths leave classification.
#[test]
fn a_path_the_scan_left_out_is_never_a_local_deletion() {
let mut baseline = baseline_with("keep.txt", 1, 1);
baseline.extend(baseline_with("locked", 0, 0));
baseline.extend(baseline_with("locked/inner.txt", 1, 1));
baseline.extend(baseline_with("gone.txt", 1, 1));
let local: HashMap<String, LocalItem> = [("keep.txt".to_string(), local_item(false))]
.into_iter()
.collect();
let mut unscanned = Unscanned::default();
unscanned.unreadable("locked".to_string(), "Permission denied");
let rules = rules_for(&[]);
let tracked = tracked_baseline(&baseline, &rules, &unscanned);
assert_eq!(sorted_keys(&tracked), vec!["gone.txt", "keep.txt"]);
assert_eq!(local_deletions(&tracked, &local), vec!["gone.txt"]);
// Every tracked path but one is gone, and the guard still lets the
// pass run: one survivor proves the tree is there.
assert!(guard_local_wipe(&tracked, &local).is_ok());
// With everything readable left out, nothing is tracked, so nothing
// can be lost and the guard has nothing to object to.
let mut all = Unscanned::default();
all.unreadable("keep.txt".to_string(), "Permission denied");
all.unreadable("locked".to_string(), "Permission denied");
all.vanished("gone.txt".to_string());
assert!(tracked_baseline(&baseline, &rules, &all).is_empty());
let remote: HashMap<String, RemoteItem> = HashMap::new();
let mut order = classification_order(&local, &remote, &baseline, &rules);
order.retain(|rel| !unscanned.covers(rel));
order.sort_unstable();
assert_eq!(order, vec!["gone.txt", "keep.txt"]);
}
#[test]
fn unscanned_covers_a_path_and_what_is_under_it_but_not_its_siblings() {
let mut unscanned = Unscanned::default();
unscanned.unreadable("a/locked".to_string(), "Permission denied");
unscanned.vanished("b.txt".to_string());
assert!(unscanned.covers("a/locked"));
assert!(unscanned.covers("a/locked/x/y.txt"));
assert!(unscanned.covers("b.txt"));
assert!(!unscanned.covers("a"));
assert!(!unscanned.covers("a/locked2"));
assert!(!unscanned.covers("b.txt.bak"));
// A file that only vanished mid-scan is not worth telling anyone about.
assert_eq!(unscanned.report(), vec!["a/locked (Permission denied)"]);
}
/// B197: a push pass read its deletions from the raw baseline, so a path
/// synced before it became ignored, which the walk skips, was trashed on
/// Drive, and the switch the push pass ran for then evicted the local copy.
#[test]
fn a_push_pass_never_reads_an_ignored_path_as_deleted() {
let mut baseline = baseline_with("notes.md", 1, 1);
baseline.extend(baseline_with("node_modules", 0, 0));
baseline.extend(baseline_with("node_modules/x/index.js", 1, 1));
baseline.extend(baseline_with("old.md", 1, 1));
let local: HashMap<String, LocalItem> = [("notes.md".to_string(), local_item(false))]
.into_iter()
.collect();
let rules = rules_for(&["node_modules/"]);
let tracked = tracked_baseline(&baseline, &rules, &Unscanned::default());
assert_eq!(local_deletions(&tracked, &local), vec!["old.md"]);
}
/// Deletions come out shallowest first, so a trashed folder can take its
/// children with it.
#[test]
fn local_deletions_come_out_shallowest_first() {
let mut baseline = baseline_with("d/e/f.txt", 1, 1);
baseline.extend(baseline_with("d", 0, 0));
baseline.extend(baseline_with("d/e", 0, 0));
let local: HashMap<String, LocalItem> = HashMap::new();
let tracked = tracked_baseline(&baseline, &rules_for(&[]), &Unscanned::default());
assert_eq!(
local_deletions(&tracked, &local),
vec!["d", "d/e", "d/e/f.txt"]
); );
} }
@@ -2593,6 +2926,94 @@ mod tests {
assert_eq!(reason, None); assert_eq!(reason, None);
} }
// ---- B198: going online-only never evicts what Drive does not have -----
#[test]
fn a_fully_synced_mirror_folder_may_go_online_only() {
let (root, baseline) = synced_subtree("evict-synced");
let reason = mirror_tree_unsynced(&root, &baseline, &rules_for(&[]));
std::fs::remove_dir_all(&root).unwrap();
assert_eq!(reason, None);
}
/// The case that lost data: `.git/` is ignored by default, so no pass ever
/// uploads it, and the eviction deleted it with the rest.
#[test]
fn an_ignored_folder_only_this_disk_has_holds_the_switch_back() {
let (root, baseline) = synced_subtree("evict-ignored");
std::fs::create_dir(root.join(".git")).unwrap();
std::fs::write(root.join(".git/HEAD"), b"ref").unwrap();
let reason = mirror_tree_unsynced(&root, &baseline, &rules_for(&[".git/"]));
std::fs::remove_dir_all(&root).unwrap();
let reason = reason.expect("an ignored folder is only on this disk");
assert!(
reason.contains(".git") && reason.contains("excluded"),
"{reason}"
);
}
/// An ignored row stays in the baseline for good once its file is gone, so
/// counting it as a deletion not carried up would hold the switch forever.
#[test]
fn a_deleted_ignored_path_does_not_hold_the_switch_back() {
let (root, mut baseline) = synced_subtree("evict-ignored-gone");
baseline.insert(
"build/out.o".to_string(),
StoredSyncEntry {
rel_path: "build/out.o".to_string(),
..baseline["dir/a.txt"].clone()
},
);
let rules = rules_for(&["build/"]);
let ignored = mirror_tree_unsynced(&root, &baseline, &rules);
let tracked = mirror_tree_unsynced(&root, &baseline, &rules_for(&[]));
std::fs::remove_dir_all(&root).unwrap();
assert_eq!(ignored, None);
assert!(tracked.unwrap().contains("build/out.o"));
}
#[test]
fn an_edit_after_the_last_pass_holds_the_switch_back() {
let (root, baseline) = synced_subtree("evict-edit");
std::fs::write(root.join("dir/sub/b.txt"), b"edited since").unwrap();
let reason = mirror_tree_unsynced(&root, &baseline, &rules_for(&[]));
std::fs::remove_dir_all(&root).unwrap();
assert!(reason.unwrap().contains("dir/sub/b.txt"));
}
#[test]
fn a_new_file_at_the_root_holds_the_switch_back() {
let (root, baseline) = synced_subtree("evict-new");
std::fs::write(root.join("new.txt"), b"n").unwrap();
let reason = mirror_tree_unsynced(&root, &baseline, &rules_for(&[]));
std::fs::remove_dir_all(&root).unwrap();
assert!(reason.unwrap().contains("new.txt"));
}
/// The folder B196 now leaves out of the pass instead of failing it is,
/// by the same token, something the eviction must not delete.
#[test]
fn an_unreadable_folder_holds_the_switch_back() {
use std::os::unix::fs::PermissionsExt as _;
let (root, baseline) = synced_subtree("evict-eacces");
let locked = root.join("dir/sub");
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
let reason = mirror_tree_unsynced(&root, &baseline, &rules_for(&[]));
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o700)).unwrap();
std::fs::remove_dir_all(&root).unwrap();
assert!(reason.unwrap().contains("dir/sub"));
}
#[test]
fn a_symlink_holds_the_switch_back() {
let (root, baseline) = synced_subtree("evict-symlink");
std::os::unix::fs::symlink("dir/a.txt", root.join("link")).unwrap();
let reason = mirror_tree_unsynced(&root, &baseline, &rules_for(&[]));
std::fs::remove_dir_all(&root).unwrap();
assert!(reason.unwrap().contains("link"));
}
/// The same rule one level up: if the sync root itself cannot be read, the /// The same rule one level up: if the sync root itself cannot be read, the
/// pass sees nothing, and "nothing" must never mean "delete everything". /// pass sees nothing, and "nothing" must never mean "delete everything".
#[test] #[test]
@@ -2607,7 +3028,14 @@ mod tests {
let writing = HashSet::new(); let writing = HashSet::new();
let mut out = HashMap::new(); let mut out = HashMap::new();
let mut progress = no_progress(); let mut progress = no_progress();
let result = walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut progress); let result = walk_local_tree(
&root,
&rules,
&writing,
&mut out,
&mut Unscanned::default(),
&mut progress,
);
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).unwrap(); std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).unwrap();
std::fs::remove_dir_all(&root).unwrap(); std::fs::remove_dir_all(&root).unwrap();
@@ -2628,30 +3056,40 @@ mod tests {
let writing = HashSet::new(); let writing = HashSet::new();
let mut out = HashMap::new(); let mut out = HashMap::new();
let mut progress = no_progress(); let mut progress = no_progress();
assert!(walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut progress).is_err()); assert!(
walk_local_tree(
&root,
&rules,
&writing,
&mut out,
&mut Unscanned::default(),
&mut progress
)
.is_err()
);
assert!(out.is_empty()); assert!(out.is_empty());
} }
/// A name that is not valid UTF-8 cannot be keyed into the scan, so it /// A name that is not valid UTF-8 cannot be keyed into the scan, but it
/// cannot be compared against the baseline either. Refusing the pass is the /// cannot be in the baseline or on Drive either, so leaving it out reads as
/// only safe reading: skipping it would classify the file as deleted. /// the deletion of nothing. It used to fail the pass (B196).
#[test] #[test]
fn a_non_utf8_name_fails_the_scan() { fn a_non_utf8_name_is_left_out_and_reported() {
use std::ffi::OsStr; use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt as _; use std::os::unix::ffi::OsStrExt as _;
let root = sync_test_dir("scan-non-utf8"); let root = sync_test_dir("scan-non-utf8");
std::fs::write(root.join(OsStr::from_bytes(b"bad\xffname")), b"x").unwrap(); std::fs::write(root.join(OsStr::from_bytes(b"bad\xffname")), b"x").unwrap();
std::fs::write(root.join("good.txt"), b"y").unwrap();
let rules = rules_for(&[]); let result = scan(&root);
let writing = HashSet::new();
let mut out = HashMap::new();
let mut progress = no_progress();
let result = walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut progress);
std::fs::remove_dir_all(&root).unwrap(); std::fs::remove_dir_all(&root).unwrap();
let error = result.expect_err("a name the scan cannot key must abort the pass"); let (out, unscanned) = result.unwrap();
assert!(error.contains("UTF-8"), "{error}"); assert_eq!(sorted_keys(&out), vec!["good.txt"]);
let report = unscanned.report();
assert_eq!(report.len(), 1);
assert!(report[0].contains("UTF-8"), "{report:?}");
} }
/// The complement: a scan that *can* read everything reports everything, so /// The complement: a scan that *can* read everything reports everything, so
@@ -2670,9 +3108,14 @@ mod tests {
let writing = HashSet::new(); let writing = HashSet::new();
let mut out = HashMap::new(); let mut out = HashMap::new();
let mut seen: Vec<String> = Vec::new(); let mut seen: Vec<String> = Vec::new();
let result = walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut |rel| { let result = walk_local_tree(
seen.push(rel.to_string()) &root,
}); &rules,
&writing,
&mut out,
&mut Unscanned::default(),
&mut |rel| seen.push(rel.to_string()),
);
std::fs::remove_dir_all(&root).unwrap(); std::fs::remove_dir_all(&root).unwrap();
result.unwrap(); result.unwrap();
+5
View File
@@ -98,6 +98,11 @@ pub(super) struct Outcome {
pub(super) errors: usize, pub(super) errors: usize,
/// Files skipped because another process held them open for writing. /// Files skipped because another process held them open for writing.
pub(super) deferred: usize, pub(super) deferred: usize,
/// Paths the local scan could not read, left out of the pass. Not part of
/// [`is_empty`](Self::is_empty) or the summary: an unreadable folder stays
/// unreadable from one poll to the next, so the pass reports it once, when
/// the set changes, rather than on every poll.
pub(super) unscanned: Vec<String>,
} }
impl Outcome { impl Outcome {
+90
View File
@@ -90,6 +90,96 @@ pub(super) fn filter_baseline<'a, B>(
.collect() .collect()
} }
/// The paths a local scan left out because it could not read them: a folder
/// it has no permission for, an entry that vanished between the listing and
/// its `stat`, a name that is not UTF-8.
///
/// A left-out path is not a deleted one. A pass drops everything an entry
/// covers from classification, exactly as it drops ignored paths, so a
/// baseline row under an unreadable folder is neither read as a local deletion
/// nor answered with a download into a folder the pass cannot write. Failing
/// the whole pass instead is what B55 did, and it left one root-owned folder
/// stopping every other file in the tree from syncing (B196).
#[derive(Default)]
pub(super) struct Unscanned {
covered: HashSet<String>,
/// The subset worth telling the user about, with why: an entry that only
/// vanished mid-scan is not, since the next pass sees it settled.
unreadable: Vec<String>,
}
impl Unscanned {
/// Leave out `rel`, which vanished while the scan looked at it.
pub(super) fn vanished(&mut self, rel: String) {
self.covered.insert(rel);
}
/// Leave out `rel`, which cannot be read for `reason`.
pub(super) fn unreadable(&mut self, rel: String, reason: &str) {
self.unreadable.push(format!("{rel} ({reason})"));
self.covered.insert(rel);
}
/// Whether `rel` is a left-out path or lies under one.
pub(super) fn covers(&self, rel: &str) -> bool {
if self.covered.is_empty() {
return false;
}
let mut at = rel;
loop {
if self.covered.contains(at) {
return true;
}
match at.rfind('/') {
Some(i) => at = &at[..i],
None => return false,
}
}
}
/// The unreadable paths with their reasons, sorted, for the activity feed.
pub(super) fn report(&self) -> Vec<String> {
let mut lines = self.unreadable.clone();
lines.sort_unstable();
lines
}
}
/// The baseline paths a pass can see: [`filter_baseline`], minus everything
/// under a path the scan could not read. This is the set [`guard_local_wipe`]
/// checks and the set a push pass reads deletions from, since a path outside
/// it is absent from the local walk by rule or by failure, never by loss.
pub(super) fn tracked_baseline<'a, B>(
baseline: &'a HashMap<String, B>,
rules: &IgnoreRules,
unscanned: &Unscanned,
) -> HashMap<String, &'a B> {
let mut tracked = filter_baseline(baseline, rules);
tracked.retain(|rel, _| !unscanned.covers(rel));
tracked
}
/// The baseline paths a push pass has to trash on Drive: tracked, and gone
/// from the local walk. Shallowest first, so a trashed folder takes its
/// children with it.
///
/// Built from [`tracked_baseline`] rather than from the raw baseline. Reading
/// it raw trashed every synced path that later became ignored, because the walk
/// skips those, and the switch to on-demand that the push pass runs for then
/// deleted the local copy too (B197).
pub(super) fn local_deletions<B, L>(
tracked: &HashMap<String, &B>,
local: &HashMap<String, L>,
) -> Vec<String> {
let mut missing: Vec<String> = tracked
.keys()
.filter(|rel| !local.contains_key(*rel))
.cloned()
.collect();
missing.sort_by_key(|p| p.matches('/').count());
missing
}
/// Refuse to run a pass whose local side has vanished in its entirety. /// Refuse to run a pass whose local side has vanished in its entirety.
/// ///
/// When every baseline path is absent locally, the likely cause is an unavailable /// When every baseline path is absent locally, the likely cause is an unavailable
+21 -8
View File
@@ -202,12 +202,20 @@ impl Ui {
} }
fn main() -> glib::ExitCode { fn main() -> glib::ExitCode {
tracing_subscriber::fmt() let verify = std::env::args().nth(1).as_deref() == Some(VERIFY_ARG);
.with_env_filter( let logs = tracing_subscriber::fmt().with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()), tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
) );
.init(); // The verification window's stdout carries its token back to the app.
if verify {
logs.with_writer(std::io::stderr).init();
} else {
logs.init();
}
i18n::init(); i18n::init();
if verify {
return run_verification_window();
}
// The tray launches the app with arguments ("--page locations", // The tray launches the app with arguments ("--page locations",
// "--confirm-stop"), which a running instance must receive too. // "--confirm-stop"), which a running instance must receive too.
@@ -303,6 +311,11 @@ fn build_window(app: &adw::Application) {
return; return;
} }
}; };
// On a fresh install the daemon has not run yet to make these, and the
// Takeout page keeps its staged archives in the state dir (B194).
if let Err(e) = dirs.ensure() {
tracing::warn!("cannot create the app's directories: {e}");
}
let stack = adw::ViewStack::new(); let stack = adw::ViewStack::new();
let (login_page, login_widgets) = build_login_page(); let (login_page, login_widgets) = build_login_page();
@@ -1210,7 +1223,7 @@ fn install_window_actions(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
let dialog = adw::AboutDialog::builder() let dialog = adw::AboutDialog::builder()
.application_name(gettext("Proton Drive for Linux")) .application_name(gettext("Proton Drive for Linux"))
.application_icon("io.narl.proton-drive-linux") .application_icon("io.narl.proton-drive-linux")
.version(pdfs_core::config::APP_VERSION) .version(env!("CARGO_PKG_VERSION"))
.developer_name("Nils Pukropp") .developer_name("Nils Pukropp")
.website("https://proton-drive.narl.io") .website("https://proton-drive.narl.io")
.issue_url("https://github.com/narrrl/proton-drive-linux/issues") .issue_url("https://github.com/narrrl/proton-drive-linux/issues")
@@ -1350,8 +1363,8 @@ fn debug_info(ui: &Rc<Ui>) -> String {
format!( format!(
"App version: {}\nUser agent: {}\nlibadwaita: {}.{}.{}\nGTK: {}.{}.{}\n\ "App version: {}\nUser agent: {}\nlibadwaita: {}.{}.{}\nGTK: {}.{}.{}\n\
Mountpoint: {}\nControl socket: {}\nMount service running: {}\n", Mountpoint: {}\nControl socket: {}\nMount service running: {}\n",
pdfs_core::config::APP_VERSION, config.resolved_app_version(),
pdfs_core::config::USER_AGENT, config.resolved_user_agent(),
adw::major_version(), adw::major_version(),
adw::minor_version(), adw::minor_version(),
adw::micro_version(), adw::micro_version(),
+65
View File
@@ -439,6 +439,10 @@ fn other_computer_row(ui: &Rc<Ui>, dev: &DeviceInfo) -> adw::ActionRow {
menu.item(&gettext("Restore to This Computer…"), move || { menu.item(&gettext("Restore to This Computer…"), move || {
prompt_restore_folders(&ui_c, Some((uid_c.clone(), name_c.clone()))) prompt_restore_folders(&ui_c, Some((uid_c.clone(), name_c.clone())))
}); });
let (ui_c, uid_c, name_c) = (ui.clone(), uid.clone(), name.clone());
menu.item(&gettext("Move to This Computer…"), move || {
prompt_migrate_device(&ui_c, &uid_c, &name_c)
});
menu.section(); menu.section();
let (ui_c, uid_c, name_c) = (ui.clone(), uid.clone(), name.clone()); let (ui_c, uid_c, name_c) = (ui.clone(), uid.clone(), name.clone());
menu.item(&gettext("Rename…"), move || { menu.item(&gettext("Rename…"), move || {
@@ -1096,6 +1100,67 @@ pub(crate) fn prompt_adopt_device(ui: &Rc<Ui>, uid: &str, name: &str) {
dialog.present(win.as_ref()); dialog.present(win.as_ref());
} }
/// Ask before moving another computer's folders into this computer's backup,
/// then offer them for syncing here.
///
/// Destructive-looking on purpose: the other computer keeps its device but
/// loses its folders, and a copy of Proton Drive still running there would
/// watch them vanish.
pub(crate) fn prompt_migrate_device(ui: &Rc<Ui>, uid: &str, name: &str) {
let win = ui_window(ui);
let dialog = adw::AlertDialog::builder()
.heading(gettext("Move Backup to This Computer?"))
// Translators: {name} is the name of the other computer whose folders move to this one.
.body(gettext_f(
"The folders backed up by “{name}” move into this computer's backup in Proton Drive, and you choose where they go on this computer. “{name}” stays in your account, without those folders.\n\nStop Proton Drive on “{name}” first. Otherwise it sees its folders disappear.",
&[("name", name)],
))
.build();
dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("move", &gettext("Move Here"));
dialog.set_response_appearance("move", adw::ResponseAppearance::Destructive);
dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel");
let ui = ui.clone();
let uid = uid.to_string();
// Translators: {name} is the name of the computer whose folders moved to this one.
let done = gettext_f("Moved the backup of “{name}”", &[("name", name)]);
dialog.connect_response(None, move |_, resp| {
if resp != "move" {
return;
}
ui.busy_begin();
let rx = spawn_request(
ui.dirs.control_socket(),
Request::MigrateDevice { uid: uid.clone() },
);
let (ui, done) = (ui.clone(), done.clone());
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.busy_end();
let failed = gettext("Couldn't move that backup");
match result {
Ok(Ok(Response::Ok { .. })) => {
reload_sync_pages(&ui);
toast(&ui, &done);
// The folders are this computer's now, so its own restore
// picker is where the user chooses their local paths.
prompt_restore_folders(&ui, None);
}
Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, &failed, &message, kind)
}
_ => toast_error(
&ui,
&failed,
&gettext("The Proton Drive service didn't respond."),
),
}
});
});
dialog.present(win.as_ref());
}
/// Run a mutation raised from Computers or Locations and reload the page on success. /// Run a mutation raised from Computers or Locations and reload the page on success.
pub(crate) fn run_devices_mutation(ui: &Rc<Ui>, req: Request, done: &str, failed: &str) { pub(crate) fn run_devices_mutation(ui: &Rc<Ui>, req: Request, done: &str, failed: &str) {
ui.busy_begin(); ui.busy_begin();
+17 -2
View File
@@ -150,15 +150,27 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
}); });
// Same shape for human verification: only fires when the API gates the // Same shape for human verification: only fires when the API gates the
// sign-in, and the worker is blocked on the token until it does. // sign-in, and the worker is blocked on the token until it does. A
// window that never came up is told apart from one the user closed, so
// the result below can say why the sign-in stopped.
let ui_hv = ui.clone(); let ui_hv = ui.clone();
let verification_failed = Rc::new(Cell::new(false));
let hv_failed = verification_failed.clone();
glib::spawn_future_local(async move { glib::spawn_future_local(async move {
if let Ok((url, token_tx)) = hv_req_rx.recv().await { if let Ok((url, token_tx)) = hv_req_rx.recv().await {
ui_hv ui_hv
.login .login
.login_status .login_status
.set_text(&gettext("Complete the verification to continue…")); .set_text(&gettext("Complete the verification to continue…"));
prompt_human_verification(&ui_hv, &url, token_tx); // Anything but a token drops the sender, which the worker reads
// as a cancelled sign-in.
match verify_human(url).await {
Verification::Solved(token) => {
let _ = token_tx.send(token);
}
Verification::Cancelled => {}
Verification::Failed => hv_failed.set(true),
}
} }
}); });
@@ -194,6 +206,9 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
let _ = gio::spawn_blocking(service::enable_start).await; let _ = gio::spawn_blocking(service::enable_start).await;
refresh(&ui); refresh(&ui);
} }
Err(_) if verification_failed.get() => ui.login.login_status.set_text(&gettext(
"The verification page couldn't be opened, so the sign-in was cancelled. Your system may keep the web view's sandbox from starting; the troubleshooting guide explains what to do.",
)),
Err(e) => ui.login.login_status.set_text(&e), Err(e) => ui.login.login_status.set_text(&e),
} }
}); });
+168 -40
View File
@@ -1,4 +1,4 @@
//! The human-verification (CAPTCHA) dialog. //! The human-verification (CAPTCHA) window.
//! //!
//! Proton gates logins it does not recognise — a new IP, a VPN exit — behind a //! Proton gates logins it does not recognise — a new IP, a VPN exit — behind a
//! CAPTCHA. The API answers the login with a challenge instead of a session, and //! CAPTCHA. The API answers the login with a challenge instead of a session, and
@@ -8,10 +8,26 @@
//! The page is hosted in a `WebKitWebView` rather than handed to the system //! The page is hosted in a `WebKitWebView` rather than handed to the system
//! browser because it reports completion by posting a message to its host, not //! browser because it reports completion by posting a message to its host, not
//! by redirecting: a browser has nowhere to post it back to. //! by redirecting: a browser has nowhere to post it back to.
//!
//! The webview runs in a child `pdfs-app` started with [`VERIFY_ARG`], not in the
//! app itself. WebKit aborts its whole process when its sandbox cannot start, as
//! on hardened systems that keep bubblewrap from the journal socket
//! (`docs/BUGS.md` B195); in a child that costs the verification, not the app.
//! The URL goes to the child on stdin, so it never shows in a process list, and
//! the token comes back on stdout.
use crate::*; use crate::*;
use std::io::{BufRead, BufReader, Write as _};
use std::process::{ExitStatus, Stdio};
use webkit6::prelude::*; use webkit6::prelude::*;
/// The argument that makes `pdfs-app` the verification window instead of the app.
pub(crate) const VERIFY_ARG: &str = "--human-verification";
/// Marks the token among whatever else lands on the child's stdout: GLib and
/// WebKit write debug output there.
const TOKEN_PREFIX: &str = "hv-token ";
/// Bridge the verification page's `postMessage` to a handler this side can read. /// Bridge the verification page's `postMessage` to a handler this side can read.
/// ///
/// The page targets its embedding host (`window.parent`), which in a webview is /// The page targets its embedding host (`window.parent`), which in a webview is
@@ -26,15 +42,100 @@ window.addEventListener('message', function (event) {
}); });
"#; "#;
/// Present the challenge and hand the solved token back through `token_tx`. /// How the verification window ended.
/// #[derive(Debug, PartialEq, Eq)]
/// Closing the dialog without solving drops the sender, which the waiting login pub(crate) enum Verification {
/// worker reads as a cancelled sign-in — the same contract as the 2FA prompt. Solved(String),
pub(crate) fn prompt_human_verification( /// Closed without solving.
ui: &Rc<Ui>, Cancelled,
url: &str, /// Never came up, or died: most likely WebKit could not start its sandbox.
token_tx: std::sync::mpsc::Sender<String>, Failed,
) { }
/// Show the challenge in the verification window and wait until it closes.
pub(crate) async fn verify_human(url: String) -> Verification {
match gio::spawn_blocking(move || run_verification_child(&url)).await {
Ok(Ok(verification)) => verification,
Ok(Err(e)) => {
tracing::error!("cannot run the verification window: {e}");
Verification::Failed
}
Err(_) => Verification::Failed,
}
}
fn run_verification_child(url: &str) -> std::io::Result<Verification> {
let mut child = Command::new(own_exe())
.arg(VERIFY_ARG)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()?;
if let Some(mut stdin) = child.stdin.take() {
// A failed write means the child is already gone; its exit status says
// how. Dropping `stdin` closes it.
let _ = writeln!(stdin, "{url}");
}
// Stops reading at the token rather than at EOF: WebKit's helper processes
// inherit the pipe and may hold it open a moment after the child quits.
let token = child
.stdout
.take()
.and_then(|out| find_token(BufReader::new(out)));
let status = child.wait()?;
Ok(outcome(token, status))
}
/// This very binary, so a development build opens its own window. After an
/// upgrade replaced the file, `current_exe` names a deleted path, and the
/// installed `pdfs-app` is the next best.
fn own_exe() -> PathBuf {
std::env::current_exe()
.ok()
.filter(|path| path.is_file())
.unwrap_or_else(|| PathBuf::from("pdfs-app"))
}
fn find_token(out: impl BufRead) -> Option<String> {
out.lines()
.map_while(Result::ok)
.find_map(|line| line.strip_prefix(TOKEN_PREFIX).map(str::to_owned))
}
/// A token wins over the exit status: the user solved the page, even if
/// WebKit's teardown crashed after it.
fn outcome(token: Option<String>, status: ExitStatus) -> Verification {
match token {
Some(token) => Verification::Solved(token),
None if status.success() => Verification::Cancelled,
None => {
tracing::error!(%status, "the verification window failed");
Verification::Failed
}
}
}
/// Run as the verification window: read the page's URL from stdin, show it, and
/// print the token once the user has solved it. Closing the window exits
/// without a token, which the app reads as a cancelled sign-in.
pub(crate) fn run_verification_window() -> glib::ExitCode {
let mut url = String::new();
if std::io::stdin().lock().read_line(&mut url).is_err() || url.trim().is_empty() {
tracing::error!("{VERIFY_ARG} expects the verification URL on stdin");
return glib::ExitCode::FAILURE;
}
let url = url.trim().to_owned();
// Not unique: the app that started it already holds APP_ID.
let app = adw::Application::builder()
.flags(gio::ApplicationFlags::NON_UNIQUE)
.build();
app.connect_activate(move |app| build_verification_window(app, &url));
// GApplication would refuse VERIFY_ARG as an unknown option.
let argv0 = std::env::args().next().unwrap_or_default();
app.run_with_args(&[argv0])
}
fn build_verification_window(app: &adw::Application, url: &str) {
let content = webkit6::UserContentManager::new(); let content = webkit6::UserContentManager::new();
content.add_script(&webkit6::UserScript::new( content.add_script(&webkit6::UserScript::new(
BRIDGE, BRIDGE,
@@ -53,24 +154,19 @@ pub(crate) fn prompt_human_verification(
.hexpand(true) .hexpand(true)
.build(); .build();
let dialog = adw::Dialog::builder() let body = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
body.append(&adw::HeaderBar::new());
body.append(&webview);
let window = adw::ApplicationWindow::builder()
.application(app)
.title(gettext("Verification")) .title(gettext("Verification"))
.content_width(420) .icon_name(APP_ID)
.content_height(560) .default_width(420)
.default_height(560)
.content(&body)
.build(); .build();
let header = adw::HeaderBar::new(); let app = app.downgrade();
let body = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
body.append(&header);
body.append(&webview);
dialog.set_child(Some(&body));
// Taken on the first success *or* on close, so the token is sent at most
// once and a cancelled dialog always drops the sender.
let token_tx = Rc::new(RefCell::new(Some(token_tx)));
let tx = token_tx.clone();
let dlg_weak = dialog.downgrade();
content.connect_script_message_received(Some("hv"), move |_, value| { content.connect_script_message_received(Some("hv"), move |_, value| {
let Some(token) = extract_token(&value.to_str()) else { let Some(token) = extract_token(&value.to_str()) else {
// Every `postMessage` on the page reaches here, most of them the // Every `postMessage` on the page reaches here, most of them the
@@ -78,25 +174,17 @@ pub(crate) fn prompt_human_verification(
// is simply not ours. // is simply not ours.
return; return;
}; };
if let Some(tx) = tx.borrow_mut().take() { let mut stdout = std::io::stdout().lock();
let _ = tx.send(token); if let Err(e) = writeln!(stdout, "{TOKEN_PREFIX}{token}").and_then(|()| stdout.flush()) {
tracing::error!("cannot hand the verification token back: {e}");
} }
if let Some(dlg) = dlg_weak.upgrade() { if let Some(app) = app.upgrade() {
dlg.close(); app.quit();
} }
}); });
// Covers the close button, Escape, and the user giving up.
dialog.connect_closed(move |_| {
token_tx.borrow_mut().take();
});
webview.load_uri(url); webview.load_uri(url);
window.present();
let parent = ui.login.login_button.root().and_downcast::<gtk4::Window>();
// Returns immediately; the login worker stays blocked on the channel until
// the handler above sends, or the dialog closes and drops the sender.
dialog.present(parent.as_ref());
} }
/// Pull the verification token out of a message posted by the verification page. /// Pull the verification token out of a message posted by the verification page.
@@ -133,7 +221,12 @@ fn extract_token(raw: &str) -> Option<String> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::extract_token; use super::{Verification, extract_token, find_token, outcome};
use std::os::unix::process::ExitStatusExt as _;
use std::process::ExitStatus;
/// The wait status of a process killed by SIGABRT.
const ABORTED: i32 = 6;
#[test] #[test]
fn a_completion_message_yields_its_token() { fn a_completion_message_yields_its_token() {
@@ -163,4 +256,39 @@ mod tests {
assert!(extract_token(raw).is_none(), "accepted: {raw}"); assert!(extract_token(raw).is_none(), "accepted: {raw}");
} }
} }
#[test]
fn the_token_is_found_among_other_output() {
let out = "Gtk-Message: something\nhv-token tok-1\nmore\n";
assert_eq!(find_token(out.as_bytes()).as_deref(), Some("tok-1"));
assert_eq!(find_token("only chatter\n".as_bytes()), None);
}
#[test]
fn a_window_closed_without_a_token_cancels() {
assert_eq!(
outcome(None, ExitStatus::from_raw(0)),
Verification::Cancelled
);
}
/// WebKit aborts when its sandbox cannot start (B195). That must read as a
/// failure the app explains, not as the user giving up.
#[test]
fn a_window_that_died_fails() {
assert_eq!(
outcome(None, ExitStatus::from_raw(ABORTED)),
Verification::Failed
);
let exited_1 = ExitStatus::from_raw(1 << 8);
assert_eq!(outcome(None, exited_1), Verification::Failed);
}
#[test]
fn a_token_counts_even_if_the_window_died_after_it() {
assert_eq!(
outcome(Some("tok-1".into()), ExitStatus::from_raw(ABORTED)),
Verification::Solved("tok-1".into())
);
}
} }
+4
View File
@@ -485,6 +485,10 @@ fn main() -> Result<()> {
return Ok(()); return Ok(());
} }
// The socket lives in the state dir, which nothing has made yet when the app
// starts the tray on a fresh install (B194).
dirs.ensure().context("create the app's directories")?;
// Ensure only one instance of the tray runs. // Ensure only one instance of the tray runs.
let tray_sock = dirs.tray_socket(); let tray_sock = dirs.tray_socket();
if std::os::unix::net::UnixStream::connect(&tray_sock).is_ok() { if std::os::unix::net::UnixStream::connect(&tray_sock).is_ok() {
+16 -5
View File
@@ -310,8 +310,10 @@ its changes go through the queue like My files.
A filesystem watcher (`notify`) and a remote poll every 120 s feed a debounced pass (2 s, at most A filesystem watcher (`notify`) and a remote poll every 120 s feed a debounced pass (2 s, at most
30 s). Passes are serialized per daemon. A pass: 30 s). Passes are serialized per daemon. A pass:
1. **Walks the local tree.** Any `readdir`, metadata or permission failure marks the scan 1. **Walks the local tree.** A path the user may not read, an entry that vanishes mid-scan and a
incomplete, and an incomplete scan cannot delete anything. name that is not UTF-8 are left out, and the pass drops everything under them, as it drops
ignored paths, so a left-out path is never read as deleted. An unreadable root and any other
I/O error fail the pass.
2. **Walks the remote tree.** 2. **Walks the remote tree.**
3. **Loads the baseline**, the `sync_entry` rows: both sides as of the last successful pass. 3. **Loads the baseline**, the `sync_entry` rows: both sides as of the last successful pass.
4. **Classifies each path** by comparing local, remote and baseline, with `(mtime, size)` as the 4. **Classifies each path** by comparing local, remote and baseline, with `(mtime, size)` as the
@@ -377,6 +379,12 @@ Restore lists the device's folders, proposes a local path from the profile, and
adds a synced folder bound to the remote uid. The download is an ordinary pass against an empty adds a synced folder bound to the remote uid. The download is an ordinary pass against an empty
baseline. The user side is in [RECOVERY.md](RECOVERY.md). baseline. The user side is in [RECOVERY.md](RECOVERY.md).
`Core::migrate_device` moves another device's top-level folders under this machine's device root.
Device roots are separate shares on the main volume, so each folder is one server-side move that
keeps its uid. A name already taken here gets the other device's name as a suffix. A synced folder
that already tracked one of them keeps its `remote_uid` and only has its `remote_share_id`
re-pointed. The moved folders are then offered by the ordinary restore.
--- ---
## 8. Control protocol ## 8. Control protocol
@@ -542,7 +550,7 @@ sequenceDiagram
actor User actor User
participant UI as pdfs-app participant UI as pdfs-app
participant Auth as pdfs-core auth participant Auth as pdfs-core auth
participant Web as WebKitGTK dialog participant Web as pdfs-app --human-verification
participant API as Proton API participant API as Proton API
User->>UI: email and password User->>UI: email and password
@@ -550,9 +558,9 @@ sequenceDiagram
Auth->>API: SRP handshake Auth->>API: SRP handshake
API-->>Auth: 9001, challenge URL API-->>Auth: 9001, challenge URL
Auth-->>UI: HumanVerificationRequired Auth-->>UI: HumanVerificationRequired
UI->>Web: load challenge UI->>Web: challenge URL (stdin)
User->>Web: solve it User->>Web: solve it
Web-->>UI: token (postMessage) Web-->>UI: token (stdout)
UI->>Auth: login_verified(token) UI->>Auth: login_verified(token)
Auth->>API: new SRP handshake with the token Auth->>API: new SRP handshake with the token
API-->>Auth: session API-->>Auth: session
@@ -561,6 +569,9 @@ sequenceDiagram
- The challenge used up the first SRP handshake, so the retry starts a new one with the token - The challenge used up the first SRP handshake, so the retry starts a new one with the token
attached. attached.
- The page may post the token as an object or as a JSON string; both are accepted. - The page may post the token as an object or as a JSON string; both are accepted.
- The page runs in a WebKitGTK view inside a child `pdfs-app`. WebKit aborts its process when its
sandbox cannot start, as on hardened systems such as secureblue (B195). In the child, that ends
the verification, and the app says why, instead of closing the app.
- The CLI has no web view. It fails with a message to sign in once with `pdfs-app`; the stored - The CLI has no web view. It fails with a message to sign in once with `pdfs-app`; the stored
session then works for the CLI and the service. session then works for the CLI and the service.
+153 -1
View File
@@ -47,7 +47,7 @@ original wording.
it in the index in the same change. it in the index in the same change.
```markdown ```markdown
### B194 — What the user sees, in one line ### B196 — What the user sees, in one line
**Status:** Open **Status:** Open
**Found:** 2026-10-06, how it was found **Found:** 2026-10-06, how it was found
@@ -100,6 +100,10 @@ a live account since.
| Entry | Title | | Entry | Title |
|---|---| |---|---|
| [B198](#b198--switching-to-online-only-deletes-local-files-that-never-reached-drive) | Switching to online-only deletes local files that never reached Drive |
| [B197](#b197--switching-to-online-only-trashes-synced-files-that-became-ignored) | Switching to online-only trashes synced files that became ignored |
| [B196](#b196--one-unreadable-folder-stops-the-whole-synced-folder-from-syncing) | One unreadable folder stops the whole synced folder from syncing |
| [B195](#b195--the-app-crashes-when-proton-asks-for-a-captcha-on-secureblue) | The app crashes when Proton asks for a CAPTCHA on secureblue |
| [B193](#b193--deleting-a-file-whose-create-failed-can-trash-a-file-of-the-users-on-drive) | Deleting a file whose create failed can trash a file of the user's on Drive | | [B193](#b193--deleting-a-file-whose-create-failed-can-trash-a-file-of-the-users-on-drive) | Deleting a file whose create failed can trash a file of the user's on Drive |
| [B191](#b191--a-file-deleted-after-its-create-failed-and-was-written-again-can-stay-on-drive) | A file deleted after its create failed and was written again can stay on Drive | | [B191](#b191--a-file-deleted-after-its-create-failed-and-was-written-again-can-stay-on-drive) | A file deleted after its create failed and was written again can stay on Drive |
| [B190](#b190--a-file-saved-just-before-the-daemon-stops-can-keep-its-old-content-on-drive) | A file saved just before the daemon stops can keep its old content on Drive | | [B190](#b190--a-file-saved-just-before-the-daemon-stops-can-keep-its-old-content-on-drive) | A file saved just before the daemon stops can keep its old content on Drive |
@@ -214,6 +218,7 @@ a live account since.
| Entry | Title | | Entry | Title |
|---|---| |---|---|
| [B194](#b194--on-a-fresh-install-the-tray-exits-and-the-takeout-page-cannot-remember-its-archives) | On a fresh install the tray exits and the Takeout page cannot remember its archives |
| [B192](#b192--a-new-file-closed-empty-reaches-drive-only-half-a-minute-later) | A new file closed empty reaches Drive only half a minute later | | [B192](#b192--a-new-file-closed-empty-reaches-drive-only-half-a-minute-later) | A new file closed empty reaches Drive only half a minute later |
| [B181](#b181--the-app-shows-a-queued-edit-kept-as-a-conflict-copy-in-english) | The app shows a queued edit kept as a conflict copy in English | | [B181](#b181--the-app-shows-a-queued-edit-kept-as-a-conflict-copy-in-english) | The app shows a queued edit kept as a conflict copy in English |
| [B180](#b180--a-stop-waits-10-s-for-a-drain-worker-that-slept-through-it) | A stop waits 10 s for a drain worker that slept through it | | [B180](#b180--a-stop-waits-10-s-for-a-drain-worker-that-slept-through-it) | A stop waits 10 s for a drain worker that slept through it |
@@ -296,6 +301,149 @@ a live account since.
Newest first. Newest first.
### B198 — Switching to online-only deletes local files that never reached Drive
**Status:** Fixed (unverified).
**Found:** 2026-10-08, reading the switch while fixing B196.
**Where:** `crates/pdfs-fuse/src/devices.rs`, `Core::apply_sync_folder_mode`;
`crates/pdfs-fuse/src/sync.rs`, `mirror_tree_unsynced`
**Repro:** Mirror a project folder with a `.git/` in it, then switch it to **Online only**.
`.git/` is gone from the disk and was never on Drive.
**Cause.** The switch evicts the whole local tree once the folder is `idle`. `idle` only says the
last pass applied everything it tried, and a pass never tries an ignored path (`.git/` is ignored
by default), a symlink, or a folder it cannot read (B196). It also defers a file that is open for
writing without leaving `idle`, and a file can change after the pass ends. The eviction deleted
all of those.
**Fix.** Before the mode is written and anything is evicted, the switch walks the folder against
the baseline, as a move out of a mirror folder already does, and refuses when something is only on
this disk. The message names the path; an ignored one is called excluded from sync. A tracked file
deleted here but not yet on Drive also refuses. An ignored one does not, since its baseline row
stays for good and would hold the switch forever.
**Test:** unit tests `a_fully_synced_mirror_folder_may_go_online_only`,
`an_ignored_folder_only_this_disk_has_holds_the_switch_back`,
`a_deleted_ignored_path_does_not_hold_the_switch_back`,
`an_edit_after_the_last_pass_holds_the_switch_back`, `a_new_file_at_the_root_holds_the_switch_back`,
`an_unreadable_folder_holds_the_switch_back` and `a_symlink_holds_the_switch_back`. The switch
itself has not been driven against a live account.
---
### B197 — Switching to online-only trashes synced files that became ignored
**Status:** Fixed (unverified).
**Found:** 2026-10-08, reading the push pass while fixing B196.
**Where:** `crates/pdfs-fuse/src/sync.rs`, `Core::push_pass`; `crates/pdfs-fuse/src/sync/planner.rs`,
`local_deletions`
**Repro:** Mirror a folder until `build/` is on Drive, add `build/` to `.pdfsignore`, then switch
the folder to **Online only**. `build/` goes to the Drive trash, and the switch then deletes the
local copy.
**Cause.** The push pass that runs before the switch read its deletions as "in the baseline, not in
the local walk". The walk skips ignored paths, so every synced path that later became ignored read
as deleted. A full pass does not do this: it drops ignored paths from classification, which is
what the user guide promises ("Ignoring never deletes anything").
**Fix.** The push pass reads its deletions from the same filtered baseline the wipe guard checks:
without ignored paths, and without paths the scan could not read (B196).
**Test:** unit tests `a_push_pass_never_reads_an_ignored_path_as_deleted` and
`local_deletions_come_out_shallowest_first`.
---
### B196 — One unreadable folder stops the whole synced folder from syncing
**Status:** Fixed (unverified). The reporter has not run the fix yet.
**Found:** 2026-10-08, [#33](https://github.com/narrrl/proton-drive-linux/issues/33). A synced
`~/Documents` held a restic cache a Docker container had created, which the user may not read.
Every pass failed with `Couldn't sync Documents: read …/restic/cache/…/snapshots: Permission
denied`, and nothing else in `Documents` synced.
**Where:** `crates/pdfs-fuse/src/sync.rs`, `walk_local_tree`; `crates/pdfs-fuse/src/sync/planner.rs`,
`Unscanned`
**Repro:** In a mirror folder, `sudo mkdir locked && sudo chmod 700 locked`, then sync.
**Cause.** B55 made every scan failure fatal to the pass, because a path left out of the scan read
as a local deletion and was trashed on Drive. That was safe, but a folder the user may not read is
not transient: the pass failed on every poll, for good. The same held for a file deleted between
the listing and its `stat`, which a busy folder hits now and then, and for a name that is not UTF-8.
**Fix.** The scan leaves such a path out and records it, and the pass drops everything under it
from classification, the wipe guard and the push pass's deletions, as it already did for ignored
paths. A left-out path is then neither deleted on Drive nor downloaded into. A folder that lists
but does not allow `stat` is left out whole. An unreadable or absent root, and every other I/O
error, still fails the pass. The activity feed names the unreadable paths once each time the set
changes, and the folder stays `idle`. Adding the folder to `.pdfsignore` was already a workaround
and still silences the report.
**Test:** unit tests `an_unreadable_subdirectory_is_left_out_and_the_rest_scans`,
`a_listable_but_unsearchable_folder_is_left_out_whole`,
`a_path_the_scan_left_out_is_never_a_local_deletion`,
`unscanned_covers_a_path_and_what_is_under_it_but_not_its_siblings` and
`a_non_utf8_name_is_left_out_and_reported`. `an_unreadable_root_fails_the_scan` and
`an_absent_root_fails_the_scan` still hold.
---
### B195 — The app crashes when Proton asks for a CAPTCHA on secureblue
**Status:** Fixed (unverified). Nobody has run the fix on secureblue yet.
**Found:** 2026-10-07, [#29](https://github.com/narrrl/proton-drive-linux/issues/29). On
secureblue 44 (a hardened Fedora Atomic), `pdfs-app` aborted a few seconds after the sign-in, with
`bwrap: Can't get type of source /run/systemd/journal/socket: Permission denied` and
`Failed to fully launch dbus-proxy`.
**Where:** `crates/pdfs-gui/src/app/pages/verify.rs`, `crates/pdfs-gui/src/app/pages/login.rs`
**Repro:** Sign in on secureblue from a network Proton asks a CAPTCHA for, such as a VPN.
**Cause.** The CAPTCHA page runs in a WebKitGTK view (B8). Before it loads anything, WebKit starts
its sandbox: bubblewrap, with the journal socket bound in, and `xdg-dbus-proxy`. secureblue keeps
bubblewrap from the socket, the proxy does not start, and WebKit ends its process with `g_error`.
The view was in the app's own process, so the whole app went down, and nothing in our code can
catch that.
**Fix.** The view runs in a child, `pdfs-app --human-verification`, that reads the URL on stdin
and prints the token on stdout. A child that exits without a token after a crash or a non-zero
status ends the sign-in with a message that says the page could not be opened. TROUBLESHOOTING.md
gives the cause and a one-time workaround. The sandbox is not switched off by the app: users choose
a hardened system for that isolation.
**Test:** unit tests `a_window_that_died_fails`, `a_window_closed_without_a_token_cancels`,
`a_token_counts_even_if_the_window_died_after_it` and `the_token_is_found_among_other_output`.
On 2026-10-08 the child ran under real WebKit on Arch against a local page that posts a completion,
and handed back its token.
---
### B194 — On a fresh install the tray exits and the Takeout page cannot remember its archives
**Status:** Fixed.
**Found:** 2026-10-07, in the log of [#29](https://github.com/narrrl/proton-drive-linux/issues/29):
`failed to bind tray single-instance socket: No such file or directory` and
`cannot remember staged Takeout archives: No such file or directory`.
**Where:** `crates/pdfs-gui/src/main.rs`, `build_window` in `crates/pdfs-gui/src/app/main.rs`
**Repro:** Start `pdfs-app` with no `~/.local/state/proton-drive-linux`, as on a fresh install. The
tray is gone, and staging a Takeout archive logs the warning.
**Cause.** Both write to the state directory, and only the daemon and the CLI made it
(`AppDirs::ensure`). The app starts the tray before the first sign-in, so before the daemon has
ever run.
**Fix.** `pdfs-tray` and `pdfs-app` call `AppDirs::ensure` at start, which also makes the
directories owner-only (B6).
**Verified:** 2026-10-08, `pdfs-tray` with `XDG_STATE_HOME`, `XDG_CONFIG_HOME` and
`XDG_CACHE_HOME` pointing at an empty directory. It exited with the error before the fix, and
afterwards ran with `tray.sock` in a `0700` state directory.
---
### B193 — Deleting a file whose create failed can trash a file of the user's on Drive ### B193 — Deleting a file whose create failed can trash a file of the user's on Drive
**Status:** Fixed (unverified). **Status:** Fixed (unverified).
@@ -4766,6 +4914,10 @@ subdirectory holding a file, asserting the error names the subtree),
strictness must not cost a working pass, so a healthy tree still reports every entry and still strictness must not cost a working pass, so a healthy tree still reports every entry and still
skips symlinks by design. skips symlinks by design.
B196 later relaxed this: a path the user may not read, an entry that vanishes mid-scan and a name
that is not UTF-8 are left out of the pass instead of failing it, and the pass drops everything
under them from classification, so the B55 guarantee holds without the wedge.
Not covered: transient I/O errors, and an entry that disappears between `read_dir` and `stat`. Not covered: transient I/O errors, and an entry that disappears between `read_dir` and `stat`.
Both are races that would need a filesystem shim to force; the code path they take is the same Both are races that would need a filesystem shim to force; the code path they take is the same
`symlink_metadata` failure the permission test drives. `symlink_metadata` failure the permission test drives.
+57
View File
@@ -11,6 +11,57 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
## [Unreleased] ## [Unreleased]
### Fixed
- **One unreadable folder stopped a synced folder from syncing at all.** A folder you may not
read, such as a cache a container created, failed every pass, so nothing else in the synced
folder reached Drive. Such a folder is now left out and named once in the activity feed, and
everything around it syncs. A file deleted during a scan, and a name that is not UTF-8, no
longer fail the pass either.
([#33](https://github.com/narrrl/proton-drive-linux/issues/33))
- **Switching a folder to online-only could delete files that were never on Drive.** Ignored
folders such as `.git/`, symlinks, unreadable folders and files changed after the last sync were
deleted with the local copy. The switch now refuses and names what is only on this computer.
- **Switching to online-only trashed synced files that had become ignored.** Adding a synced path
to `.pdfsignore` and then switching moved it to the Drive trash. Ignoring never deletes now, as
the user guide says.
### Added
- **Move another computer's backup to this one.** **Move to This Computer…** on the Computers page,
or `pdfs devices migrate UID`, moves every folder another computer backs up, for example an old
Windows PC, into this computer's backup on Proton Drive. Then it offers them for syncing here. The
folders move on Proton Drive itself, so nothing is uploaded again. The other computer stays in the
account, without those folders.
## [3.1.2] — 2026-10-08
Schema: **42**, unchanged.
### Fixed
- **The app crashed when Proton asked for a CAPTCHA on hardened systems.** On secureblue, WebKit
cannot start the sandbox for the verification page and ends its process, which took the app
down with it. The page now opens in a separate window process: if that process fails, the
sign-in stops with a message that says why, and TROUBLESHOOTING.md has a workaround.
([#29](https://github.com/narrrl/proton-drive-linux/issues/29))
- **On a fresh install the tray icon did not start.** The tray and the app now create their state
directory themselves instead of waiting for the service to do it. This also lets the Takeout
page remember staged archives before the first mount.
([#29](https://github.com/narrrl/proton-drive-linux/issues/29))
## [3.1.1] — 2026-10-07
Schema: **42**, unchanged.
### Changed
- **The client identifies itself with its release.** Sign-in and every API call used to send the
fixed `external-drive-linux@0.1.0-alpha` and `proton-drive-linux/0.1.0`. If Proton retired that
version, every release would fail to sign in at once and no update could fix it. The client now
sends `external-drive-linux@<version>-stable` and `proton-drive-linux/<version>`. `app_version`
and `user_agent` in `config.json` are optional overrides now. The fixed values that releases up
to 3.1.0 wrote there count as unset and are removed on the next save. A value you set by hand is
kept.
- The About dialog shows the release version, and its debug info shows the identity that is
actually sent.
## [3.1.0] — 2026-10-07 ## [3.1.0] — 2026-10-07
Schema: **42**, unchanged. Schema: **42**, unchanged.
@@ -1635,6 +1686,12 @@ First stable release: FUSE files-on-demand mount, sync daemon under `proton-driv
- The outstanding FUSE defects tracked in `docs/BUGS.md`, plus a truncate defect, validated - The outstanding FUSE defects tracked in `docs/BUGS.md`, plus a truncate defect, validated
by a new POSIX compliance suite for the filesystem. by a new POSIX compliance suite for the filesystem.
[3.1.2]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.1.2
[3.1.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.1.1
[3.1.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.1.0
[3.0.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.0.0 [3.0.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.0.0
[2.10.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.10.0 [2.10.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.10.0
+1
View File
@@ -103,6 +103,7 @@ commands are for scripts and for cases where the mount is not available.
| `pdfs devices rename UID NAME` | Rename a computer. | | `pdfs devices rename UID NAME` | Rename a computer. |
| `pdfs devices rm UID` | Remove a computer and its backup from the account. | | `pdfs devices rm UID` | Remove a computer and its backup from the account. |
| `pdfs devices adopt UID` | Make this machine continue another computer's backup, for example after a reinstall. `--clear` returns to matching by hostname. | | `pdfs devices adopt UID` | Make this machine continue another computer's backup, for example after a reinstall. `--clear` returns to matching by hostname. |
| `pdfs devices migrate UID [--yes]` | Move another computer's folders into this computer's backup, then restore them here like `pdfs sync restore`. The other computer stays registered, without those folders. `--yes` skips the confirmation and accepts every proposed path. See [RECOVERY.md](RECOVERY.md). |
## Trash ## Trash
+5 -1
View File
@@ -89,7 +89,11 @@ fix it.
| `open_with` | object | `xdg-open` | — | See [`open_with`](#open_with) | | `open_with` | object | `xdg-open` | — | See [`open_with`](#open_with) |
| `prompt` | object | built-in window | — | See [`prompt`](#prompt) | | `prompt` | object | built-in window | — | See [`prompt`](#prompt) |
`app_version` and `user_agent` identify the client to Proton. Leave them alone. `app_version` and `user_agent` identify the client to Proton. When the keys are absent, the
client sends its own release (`external-drive-linux@<version>-stable`, `proton-drive-linux/<version>`).
Leave them out unless a support case asks for a different value. The fixed
`external-drive-linux@0.1.0-alpha` and `proton-drive-linux/0.1.0` that releases up to 3.1.0 wrote
here are treated as absent and removed on the next save.
### `ignore_patterns` ### `ignore_patterns`
+23
View File
@@ -133,6 +133,29 @@ pdfs sync restore --device UID
The folders stay part of the other computer's backup and sync in both directions. This machine The folders stay part of the other computer's backup and sync in both directions. This machine
does not take over that computer's backup. does not take over that computer's backup.
### Moving another computer's backup here
To switch computers for good, for example from a Windows PC to this Linux machine, move the old
computer's folders into this computer's backup instead. Use **Move to This Computer…** on that
computer's row, or:
```bash
pdfs devices list # find the old computer's UID
pdfs devices migrate UID
```
Each folder moves on Proton Drive itself, so nothing is uploaded again and no storage is used
twice. A folder whose name this computer already uses gets the old computer's name added, for
example `Documents (DESKTOP-1)`. The restore picker then opens, as in
[Step 3](#step-3-restore-the-folders), so you choose where each folder goes here.
The old computer stays in your account, without those folders. Unlike
[adoption](#step-2-continue-the-old-computers-backup), this machine keeps its own name and backup.
> [!WARNING]
> Stop Proton Drive on the old computer first. A client still running there sees its folders
> disappear, and may upload them again as new.
## 3. How the restore works ## 3. How the restore works
This section is for the curious and for anyone debugging a restore. This section is for the curious and for anyone debugging a restore.
+14
View File
@@ -72,6 +72,20 @@ pdfs status
unfamiliar network. The CLI cannot show it. Sign in with `pdfs-app`, which displays the challenge; unfamiliar network. The CLI cannot show it. Sign in with `pdfs-app`, which displays the challenge;
the stored session then works for the CLI too. the stored session then works for the CLI too.
**"The verification page couldn't be opened"** — WebKit, which shows the CAPTCHA page, could not
start its sandbox. Hardened systems such as secureblue keep its bubblewrap from the journal socket,
and the log then shows `bwrap: Can't get type of source /run/systemd/journal/socket: Permission
denied` and `Failed to fully launch dbus-proxy`. Check that `bwrap` and `xdg-dbus-proxy` are
installed. If they are, sign in once with the sandbox switched off for this one run:
```bash
WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS=1 pdfs-app
```
Quit the app first, so this starts a new instance. The page then runs without WebKit's isolation,
so use this only for the sign-in. The stored session keeps working without it. Signing in from
your usual network, without a VPN, often avoids the CAPTCHA in the first place.
**Sign-in works but is forgotten after a restart** — no Secret Service provider is running, or its **Sign-in works but is forgotten after a restart** — no Secret Service provider is running, or its
keyring is locked. Install and unlock GNOME Keyring, KWallet or KeePassXC (with Secret Service keyring is locked. Install and unlock GNOME Keyring, KWallet or KeePassXC (with Secret Service
enabled). `pdfs diagnose` shows `keyring session` as failing in that case. enabled). `pdfs diagnose` shows `keyring session` as failing in that case.
+9 -1
View File
@@ -96,7 +96,9 @@ app and in the web app.
Each synced folder can also be switched to **Online only**. The local copy is replaced by a mount Each synced folder can also be switched to **Online only**. The local copy is replaced by a mount
of the folder, so the files stay visible but take no disk space until opened. Switch with the of the folder, so the files stay visible but take no disk space until opened. Switch with the
**Synced / Online only** switch on the folder's card, or `pdfs sync mode <id> ondemand` and back **Synced / Online only** switch on the folder's card, or `pdfs sync mode <id> ondemand` and back
with `pdfs sync mode <id> mirror`. `pdfs locations` lists every local place the service occupies. with `pdfs sync mode <id> mirror`. The switch waits until everything in the folder is on Proton
Drive, and refuses while the folder holds something that never goes there: an ignored path such
as `.git/`, a symlink, or a folder you may not read. The message names it. `pdfs locations` lists every local place the service occupies.
### Moving between synced folders and My files ### Moving between synced folders and My files
@@ -146,6 +148,9 @@ places, and both apply:
Rules are re-read on every pass. Ignoring never deletes anything: a file that becomes ignored is Rules are re-read on every pass. Ignoring never deletes anything: a file that becomes ignored is
simply no longer tracked, and its copy on Proton Drive stays. simply no longer tracked, and its copy on Proton Drive stays.
A folder you may not read, such as a cache a container created as root, is skipped the same way,
and the activity feed names it once. Add it to `.pdfsignore` to stop that message.
## Changes waiting for Proton Drive ## Changes waiting for Proton Drive
A change you made in the Proton Drive folder exists only on this computer until it reaches A change you made in the Proton Drive folder exists only on this computer until it reaches
@@ -278,6 +283,9 @@ Proton Drive keeps earlier versions of every file. **Details → Versions…** i
**Restore to This Computer…** downloads its folders here. **Restore to This Computer…** downloads its folders here.
- **Continue This Backup Here…** makes this computer take over another computer's backup, for - **Continue This Backup Here…** makes this computer take over another computer's backup, for
example after reinstalling or replacing a machine. See [RECOVERY.md](RECOVERY.md). example after reinstalling or replacing a machine. See [RECOVERY.md](RECOVERY.md).
- **Move to This Computer…** moves another computer's folders into this computer's backup, for
example when you switch from Windows to Linux, and then offers them for syncing here. The other
computer stays in the list, without those folders.
## Search launcher ## Search launcher
+1 -1
View File
@@ -9,7 +9,7 @@
# Build + install: cd packaging && makepkg -fi # Build + install: cd packaging && makepkg -fi
pkgname=proton-drive-linux pkgname=proton-drive-linux
pkgver=3.1.0 pkgver=3.1.2
pkgrel=1 pkgrel=1
pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray" pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray"
arch=('x86_64') arch=('x86_64')
@@ -1,6 +1,6 @@
pkgbase = proton-drive-for-linux-bin pkgbase = proton-drive-for-linux-bin
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries) pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)
pkgver = 3.0.0 pkgver = 3.1.2
pkgrel = 1 pkgrel = 1
url = https://proton-drive.narl.io url = https://proton-drive.narl.io
arch = x86_64 arch = x86_64
@@ -21,21 +21,21 @@ pkgbase = proton-drive-for-linux-bin
optdepends = xdg-utils: "Open folder" action in the tray menu optdepends = xdg-utils: "Open folder" action in the tray menu
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf) optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
provides = proton-drive-for-linux=3.0.0 provides = proton-drive-for-linux=3.1.2
provides = proton-drive-linux=3.0.0 provides = proton-drive-linux=3.1.2
provides = pdfs provides = pdfs
conflicts = proton-drive-linux conflicts = proton-drive-linux
conflicts = proton-drive-for-linux conflicts = proton-drive-for-linux
conflicts = proton-drive-for-linux-git conflicts = proton-drive-for-linux-git
options = !strip options = !strip
options = !debug options = !debug
source = proton-drive-linux-3.0.0-x86_64.tar.gz::https://github.com/narrrl/proton-drive-linux/releases/download/v3.0.0/proton-drive-linux-3.0.0-x86_64.tar.gz source = proton-drive-linux-3.1.2-x86_64.tar.gz::https://github.com/narrrl/proton-drive-linux/releases/download/v3.1.2/proton-drive-linux-3.1.2-x86_64.tar.gz
source = io.narl.proton-drive-linux-3.0.0.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.0.0/packaging/io.narl.proton-drive-linux.desktop source = io.narl.proton-drive-linux-3.1.2.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.1.2/packaging/io.narl.proton-drive-linux.desktop
source = io.narl.proton-drive-linux-tray-3.0.0.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.0.0/packaging/io.narl.proton-drive-linux-tray.desktop source = io.narl.proton-drive-linux-tray-3.1.2.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.1.2/packaging/io.narl.proton-drive-linux-tray.desktop
source = io.narl.proton-drive-linux-3.0.0.svg::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.0.0/packaging/io.narl.proton-drive-linux.svg source = io.narl.proton-drive-linux-3.1.2.svg::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.1.2/packaging/io.narl.proton-drive-linux.svg
source = proton-drive-3.0.0.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.0.0/packaging/proton-drive.service source = proton-drive-3.1.2.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.1.2/packaging/proton-drive.service
source = LICENSE-3.0.0::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.0.0/LICENSE source = LICENSE-3.1.2::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.1.2/LICENSE
sha256sums = 4d72420f287e0a9b1d65dfc2954779b2df1bf367cb41ffe28519b2791451b6ed sha256sums = 71fc50775cbd795717ed17c4a3994d8fdfb5ab30a223dcbc7f0e313f9ab615f7
sha256sums = d190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123 sha256sums = d190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123
sha256sums = 1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0 sha256sums = 1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0
sha256sums = e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828 sha256sums = e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828
@@ -1,7 +1,7 @@
# Maintainer: Nils Pukropp <contact@narl.io> # Maintainer: Nils Pukropp <contact@narl.io>
pkgname=proton-drive-for-linux-bin pkgname=proton-drive-for-linux-bin
pkgver=3.0.0 pkgver=3.1.2
pkgrel=1 pkgrel=1
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)" pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)"
arch=('x86_64') arch=('x86_64')
@@ -34,7 +34,7 @@ source=("proton-drive-linux-$pkgver-x86_64.tar.gz::$_repo/releases/download/$_ta
"io.narl.proton-drive-linux-$pkgver.svg::$_raw/packaging/io.narl.proton-drive-linux.svg" "io.narl.proton-drive-linux-$pkgver.svg::$_raw/packaging/io.narl.proton-drive-linux.svg"
"proton-drive-$pkgver.service::$_raw/packaging/proton-drive.service" "proton-drive-$pkgver.service::$_raw/packaging/proton-drive.service"
"LICENSE-$pkgver::$_raw/LICENSE") "LICENSE-$pkgver::$_raw/LICENSE")
sha256sums=('4d72420f287e0a9b1d65dfc2954779b2df1bf367cb41ffe28519b2791451b6ed' sha256sums=('71fc50775cbd795717ed17c4a3994d8fdfb5ab30a223dcbc7f0e313f9ab615f7'
'd190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123' 'd190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123'
'1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0' '1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0'
'e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828' 'e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828'
@@ -1,6 +1,6 @@
pkgbase = proton-drive-for-linux-git pkgbase = proton-drive-for-linux-git
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git) pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)
pkgver = 3.0.0.r0.g2177383 pkgver = 3.1.2.r0.g07c9b67
pkgrel = 1 pkgrel = 1
url = https://proton-drive.narl.io url = https://proton-drive.narl.io
arch = x86_64 arch = x86_64
@@ -24,8 +24,8 @@ pkgbase = proton-drive-for-linux-git
optdepends = xdg-utils: "Open folder" action in the tray menu optdepends = xdg-utils: "Open folder" action in the tray menu
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf) optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
provides = proton-drive-for-linux=3.0.0.r0.g2177383 provides = proton-drive-for-linux=3.1.2.r0.g07c9b67
provides = proton-drive-linux=3.0.0.r0.g2177383 provides = proton-drive-linux=3.1.2.r0.g07c9b67
provides = pdfs provides = pdfs
conflicts = proton-drive-linux conflicts = proton-drive-linux
conflicts = proton-drive-for-linux conflicts = proton-drive-for-linux
@@ -1,7 +1,7 @@
# Maintainer: Nils Pukropp <contact@narl.io> # Maintainer: Nils Pukropp <contact@narl.io>
pkgname=proton-drive-for-linux-git pkgname=proton-drive-for-linux-git
pkgver=3.0.0.r0.g2177383 pkgver=3.1.2.r0.g07c9b67
pkgrel=1 pkgrel=1
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)" pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)"
arch=('x86_64') arch=('x86_64')
@@ -1,6 +1,6 @@
pkgbase = proton-drive-for-linux pkgbase = proton-drive-for-linux
pkgdesc = Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray pkgdesc = Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray
pkgver = 3.0.0 pkgver = 3.1.2
pkgrel = 1 pkgrel = 1
url = https://proton-drive.narl.io url = https://proton-drive.narl.io
arch = x86_64 arch = x86_64
@@ -23,13 +23,13 @@ pkgbase = proton-drive-for-linux
optdepends = xdg-utils: "Open folder" action in the tray menu optdepends = xdg-utils: "Open folder" action in the tray menu
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf) optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
provides = proton-drive-linux=3.0.0 provides = proton-drive-linux=3.1.2
provides = pdfs provides = pdfs
conflicts = proton-drive-linux conflicts = proton-drive-linux
conflicts = proton-drive-for-linux-bin conflicts = proton-drive-for-linux-bin
conflicts = proton-drive-for-linux-git conflicts = proton-drive-for-linux-git
options = !lto options = !lto
source = proton-drive-for-linux-3.0.0.tar.gz::https://github.com/narrrl/proton-drive-linux/archive/refs/tags/v3.0.0.tar.gz source = proton-drive-for-linux-3.1.2.tar.gz::https://github.com/narrrl/proton-drive-linux/archive/refs/tags/v3.1.2.tar.gz
sha256sums = 3a9ca82adae6b03e2b33af520ec49f702a3b4c83151ccc70ae3c21645b719ab5 sha256sums = 260ed05c8b041cf38c5904f7543780de0d0cb06d9695a427a98250d2a9ec0eb1
pkgname = proton-drive-for-linux pkgname = proton-drive-for-linux
@@ -1,7 +1,7 @@
# Maintainer: Nils Pukropp <contact@narl.io> # Maintainer: Nils Pukropp <contact@narl.io>
pkgname=proton-drive-for-linux pkgname=proton-drive-for-linux
pkgver=3.0.0 pkgver=3.1.2
pkgrel=1 pkgrel=1
pkgdesc="Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray" pkgdesc="Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray"
arch=('x86_64') arch=('x86_64')
@@ -24,7 +24,7 @@ conflicts=('proton-drive-linux' 'proton-drive-for-linux-bin' 'proton-drive-for-l
# LTO has broken GTK/Rust links for this workspace; the release profile keeps it off. # LTO has broken GTK/Rust links for this workspace; the release profile keeps it off.
options=('!lto') options=('!lto')
source=("$pkgname-$pkgver.tar.gz::$_repo/archive/refs/tags/v$pkgver.tar.gz") source=("$pkgname-$pkgver.tar.gz::$_repo/archive/refs/tags/v$pkgver.tar.gz")
sha256sums=('3a9ca82adae6b03e2b33af520ec49f702a3b4c83151ccc70ae3c21645b719ab5') sha256sums=('260ed05c8b041cf38c5904f7543780de0d0cb06d9695a427a98250d2a9ec0eb1')
_srcname="proton-drive-linux-$pkgver" _srcname="proton-drive-linux-$pkgver"
+240 -184
View File
File diff suppressed because it is too large. Load diff
+220 -171
View File
File diff suppressed because it is too large. Load diff
+226 -172
View File
File diff suppressed because it is too large. Load diff
+244 -186
View File
File diff suppressed because it is too large. Load diff
+245 -186
View File
File diff suppressed because it is too large. Load diff
+239 -184
View File
File diff suppressed because it is too large. Load diff
+208 -170
View File
File diff suppressed because it is too large. Load diff
+240 -184
View File
File diff suppressed because it is too large. Load diff
+239 -184
View File
File diff suppressed because it is too large. Load diff