//! `pdfs` — command-line front-end for the Proton Drive Linux client. use std::collections::HashMap; use std::fs::OpenOptions; use std::io::Write; use std::os::unix::fs::FileTypeExt; use std::path::{Path, PathBuf}; use std::sync::Arc; use anyhow::{Context, Result, anyhow, bail}; use clap::{Parser, Subcommand}; use pdfs_core::auth; use pdfs_core::cache::ContentCache; use pdfs_core::config::AppDirs; use pdfs_core::control::{ ErrorKind, MountKind, RefreshScope, Request as CtlRequest, Response as CtlResponse, RestoreItem as CtlRestoreItem, ShareEntryKind, SyncPhase, pending_summary, }; use pdfs_core::db::Db; use pdfs_core::service; #[derive(Parser)] #[command( name = "pdfs", version, about = "Proton Drive for Linux (Files On-Demand)" )] struct Cli { /// Emit machine-readable JSON instead of formatted text. /// /// Applies to the query commands (`status`, `ls`, `pins`, `sync list`, /// `devices list`, `locations`, `transfers`, `activity`, `cache inspect`). Commands that /// perform an action keep their human output — a script that needs to know /// whether one succeeded has the exit code. #[arg(long, global = true)] json: bool, #[command(subcommand)] command: Command, } /// Whether `--json` was passed. A global rather than a threaded parameter /// because it is read at the leaves — one line per query command — and /// threading a display flag through every command signature would cost more /// than it explains. static JSON_OUTPUT: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); fn json_enabled() -> bool { JSON_OUTPUT.load(std::sync::atomic::Ordering::Relaxed) } /// The daemon's reply as JSON, with the response-variant tag stripped. /// /// Serializing [`CtlResponse`] directly yields serde's externally tagged form, /// `{"SyncFolders": {"items": […]}}`, which makes every script write /// `jq '.SyncFolders.items[]'` and couples it to the name of an internal enum /// variant. The payload is what the caller asked for, so that is what is /// emitted: `{"items": […]}`. /// /// The reply is still serialized from the daemon's own type rather than /// rebuilt by hand, so new fields appear in the JSON without anyone /// remembering to add them here. fn response_payload(response: &CtlResponse) -> Result { let value = serde_json::to_value(response)?; // Every variant serializes as a one-key object keyed by the variant name; // anything else is left alone rather than guessed at. if let serde_json::Value::Object(map) = &value && map.len() == 1 && let Some((_, payload)) = map.iter().next() { return Ok(payload.clone()); } Ok(value) } /// Print the daemon's reply as JSON if `--json` is in effect, reporting whether /// it did so — a `true` means the caller should skip its human formatting. /// /// A daemon-side error still prints its payload, so a script can read the /// `kind`, but then fails the command. Emitting the error and exiting `0` would /// make every `--json` caller inspect the body to find out whether it worked, /// when the exit code is the thing they already check. fn emit_json(response: &CtlResponse) -> Result { if !json_enabled() { return Ok(false); } println!( "{}", serde_json::to_string_pretty(&response_payload(response)?)? ); if let CtlResponse::Error { message, kind } = response { bail!("{}", cli_error(*kind, message)); } Ok(true) } #[derive(Subcommand)] enum Command { /// Log in to a Proton account (SRP + optional 2FA) and store the session. Login { /// Account email; prompted if omitted. #[arg(short, long)] username: Option, }, /// Forget the stored session. Logout, /// Show account login state, and mount status if a daemon is running. Status, /// List every local Proton Drive location. Locations, /// Mount Proton Drive at the given (or default) path. Blocks until unmounted. Mount { /// Mountpoint; defaults to ~/ProtonDrive. mountpoint: Option, }, /// Run the auto-mount daemon: wait for login, mount, and keep it mounted /// until stopped. This is what the systemd user service runs; you normally /// don't invoke it by hand. Daemon { /// Mountpoint; defaults to ~/ProtonDrive. mountpoint: Option, }, /// Keep a file on this device (download + cache it). Needs a running mount. Pin { /// File path, inside the mountpoint or relative to it. path: PathBuf, }, /// Stop keeping a file on this device, evicting its cached content. Unpin { /// File path, inside the mountpoint or relative to it. path: PathBuf, }, /// List files pinned to this device. Pins, /// List a directory via the running daemon (in-app browser backend). Ls { /// Directory path, inside the mountpoint or relative to it. Root if omitted. path: Option, }, /// List the photos timeline (newest first) via the running daemon. Photos { /// Max photos to list. #[arg(long, default_value_t = 50)] limit: usize, /// Skip this many photos from the start of the timeline. #[arg(long, default_value_t = 0)] offset: usize, /// List only favourited photos. #[arg(long)] favorites: bool, }, /// Add or remove a photo's favourite mark. Favorite { /// Photo node uid in `volume~link` form (from `pdfs photos`). uid: String, /// Remove the mark instead of adding it. #[arg(long)] remove: bool, }, /// List the photo albums (including ones shared with this account). Albums, /// Import a Google Photos Takeout export into Proton Photos. /// /// Pass every zip of the export at once — Google splits one export across /// numbered parts, and a photo's metadata often sits in a different part /// than the photo. Albums in the export are recreated as Proton albums, and /// photos already on the account (same name and same content) are skipped, /// so an interrupted import can simply be run again. ImportGooglePhotos { /// The export's `.zip` files (e.g. `takeout-*.zip`). #[arg(required = true)] archives: Vec, /// Report what would be imported without uploading anything. #[arg(long)] dry_run: bool, /// Stay attached and print progress until the import finishes. #[arg(long)] wait: bool, }, /// Fix photos filed under the day they were imported instead of the day /// they were taken. /// /// A Takeout whose metadata sidecars did not match imports with no capture /// time, and Proton then stamps every photo with the moment it arrived. The /// date is still in the file name (`IMG-20230219-WA0001.jpg`), so this finds /// photos whose name disagrees with their capture time by more than a day /// and rewrites them at the right time. /// /// Proton cannot edit a sealed capture time, so a rewrite means re-uploading /// the photo and trashing the original — favourites and albums are carried /// across, and the original goes to Proton's trash, not away. Run it with /// `--dry-run` first. RedatePhotos { /// Report what would be re-dated without uploading or trashing anything. #[arg(long)] dry_run: bool, /// Stop after this many photos. #[arg(long)] limit: Option, /// Only touch photos whose current capture time is on or after this /// date (`YYYY-MM-DD`, UTC) — aim the repair at the days an import /// landed on. #[arg(long)] from: Option, /// Only touch photos whose current capture time is before this date /// (`YYYY-MM-DD`, UTC). #[arg(long)] to: Option, /// Stay attached and print the report when the run finishes. #[arg(long)] wait: bool, }, /// Show how the running (or last) photo re-date is doing. RedateStatus, /// Stop the running photo re-date. CancelRedate, /// Show how the running (or last) Google Photos import is doing. ImportStatus, /// Stop the running Google Photos import. CancelImport, /// List the photos in one album (newest capture first). Album { /// Album node uid in `volume~link` form (from `pdfs albums`). uid: String, /// Max photos to list. #[arg(long, default_value_t = 50)] limit: usize, /// Skip this many photos from the start of the album. #[arg(long, default_value_t = 0)] offset: usize, }, /// Download a photo by uid (`volume~link`) and print its cached path. OpenPhoto { /// Photo node uid in `volume~link` form (from `pdfs photos`). uid: String, }, /// Search file/folder names via the running daemon's local index. Search { /// Query string (substring match). query: String, /// Max hits to return. #[arg(long, default_value_t = 50)] limit: usize, }, /// Rename a file or folder via the running daemon. Rename { /// File/folder path, inside the mountpoint or relative to it. path: PathBuf, /// New name (a single path component). new_name: String, }, /// Move a file or folder into another folder via the running daemon. Move { /// File/folder path, inside the mountpoint or relative to it. path: PathBuf, /// Destination folder path, inside the mountpoint or relative to it. new_parent: PathBuf, }, /// Trash a file or folder via the running daemon. Rm { /// File/folder path, inside the mountpoint or relative to it. path: PathBuf, }, /// Create a folder via the running daemon. Mkdir { /// Parent folder path, inside the mountpoint or relative to it. parent: PathBuf, /// New folder name (a single path component). name: String, }, /// Upload local files and/or folders into a Drive folder via the running /// daemon. Folders are uploaded recursively. The daemon uploads in the /// background; watch progress with `pdfs transfers`. Upload { /// Local files and/or folders to upload. #[arg(required = true)] sources: Vec, /// Destination folder, inside the mountpoint or relative to it. #[arg(short = 't', long = "to", default_value = ".")] parent: PathBuf, }, /// Show the daemon's in-flight transfers (active uploads/downloads). Transfers, /// Show what the daemon is doing right now: worker threads, queue depths, /// in-flight control requests and memory. For a daemon that has stopped /// answering rather than one that is merely busy. Diagnostics, /// List what is in the account's trash, with the uids the restore and /// delete-forever commands take. Trash, /// Restore trashed items, by uid, to the folders they were trashed from. Restore { /// Node uids (`volume~link`), as printed by `pdfs trash`. #[arg(required = true)] uids: Vec, }, /// Permanently delete trashed items by uid. This cannot be undone. DeleteForever { /// Node uids (`volume~link`), as printed by `pdfs trash`. #[arg(required = true)] uids: Vec, }, /// Permanently delete everything in the trash. This cannot be undone. EmptyTrash, /// Drop a cached listing so the next read re-fetches it from the server. /// Use it when another client changed something and the daemon hasn't /// noticed yet. Refresh { /// What to refresh: a folder path (inside the mountpoint or relative to /// it), `trash`, or `photos`. The mount root if omitted. target: Option, /// With `photos`: read every photo's metadata again instead of only what /// changed. Minutes on a large library; for a gallery that disagrees with /// the account. #[arg(long)] full: bool, }, /// Manage the account's registered devices. Devices { #[command(subcommand)] action: DeviceCmd, }, /// Sync local folders to this machine's Proton Drive device. Sync { #[command(subcommand)] action: SyncCmd, }, /// Share a file or folder with Proton and/or external email addresses. Share { /// File/folder path, inside the mountpoint or relative to it. path: PathBuf, /// One or more invitee emails (Proton or external, auto-detected). #[arg(required = true)] emails: Vec, /// Role to grant: viewer, editor or admin. #[arg(long, default_value = "viewer")] role: String, /// Optional message included in the invitation email. #[arg(long)] message: Option, }, /// List a node's members, pending invitations and public link. Members { /// File/folder path, inside the mountpoint or relative to it. path: PathBuf, }, /// Change the role of a member or pending invitation on a node's share. ShareRole { /// File/folder path, inside the mountpoint or relative to it. path: PathBuf, /// Entry id (membership or invitation id), from `pdfs members`. id: String, /// Entry kind: member, proton or external. kind: ShareKindArg, /// New role: viewer, editor or admin. role: String, }, /// Remove a member or pending invitation from a node's share. Unshare { /// File/folder path, inside the mountpoint or relative to it. path: PathBuf, /// Entry id (membership or invitation id), from `pdfs members`. id: String, /// Entry kind: member, proton or external. kind: ShareKindArg, }, /// Inspect and restore a file's earlier versions. Versions { #[command(subcommand)] action: VersionCmd, }, /// Manage a node's public link. PublicLink { #[command(subcommand)] action: PublicLinkCmd, }, /// List the items I have shared with others (members, invites or a link). Shared, /// List nodes shared with me that I have accepted, or the children of one. SharedWithMe { /// Folder uid to list into, in `volume~link` form (from a previous run). /// Omit for the top level. uid: Option, }, /// Download a file shared with me by its uid (from `pdfs shared-with-me`). SharedGet { /// Node uid in `volume~link` form. uid: String, /// Where to write it. Omit to leave it in the cache and print the path. dest: Option, }, /// Leave a shared node by its uid (from `pdfs shared-with-me`). Leave { /// Node uid in `volume~link` form. uid: String, }, /// Manage invitations addressed to me. Invitations { #[command(subcommand)] action: InvitationCmd, }, /// Manage saved public-link bookmarks. Bookmarks { #[command(subcommand)] action: BookmarkCmd, }, /// Show the daemon's recent activity, newest first. Activity { /// Maximum entries to show. #[arg(short, long, default_value_t = 50)] limit: usize, }, /// Show account storage usage (used of total, across all Proton products). Quota, /// Inspect and maintain the local metadata database and content cache. Cache { #[command(subcommand)] action: CacheCmd, }, /// Check this installation for problems and print a report. /// /// Runs without a daemon on purpose: the state worth diagnosing is usually /// the state where the daemon will not start. Diagnose, } #[derive(Subcommand)] enum CacheCmd { /// Report database size, row counts, and cache usage. Inspect { /// Also run SQLite's integrity check. Reads every page, so it is slow /// on a large database — worth it when you suspect corruption. #[arg(long)] deep: bool, }, /// Checkpoint the write-ahead log and compact the database. /// /// Takes a write lock for the duration and needs room for a second copy of /// the database while it runs. Vacuum, /// Delete cached file content, keeping pinned files. Clear, } /// Which collection a share entry lives in, for `share-role` / `unshare`. #[derive(Clone, Copy, clap::ValueEnum)] enum ShareKindArg { /// An accepted member. Member, /// A pending invitation to a Proton user. Proton, /// A pending invitation to a non-Proton email. External, } impl ShareKindArg { fn to_kind(self) -> ShareEntryKind { match self { ShareKindArg::Member => ShareEntryKind::Member, ShareKindArg::Proton => ShareEntryKind::ProtonInvite, ShareKindArg::External => ShareEntryKind::ExternalInvite, } } } /// `pdfs versions …` — a file's revision history. #[derive(Subcommand)] enum VersionCmd { /// List a file's versions, newest first. List { /// File path, inside the mountpoint or relative to it. path: PathBuf, }, /// Make an earlier version the file's current content. /// /// The server applies this in the background, so the new content may take a /// moment to show up in the mount. Restore { /// File path, inside the mountpoint or relative to it. path: PathBuf, /// Version id, from `pdfs versions list`. id: String, }, /// Write an earlier version to a local file, leaving the file untouched. Save { /// File path, inside the mountpoint or relative to it. path: PathBuf, /// Version id, from `pdfs versions list`. id: String, /// Where to write it. Must not already exist. dest: PathBuf, }, /// Permanently delete an earlier version. The content is unrecoverable, and /// the version that is currently active cannot be deleted. Rm { /// File path, inside the mountpoint or relative to it. path: PathBuf, /// Version id, from `pdfs versions list`. id: String, }, } #[derive(Subcommand)] enum DeviceCmd { /// List registered devices. List, /// Rename a device by uid. Rename { uid: String, name: String }, /// Delete (deregister) a device by uid. Rm { uid: String }, /// Adopt an existing device as this machine's, so a hostname change or a /// reinstall re-attaches to it instead of registering a duplicate. Adopt { /// Device uid (from `devices list`). Omit with `--clear`. uid: Option, /// Drop the adoption and go back to matching by hostname. #[arg(long, conflicts_with = "uid")] clear: bool, }, } #[derive(Subcommand)] enum SyncCmd { /// Add a local folder to this machine's device and upload its contents. Add { /// Local directory to sync. path: PathBuf, }, /// List this machine's synced folders. List, /// Remove a synced folder by id. Rm { /// Synced-folder id (from `sync list`). id: i64, /// Also delete the folder's copy in Proton Drive. #[arg(long)] delete_remote: bool, }, /// Force a sync pass now (all folders, or one by id). Now { /// Optional synced-folder id; omit to reconcile every folder. id: Option, }, /// Pause syncing: queued uploads and mirror folders wait, reads keep working. Pause { /// End the pause by itself after this long, e.g. `30m`, `1h`, `2d`. /// Omit to pause until `pdfs sync resume`. #[arg(long = "for", value_parser = parse_pause_duration)] duration: Option, }, /// Resume syncing after `pdfs sync pause`. Resume, /// List uploads and changes not yet on Proton Drive. Queue, /// Retry a queued operation now (by id from `sync queue`), or every failed one. Retry { /// Queue id; omit to retry every failed operation. id: Option, }, /// Switch a folder between full-copy sync and on-demand (FUSE, no local storage). Mode { /// Synced-folder id (from `sync list`). id: i64, /// `mirror` (full local copy) or `ondemand` (FUSE mount, reclaims disk). #[arg(value_parser = ["mirror", "ondemand"])] mode: String, }, /// Re-attach this machine's device folders to local directories, then sync /// them down. Use after adopting a device on a new machine. Restore { /// Accept every proposed local path without asking. #[arg(long)] yes: bool, }, } #[derive(Subcommand)] enum PublicLinkCmd { /// Create a public link on a node. Create { /// File/folder path, inside the mountpoint or relative to it. path: PathBuf, /// Role: viewer or editor. #[arg(long, default_value = "viewer")] role: String, /// Optional custom password protecting the link. #[arg(long)] password: Option, /// Optional expiry, Unix epoch seconds. #[arg(long)] expires: Option, }, /// Remove a node's public link. Remove { /// File/folder path, inside the mountpoint or relative to it. path: PathBuf, /// Public-link id, from `pdfs members`. id: String, }, } #[derive(Subcommand)] enum InvitationCmd { /// List invitations addressed to me. List, /// Accept an invitation by id. Accept { id: String }, /// Reject an invitation by id. Reject { id: String }, } #[derive(Subcommand)] enum BookmarkCmd { /// List saved public-link bookmarks. List, /// Save a public link (optionally password-protected) as a bookmark. Add { /// Public link URL, including the `#password` fragment. url: String, /// Optional custom password for the link. #[arg(long)] password: Option, }, /// Remove a saved bookmark by its token. Rm { token: String }, } /// Render a daemon failure for a terminal. /// /// Offline is the one class worth rewording: the daemon's prose names whichever /// call happened to hit the network, which tells someone at a shell nothing they /// can act on. Every other class already says something specific, so it is /// printed as sent. fn cli_error(kind: ErrorKind, message: &str) -> String { match kind { ErrorKind::Offline => "offline: the Proton Drive API is unreachable".to_string(), // The daemon's wording names the failed call ("upload x: ..."), which // does not say the thing the user has to act on. ErrorKind::Quota => format!("out of storage: {message}"), _ => message.to_string(), } } fn main() -> Result<()> { tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() // `pgp` logs a benign WARN when it re-serializes a Proton secret-key // packet with a longer (but equivalent) length encoding than the // original; drop it to error so normal runs stay quiet. .unwrap_or_else(|_| "info,pgp=error".into()), ) .init(); let cli = Cli::parse(); JSON_OUTPUT.store(cli.json, std::sync::atomic::Ordering::Relaxed); match cli.command { Command::Login { username } => cmd_login(username), Command::Logout => cmd_logout(), Command::Status => cmd_status(), Command::Locations => cmd_locations(), Command::Mount { mountpoint } => cmd_mount(mountpoint), Command::Daemon { mountpoint } => cmd_daemon(mountpoint), Command::Pin { path } => cmd_pin(path), Command::Unpin { path } => cmd_unpin(path), Command::Pins => cmd_pins(), Command::Ls { path } => cmd_ls(path), Command::Photos { limit, offset, favorites, } => cmd_photos(limit, offset, favorites), Command::Favorite { uid, remove } => cmd_favorite(uid, remove), Command::Albums => cmd_albums(), Command::ImportGooglePhotos { archives, dry_run, wait, } => cmd_import_google_photos(archives, dry_run, wait), Command::RedatePhotos { dry_run, limit, from, to, wait, } => cmd_redate_photos(dry_run, limit, from, to, wait), Command::RedateStatus => cmd_redate_status(), Command::CancelRedate => cmd_cancel_redate(), Command::ImportStatus => cmd_import_status(), Command::CancelImport => cmd_cancel_import(), Command::Album { uid, limit, offset } => cmd_album(uid, limit, offset), Command::OpenPhoto { uid } => cmd_open_photo(uid), Command::Search { query, limit } => cmd_search(query, limit), Command::Rename { path, new_name } => cmd_rename(path, new_name), Command::Move { path, new_parent } => cmd_move(path, new_parent), Command::Rm { path } => cmd_rm(path), Command::Mkdir { parent, name } => cmd_mkdir(parent, name), Command::Upload { sources, parent } => cmd_upload(sources, parent), Command::Transfers => cmd_transfers(), Command::Diagnostics => cmd_diagnostics(), Command::Trash => cmd_trash(), Command::Restore { uids } => cmd_restore(uids), Command::DeleteForever { uids } => cmd_delete_forever(uids), Command::EmptyTrash => cmd_empty_trash(), Command::Refresh { target, full } => cmd_refresh(target, full), Command::Devices { action } => cmd_devices(action), Command::Sync { action } => cmd_sync(action), Command::Share { path, emails, role, message, } => cmd_share(path, emails, role, message), Command::Members { path } => cmd_members(path), Command::ShareRole { path, id, kind, role, } => cmd_share_role(path, id, kind, role), Command::Unshare { path, id, kind } => cmd_unshare(path, id, kind), Command::Versions { action } => cmd_versions(action), Command::PublicLink { action } => cmd_public_link(action), Command::Shared => cmd_shared(), Command::SharedWithMe { uid } => cmd_shared_with_me(uid), Command::SharedGet { uid, dest } => cmd_shared_get(uid, dest), Command::Leave { uid } => cmd_leave(uid), Command::Invitations { action } => cmd_invitations(action), Command::Bookmarks { action } => cmd_bookmarks(action), Command::Activity { limit } => cmd_activity(limit), Command::Quota => cmd_quota(), Command::Cache { action } => cmd_cache(action), Command::Diagnose => cmd_diagnose(), } } fn cmd_devices(action: DeviceCmd) -> Result<()> { match action { DeviceCmd::List => { let response = control_request(CtlRequest::ListDevices)?; if emit_json(&response)? { return Ok(()); } match response { CtlResponse::Devices { items } if items.is_empty() => println!("No devices."), CtlResponse::Devices { items } => { for d in items { let sync = d .last_sync .map(|t| t.to_string()) .unwrap_or_else(|| "never".to_string()); let tag = match (d.adopted, d.this_device) { (true, _) => " *adopted*", (false, true) => " *this machine*", _ => "", }; println!( "{} {} (synced: {sync}) [{}]{tag}", d.device_type, d.name, d.uid ); } } CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } } DeviceCmd::Rename { uid, name } => { ok_or_bail(control_request(CtlRequest::RenameDevice { uid, name })?)? } DeviceCmd::Rm { uid } => ok_or_bail(control_request(CtlRequest::DeleteDevice { uid })?)?, DeviceCmd::Adopt { uid, clear } => { if uid.is_none() && !clear { bail!("give a device uid (see `pdfs devices list`), or --clear"); } let uid = if clear { None } else { uid }; ok_or_bail(control_request(CtlRequest::AdoptDevice { uid })?)? } } Ok(()) } fn cmd_sync(action: SyncCmd) -> Result<()> { match action { SyncCmd::Add { path } => { let abs = std::fs::canonicalize(&path) .with_context(|| format!("resolve {}", path.display()))?; let local_path = abs .to_str() .ok_or_else(|| anyhow!("path is not valid UTF-8"))? .to_string(); ok_or_bail(control_request(CtlRequest::AddSyncFolder { local_path })?)? } SyncCmd::List => { let response = control_request(CtlRequest::ListSyncFolders)?; if emit_json(&response)? { return Ok(()); } match response { CtlResponse::SyncFolders { items } if items.is_empty() => { println!("No synced folders.") } CtlResponse::SyncFolders { items } => { for f in items { let sync = if f.last_sync == 0 { "never".to_string() } else { f.last_sync.to_string() }; // A queued switch is reported as the mode the folder is moving // to, so `sync list` never reads as if the request was dropped. let mode = match &f.pending_mode { Some(pending) => format!("{} → {pending}", f.mode), None => f.mode.clone(), }; println!( "[{}] {} ({mode}, {}, synced: {sync})", f.id, f.local_path, f.state ); // A pass in flight says what it is doing, indented under it. if let Some(p) = &f.progress { match p.phase { SyncPhase::Scanning if p.total == 0 => println!(" scanning…"), SyncPhase::Scanning => { println!( " scanning: {} of {}", p.done, p.total.max(p.done) ) } SyncPhase::Applying => println!( " {} of {} {}", p.done + 1, p.total.max(p.done + 1), p.current ), } } } } CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } } SyncCmd::Rm { id, delete_remote } => { ok_or_bail(control_request(CtlRequest::RemoveSyncFolder { id, delete_remote, })?)? } SyncCmd::Now { id } => ok_or_bail(control_request(CtlRequest::SyncNow { id })?)?, SyncCmd::Pause { duration } => { let until = duration.map(|secs| unix_now().saturating_add(secs as i64)); ok_or_bail(control_request(CtlRequest::SetSyncPaused { paused: true, until, })?)? } SyncCmd::Queue => return cmd_sync_queue(), SyncCmd::Retry { id } => ok_or_bail(control_request(CtlRequest::RetryPendingOp { id })?)?, SyncCmd::Resume => ok_or_bail(control_request(CtlRequest::SetSyncPaused { paused: false, until: None, })?)?, SyncCmd::Mode { id, mode } => { ok_or_bail(control_request(CtlRequest::SetSyncFolderMode { id, mode })?)? } SyncCmd::Restore { yes } => return cmd_sync_restore(yes), } Ok(()) } /// `pdfs sync queue`: what has not reached Proton Drive yet, and why. fn cmd_sync_queue() -> Result<()> { let response = control_request(CtlRequest::ListPendingOps)?; if emit_json(&response)? { return Ok(()); } match response { CtlResponse::PendingOps { items } if items.is_empty() => { println!("Nothing queued: everything is on Proton Drive."); } CtlResponse::PendingOps { items } => { let now = unix_now(); for op in items { let state = match (op.parked, op.next_attempt_at) { (true, _) => "waiting for its final name".to_string(), (false, Some(at)) if at > now => { format!("next try in {}", human_age((at - now) as u64)) } (false, _) => "due now".to_string(), }; println!("[{}] {} {} ({state})", op.id, op.kind, op.path); if let Some(error) = &op.last_error { println!(" failed {} time(s): {error}", op.attempts); } } } CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } Ok(()) } /// `pdfs sync restore`: list what this machine's device holds, confirm where /// each folder should land, then hand the mapping to the daemon. /// /// The daemon only ever *proposes* local paths — a path recorded by another /// machine may name a home directory that does not exist here — so the default /// flow is propose-and-confirm. `--yes` accepts the proposals for scripting. fn cmd_sync_restore(yes: bool) -> Result<()> { let response = control_request(CtlRequest::ListRestorableFolders)?; if emit_json(&response)? { return Ok(()); } let items = match response { CtlResponse::RestorableFolders { items } => items, CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), }; let candidates: Vec<_> = items.into_iter().filter(|f| !f.already_synced).collect(); if candidates.is_empty() { println!("Nothing to restore: this device has no folders that aren't already synced here."); return Ok(()); } let mut chosen: Vec = Vec::new(); for f in candidates { if yes { println!("{} -> {} [{}]", f.name, f.local_path, f.mode); chosen.push(CtlRestoreItem { remote_uid: f.remote_uid, local_path: f.local_path, mode: f.mode, }); continue; } // Empty answer takes the proposal, `n` skips, anything else is read as // the path to use instead — the three things a user wants here. let answer = prompt_line(&format!( "{} [{}] -> {} (Enter to accept, 'n' to skip, or a path)", f.name, f.mode, f.local_path ))?; match answer.as_str() { "n" | "N" => continue, "" => chosen.push(CtlRestoreItem { remote_uid: f.remote_uid, local_path: f.local_path, mode: f.mode, }), path => chosen.push(CtlRestoreItem { remote_uid: f.remote_uid, local_path: expand_tilde(path), mode: f.mode, }), } } if chosen.is_empty() { println!("Nothing selected."); return Ok(()); } ok_or_bail(control_request(CtlRequest::RestoreSyncFolders { items: chosen, })?) } /// Expand a leading `~` in a hand-typed path. The daemon requires an absolute /// path, and a shell is not always the one doing the typing here. fn expand_tilde(path: &str) -> String { match path.strip_prefix("~/") { Some(rest) => match std::env::var_os("HOME") { Some(home) => PathBuf::from(home) .join(rest) .to_string_lossy() .into_owned(), None => path.to_string(), }, None => path.to_string(), } } fn cmd_share( path: PathBuf, emails: Vec, role: String, message: Option, ) -> Result<()> { ok_or_bail(control_request(CtlRequest::ShareNode { path: path_arg(&path)?, emails, role, message, })?) } fn cmd_versions(action: VersionCmd) -> Result<()> { match action { VersionCmd::List { path } => { let response = control_request(CtlRequest::ListRevisions { path: path_arg(&path)?, })?; if emit_json(&response)? { return Ok(()); } match response { CtlResponse::Revisions { items } if items.is_empty() => { println!("No versions."); } CtlResponse::Revisions { items } => { for r in items { // The claimed plaintext size is what the user recognises; // the storage size is ciphertext and always larger. let size = r .claimed_size .filter(|size| *size >= 0) .unwrap_or(r.size_on_storage) .max(0) as u64; let mark = if r.is_active { "*" } else { " " }; let by = r .signed_by .map(|email| format!(" {email}")) .unwrap_or_default(); println!( "{mark} {:<20} {:>10} {}{by}", r.id, human_bytes(size), format_epoch(r.created), ); } println!("(* = current)"); } CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } Ok(()) } VersionCmd::Restore { path, id } => { ok_or_bail(control_request(CtlRequest::RestoreRevision { path: path_arg(&path)?, revision_id: id, })?) } VersionCmd::Save { path, id, dest } => { // The daemon writes the file, so it needs a path that means the same // thing in its process as in this one. let dest = std::path::absolute(&dest) .with_context(|| format!("resolve {}", dest.display()))?; ok_or_bail(control_request(CtlRequest::SaveRevisionAs { path: path_arg(&path)?, revision_id: id, dest: dest.to_string_lossy().into_owned(), })?) } VersionCmd::Rm { path, id } => ok_or_bail(control_request(CtlRequest::DeleteRevision { path: path_arg(&path)?, revision_id: id, })?), } } fn cmd_members(path: PathBuf) -> Result<()> { match control_request(CtlRequest::ListShare { path: path_arg(&path)?, })? { CtlResponse::Share { entries, link } => { if entries.is_empty() { println!("No members or pending invitations."); } for e in entries { let kind = match e.kind { ShareEntryKind::Member => "member", ShareEntryKind::ProtonInvite => "invited (proton)", ShareEntryKind::ExternalInvite => "invited (external)", }; println!("{:<18} {:<8} {} [{}]", kind, e.role, e.email, e.id); } match link { Some(l) => { let url = l.url.as_deref().unwrap_or("(url hidden)"); let pw = if l.has_password { " +password" } else { "" }; println!("public link ({}{pw}): {url} [{}]", l.role, l.id); } None => println!("public link: none"), } } CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } Ok(()) } fn cmd_share_role(path: PathBuf, id: String, kind: ShareKindArg, role: String) -> Result<()> { ok_or_bail(control_request(CtlRequest::UpdateShareRole { path: path_arg(&path)?, id, kind: kind.to_kind(), role, })?) } fn cmd_unshare(path: PathBuf, id: String, kind: ShareKindArg) -> Result<()> { ok_or_bail(control_request(CtlRequest::RemoveShareEntry { path: path_arg(&path)?, id, kind: kind.to_kind(), })?) } fn cmd_public_link(action: PublicLinkCmd) -> Result<()> { match action { PublicLinkCmd::Create { path, role, password, expires, } => match control_request(CtlRequest::CreatePublicLink { path: path_arg(&path)?, role, password, expires, })? { CtlResponse::PublicLink { link } => { println!("{}", link.url.as_deref().unwrap_or("(no url returned)")); } CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), }, PublicLinkCmd::Remove { path, id } => { ok_or_bail(control_request(CtlRequest::RemovePublicLink { path: path_arg(&path)?, id, })?)? } } Ok(()) } fn cmd_shared() -> Result<()> { match control_request(CtlRequest::ListSharedByMe)? { CtlResponse::SharedByMe { items } if items.is_empty() => { println!("You haven't shared anything.") } CtlResponse::SharedByMe { items } => { for it in items { let kind = if it.is_dir { "d" } else { "-" }; let mut tags = Vec::new(); if it.member_count > 0 { tags.push(format!("{} member(s)", it.member_count)); } if it.invite_count > 0 { tags.push(format!("{} pending", it.invite_count)); } if let Some(link) = &it.link { tags.push(match &link.url { Some(url) => format!("link: {url}"), None => "link".to_string(), }); } let tags = if tags.is_empty() { String::new() } else { format!(" ({})", tags.join(", ")) }; println!("{kind} {}{tags} [{}]", it.name, it.uid); } } CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } Ok(()) } /// List what is shared with me, or — given a folder uid — that folder's /// children. Shared items have no path in the mount, so a uid is the only handle /// there is: every row prints one, and it is what the next level down (or /// `shared-get`) is addressed with. fn cmd_shared_with_me(uid: Option) -> Result<()> { let request = match uid { Some(uid) => CtlRequest::ListSharedFolder { uid }, None => CtlRequest::ListSharedWithMe, }; let response = control_request(request)?; if emit_json(&response)? { return Ok(()); } match response { CtlResponse::Entries { entries } if entries.is_empty() => { println!("Nothing shared with you.") } CtlResponse::Entries { entries } => { for e in entries { let kind = if e.is_dir { "d" } else { "-" }; // My role decides whether this is writable at all, so it is // printed whenever the API reported one; an empty role prints // nothing rather than a guess. let role = if e.role.is_empty() { String::new() } else { format!(" ({})", e.role) }; println!("{kind} {:>12} {}{role} [{}]", e.size, e.name, e.uid); if let Some(line) = shared_by_line(&e) { println!("{:>15}{line}", ""); } } } CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } Ok(()) } /// Who shared an entry with me, and when, as a line printed under it — `None` /// when the invitation said nothing (owned content, or a node inside a shared /// folder rather than its root). An inviter whose signature did not verify is /// said to be claimed, not stated: the name may be forged. fn shared_by_line(entry: &pdfs_core::control::DirEntry) -> Option { if entry.shared_by.is_empty() { return None; } let mut line = format!("shared by {}", entry.shared_by); if entry.shared_at > 0 { line.push_str(&format!(" at {}", format_epoch_utc(entry.shared_at))); } if entry.shared_by_unverified { line.push_str(" WARNING: invitation signature did not verify; the sender may be forged"); } Some(line) } /// `YYYY-MM-DD` (UTC) for epoch seconds, without pulling a date crate into the /// CLI for one column. Civil-from-days per Howard Hinnant's algorithm. fn format_epoch_utc(secs: i64) -> String { let z = secs.div_euclid(86_400) + 719_468; let era = z.div_euclid(146_097); let doe = z - era * 146_097; let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let day = doy - (153 * mp + 2) / 5 + 1; let month = if mp < 10 { mp + 3 } else { mp - 9 }; let year = yoe + era * 400 + i64::from(month <= 2); format!("{year:04}-{month:02}-{day:02}") } /// Download a file shared with me. The daemon puts the plaintext in its content /// cache and replies with that path; with a `dest` we copy it out, so the caller /// gets a file it owns rather than one the cache may later evict. fn cmd_shared_get(uid: String, dest: Option) -> Result<()> { let path = match control_request(CtlRequest::OpenSharedFile { uid })? { CtlResponse::FilePath { path } => PathBuf::from(path), CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), }; match dest { Some(dest) => { std::fs::copy(&path, &dest).with_context(|| format!("writing {}", dest.display()))?; println!("{}", dest.display()); } None => println!("{}", path.display()), } Ok(()) } fn cmd_quota() -> Result<()> { let response = control_request(CtlRequest::AccountQuota)?; if emit_json(&response)? { return Ok(()); } match response { CtlResponse::AccountQuota { max_space, used_space, } => { let used = used_space.max(0) as u64; if max_space > 0 { let total = max_space as u64; let pct = (used as f64 / total as f64 * 100.0).round() as u64; println!( "{} of {} used ({pct}%)", human_bytes(used), human_bytes(total) ); } else { println!("{} used", human_bytes(used)); } } CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } Ok(()) } fn cmd_activity(limit: usize) -> Result<()> { let response = control_request(CtlRequest::ListActivity { limit })?; if emit_json(&response)? { return Ok(()); } match response { CtlResponse::Activity { items } if items.is_empty() => println!("No recent activity."), CtlResponse::Activity { items } => { for a in items { let when = format_epoch(a.time); let verb = activity_verb(a.kind); let mark = if a.ok { " " } else { "!" }; let detail = if a.detail.is_empty() { String::new() } else { format!(" {}", a.detail) }; println!("{mark} {when} {verb:<8} {}{detail}", a.target); } } CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } Ok(()) } /// A short verb for an activity kind, for the CLI's one-line-per-event feed. fn activity_verb(kind: pdfs_core::control::ActivityKind) -> &'static str { use pdfs_core::control::ActivityKind::*; match kind { Upload => "upload", Download => "download", Sync => "sync", Rename => "rename", Move => "move", CreateFolder => "mkdir", Trash => "trash", Restore => "restore", DeleteForever => "delete", EmptyTrash => "empty", Share => "share", PublicLink => "link", Unshare => "unshare", Conflict => "conflict", } } /// Format an epoch-seconds timestamp as a compact local-ish `MM-DD HH:MM`. /// Deliberately dependency-free: derived directly from the Unix time via UTC. fn format_epoch(secs: i64) -> String { // Minimal civil-time conversion (UTC) — good enough for an activity feed. let days = secs.div_euclid(86_400); let tod = secs.rem_euclid(86_400); let (h, mi) = (tod / 3600, (tod % 3600) / 60); // Days since 1970-01-01 → y/m/d (Howard Hinnant's civil_from_days). let z = days + 719_468; let era = z.div_euclid(146_097); let doe = z.rem_euclid(146_097); let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe + era * 400; let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = doy - (153 * mp + 2) / 5 + 1; let m = if mp < 10 { mp + 3 } else { mp - 9 }; let _ = y; format!("{m:02}-{d:02} {h:02}:{mi:02}") } fn cmd_leave(uid: String) -> Result<()> { ok_or_bail(control_request(CtlRequest::LeaveShared { uid })?) } fn cmd_invitations(action: InvitationCmd) -> Result<()> { match action { InvitationCmd::List => match control_request(CtlRequest::ListInvitations)? { CtlResponse::Invitations { items } if items.is_empty() => { println!("No pending invitations.") } CtlResponse::Invitations { items } => { for i in items { let kind = if i.is_dir { "folder" } else { "file" }; let name = i.name.as_deref().unwrap_or("(name hidden)"); println!("{} shared {kind} \"{name}\" [{}]", i.inviter_email, i.id); } } CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), }, InvitationCmd::Accept { id } => { ok_or_bail(control_request(CtlRequest::AcceptInvitation { id })?)? } InvitationCmd::Reject { id } => { ok_or_bail(control_request(CtlRequest::RejectInvitation { id })?)? } } Ok(()) } fn cmd_bookmarks(action: BookmarkCmd) -> Result<()> { match action { BookmarkCmd::List => match control_request(CtlRequest::ListBookmarks)? { CtlResponse::Bookmarks { items } if items.is_empty() => println!("No bookmarks."), CtlResponse::Bookmarks { items } => { for b in items { let name = b.name.as_deref().unwrap_or("(name hidden)"); println!("{name} {} [{}]", b.url, b.token); } } CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), }, BookmarkCmd::Add { url, password } => { ok_or_bail(control_request(CtlRequest::CreateBookmark { url, password, })?)? } BookmarkCmd::Rm { token } => { ok_or_bail(control_request(CtlRequest::DeleteBookmark { token })?)? } } Ok(()) } /// Print an `Ok` message or bail on an `Error`, for the many commands whose only /// reply is one of those two. fn ok_or_bail(resp: CtlResponse) -> Result<()> { match resp { CtlResponse::Ok { message } => println!("{message}"), CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } Ok(()) } /// Read a line from stdin with a prompt (echoed). fn prompt_line(label: &str) -> Result { print!("{label}: "); std::io::stdout().flush()?; let mut line = String::new(); std::io::stdin().read_line(&mut line)?; Ok(line.trim().to_owned()) } fn cmd_login(username: Option) -> Result<()> { let username = match username { Some(u) => u, None => prompt_line("Proton email")?, }; let password = rpassword::prompt_password("Password: ").context("read password")?; // The 2FA prompt fires lazily — only if the account actually requires it. let get_totp = || -> pdfs_core::Result { prompt_line("2FA code").map_err(|e| pdfs_core::Error::Other(format!("read 2FA code: {e}"))) }; let rt = tokio::runtime::Runtime::new()?; // Solving a CAPTCHA needs a browser engine, which the CLI has no business // carrying. Point at the app that does rather than failing with the raw API // message, which reads as "your login is broken". rt.block_on(auth::login(&username, &password, get_totp)) .map_err(|e| match e { pdfs_core::Error::HumanVerificationRequired(_) => anyhow::anyhow!( "Proton is asking for a CAPTCHA before it will accept this sign-in.\n\ Run `pdfs-app` and sign in there — it can show the verification page.\n\ (This is usually triggered by a VPN or an unfamiliar IP; signing in from \ your usual network often avoids it.)" ), other => anyhow::Error::new(other).context("login failed"), })?; // A daemon that is already up is parked in `wait_for_session`, sleeping // between keyring checks; restart it so the mount appears now instead of // whenever the next poll fires. Only when it is already active — `pdfs // login` deliberately does not enable the unit the way the GUI does, so a // headless user driving `pdfs daemon` by hand keeps that setup. if service::is_active() { service::restart(); } println!("Logged in as {username}. Session stored in the system keyring."); Ok(()) } fn cmd_logout() -> Result<()> { auth::logout()?; println!("Stored session cleared."); Ok(()) } fn cmd_status() -> Result<()> { // `status` is the one query that merges two sources — the local keyring and // the daemon — so its JSON is composed here rather than being a daemon reply // passed through. A script asking for status wants both halves in one // object, including the "logged in but no daemon" case that has no reply at // all. if json_enabled() { let session = match auth::load() { Ok(s) => Some(s), Err(pdfs_core::Error::NotLoggedIn) => None, Err(e) => return Err(e.into()), }; let mount = match control_request(CtlRequest::Status) { Ok(r @ CtlResponse::Status { .. }) => response_payload(&r)?, _ => serde_json::Value::Null, }; let out = serde_json::json!({ "logged_in": session.is_some(), "username": session.as_ref().map(|s| s.username.clone()), "user_id": session.as_ref().map(|s| s.user_id.clone()), "scopes": session.as_ref().map(|s| s.scopes.clone()), "mount": mount, }); println!("{}", serde_json::to_string_pretty(&out)?); return Ok(()); } match auth::load() { Ok(s) => { println!("Logged in as {} (user {})", s.username, s.user_id); println!("Scopes: {}", s.scopes.join(", ")); } Err(pdfs_core::Error::NotLoggedIn) => { println!("Not logged in. Run `pdfs login`."); return Ok(()); } Err(e) => return Err(e.into()), } // If a mount daemon is running, report live status from its control socket. match control_request(CtlRequest::Status) { Ok(CtlResponse::Status { mountpoint, pinned, online, pending_uploads, pending_changes, parked_uploads, failing_ops, failing_error, staged_bytes, staged_oldest_secs, paused, paused_until, .. }) => { let state = match (online, paused) { (_, true) => ", sync paused", (false, false) => ", offline", (true, false) => "", }; let queued = match pending_summary(pending_uploads, pending_changes) { Some(s) => format!(", {s}"), None => String::new(), }; println!("Mounted at {mountpoint} ({pinned} pinned{state}{queued})"); // Everything below is a queue that is not making progress on its // own. Printed only when it exists, because in the ordinary case // there is nothing to say. if staged_bytes > 0 { println!( "Staged {} not yet uploaded (oldest {})", human_bytes(staged_bytes), human_age(staged_oldest_secs), ); } if let Some(until) = paused_until { let left = until.saturating_sub(unix_now()).max(0) as u64; println!("Paused sync resumes in {}", human_age(left.max(1))); } if parked_uploads > 0 { println!("Parked {parked_uploads} upload(s) waiting for a final filename"); } if failing_ops > 0 { println!( "Stuck {failing_ops} queued operation(s) keep failing: {}", failing_error.as_deref().unwrap_or("no error recorded"), ); } } Ok(other) => println!("Mount: unexpected response {other:?}"), Err(_) => println!("Mount: not running."), } Ok(()) } fn cmd_locations() -> Result<()> { let response = control_request(CtlRequest::ListLocations)?; if emit_json(&response)? { return Ok(()); } match response { CtlResponse::Locations { items } if items.is_empty() => { println!("No Proton Drive locations."); } CtlResponse::Locations { items } => { for location in items { let kind = match &location.kind { MountKind::MyFiles => "My Files".to_string(), MountKind::Device { sync_folder_id } => { format!("Device folder {sync_folder_id}") } MountKind::Shared { .. } => "Shared folder".to_string(), }; let mode = match location.pending_mode { Some(pending) => format!("{} -> {pending}", location.mode), None => location.mode.to_string(), }; let mounted = if location.mounted { "mounted" } else { "not mounted" }; println!( "{kind}: {} ({mode}, {}, {}, {mounted})", location.local_path, location.access, location.state ); } } CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } Ok(()) } fn cmd_mount(mountpoint: Option) -> Result<()> { mount_once(mountpoint)?; Ok(()) } /// How long to wait before the next login check, after `attempts` checks have /// already come back [`pdfs_core::Error::NotLoggedIn`]. /// /// Doubles from 3s to a 15s ceiling. The interval is not just a sleep: /// [`auth::load`] builds a fresh `keyring::Entry` on every call, so each check /// opens a new D-Bus connection to the Secret Service and negotiates an /// encrypted session over it. A flat 3s poll meant 28,800 of those a day from a /// daemon that had nothing to do; the ceiling cuts that by 80%. /// /// The ceiling stays low because the sleep is also the worst-case login → /// mount latency for anything that does *not* poke systemd: front-ends restart /// the unit on login (`service::enable_start`), which interrupts the sleep /// immediately, but a hand-run `pdfs daemon` has no such wake-up path. fn login_poll_delay(attempts: u32) -> std::time::Duration { const BASE_SECS: u64 = 3; const MAX_SECS: u64 = 15; // `checked_shl` rather than `<<`: the daemon can sit unauthenticated for // weeks, and a shift past the width of the type is a panic, not a big number. let factor = 1u64.checked_shl(attempts).unwrap_or(u64::MAX); std::time::Duration::from_secs(BASE_SECS.saturating_mul(factor).min(MAX_SECS)) } /// Block until a session is stored, backing off between checks. /// /// `load` and `sleep` are parameters rather than direct calls so the backoff /// schedule can be tested without a keyring and without real time passing. fn wait_for_session(mut load: L, mut sleep: S) -> Result<()> where L: FnMut() -> std::result::Result<(), pdfs_core::Error>, S: FnMut(std::time::Duration), { let mut attempts: u32 = 0; loop { match load() { Ok(()) => return Ok(()), Err(pdfs_core::Error::NotLoggedIn) => { let delay = login_poll_delay(attempts); // Say it once at INFO. Repeating it every interval buried the // journal in tens of thousands of identical lines a day. if attempts == 0 { tracing::info!("not logged in; waiting for a session…"); } else { tracing::debug!(delay_secs = delay.as_secs(), "still not logged in"); } sleep(delay); attempts = attempts.saturating_add(1); } Err(e) => return Err(e.into()), } } } /// Run the auto-mount daemon loop. Waits for a stored login, mounts, and keeps /// the mount alive: a clean stop (SIGTERM via `systemctl --user stop`) exits 0; /// an external unmount triggers a remount; errors back off and retry. This is /// the entry point for the systemd user service. fn cmd_daemon(mountpoint: Option) -> Result<()> { loop { // Wait until a session is stored. The GUI enables this service on login, // but the service may also start at boot before the user has logged in. wait_for_session(|| auth::load().map(|_| ()), std::thread::sleep)?; match mount_once(mountpoint.clone()) { Ok(pdfs_fuse::MountOutcome::Shutdown) => { tracing::info!("daemon stopping"); return Ok(()); } Ok(pdfs_fuse::MountOutcome::Unmounted) => { tracing::warn!("mount ended externally; remounting in 2s"); std::thread::sleep(std::time::Duration::from_secs(2)); } Err(e) => { // `{:#}` so the whole anyhow chain lands in the log — the top // context alone ("mount failed") never says *why*. tracing::error!(error = format!("{e:#}"), "mount failed; retrying in 5s"); std::thread::sleep(std::time::Duration::from_secs(5)); } } } } /// One mount run: resume the session, mount, and block until the mount ends /// (clean shutdown or external unmount). Returns the [`MountOutcome`] so the /// daemon loop can decide whether to exit or remount. fn mount_once(mountpoint: Option) -> Result { let dirs = AppDirs::new()?; dirs.ensure()?; let config = dirs.load_config(); // An explicit `--mountpoint` wins; otherwise honor the Settings-page choice // (config), falling back to the default location. let mountpoint = mountpoint.unwrap_or_else(|| dirs.resolved_mountpoint(&config)); std::fs::create_dir_all(&mountpoint) .with_context(|| format!("create mountpoint {}", mountpoint.display()))?; let username = auth::load().map(|s| s.username).unwrap_or_default(); // The daemon owns the unified DB (single writer). Open it here so the content // cache and the mount share one handle: the cache uses its `cache_entries` // table as the LRU index, the mount uses it for nodes/search/event cursor. let db = Arc::new(Db::open(&dirs.db_path()).context("open cache db")?); let cache = ContentCache::open( dirs.content_cache_dir(), dirs.pins_path(), config.resolved_cache_budget(), db.clone(), ) .context("open content cache")?; let control_socket = dirs.control_socket(); // Multi-threaded runtime: its worker threads keep servicing async SDK calls // while the main thread is parked inside the blocking FUSE session loop. let rt = tokio::runtime::Runtime::new()?; let (client, session) = rt .block_on(auth::resume_client()) .context("resume session (run `pdfs login` first)")?; // Token updates are persisted automatically in real-time. The SDK session has // a token refresh callback registered during `resume_client` that writes updated // credentials back to the OS keyring immediately upon rotation. This prevents // the system keyring from holding stale refresh tokens even if the daemon crashes, // the system is suspended, or the network changes. let handle = rt.handle().clone(); let result = pdfs_fuse::mount( client, handle, &mountpoint, cache, &control_socket, db, pdfs_fuse::MountOptions { username, sweep_mode: config.resolved_conflict_sweep(), }, ); // Clean unmount check (as a best-effort final backup). if let Err(e) = rt.block_on(auth::persist(&session)) { tracing::debug!(error = %e, "no new tokens to persist on shutdown"); } let outcome = result.context("mount failed")?; Ok(outcome) } fn cmd_pin(path: PathBuf) -> Result<()> { match control_request(CtlRequest::Pin { path: path_arg(&path)?, })? { CtlResponse::Ok { message } => println!("{message}"), CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } Ok(()) } fn cmd_unpin(path: PathBuf) -> Result<()> { match control_request(CtlRequest::Unpin { path: path_arg(&path)?, })? { CtlResponse::Ok { message } => println!("{message}"), CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } Ok(()) } fn cmd_transfers() -> Result<()> { use pdfs_core::control::TransferDirection; let response = control_request(CtlRequest::GetQueueStatus)?; if emit_json(&response)? { return Ok(()); } match response { CtlResponse::Transfers { items, jobs } if items.is_empty() && jobs.is_empty() => { println!("Nothing in progress.") } CtlResponse::Transfers { items, jobs } => { // Jobs first: they are the context for the transfers under them ("of // 40 files", "still scanning") rather than a separate list. for j in jobs { let count = if j.total > 0 { format!("{} of {}", j.done, j.total) } else { "—".to_string() }; let detail = if j.detail.is_empty() { String::new() } else { format!(" — {}", j.detail) }; println!("… {:>9} {}{detail}", count, j.title); } for t in items { let arrow = match t.direction { TransferDirection::Download => "↓", TransferDirection::Upload => "↑", }; let pct = if t.bytes_total > 0 { format!( "{:.0}%", 100.0 * t.bytes_completed as f64 / t.bytes_total as f64 ) } else { "—".to_string() }; let speed = t.speed_bytes_sec as f64 / 1_048_576.0; println!( "{arrow} {:>4} {:>8} / {:>8} {speed:>6.2} MiB/s {}", pct, t.bytes_completed, t.bytes_total, t.name ); } } CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } Ok(()) } fn cmd_pins() -> Result<()> { let response = control_request(CtlRequest::ListPins)?; if emit_json(&response)? { return Ok(()); } match response { CtlResponse::Pins { pins } if pins.is_empty() => println!("No pinned files."), CtlResponse::Pins { pins } => { for p in pins { println!("{}", p.path); } } CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } Ok(()) } fn cmd_ls(path: Option) -> Result<()> { let path = match path { Some(p) => path_arg(&p)?, None => String::new(), }; let response = control_request(CtlRequest::ListDir { path })?; if emit_json(&response)? { return Ok(()); } match response { CtlResponse::Entries { entries } if entries.is_empty() => println!("(empty)"), CtlResponse::Entries { entries } => { for e in entries { let kind = if e.is_dir { "d" } else { "-" }; let pin = if e.pinned { "*" } else { " " }; println!("{kind}{pin} {:>12} {} [{}]", e.size, e.name, e.uid); } } CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } Ok(()) } fn cmd_photos(limit: usize, offset: usize, favorites: bool) -> Result<()> { match control_request(CtlRequest::PhotosTimeline { offset, limit, kind: None, range: None, favorites, })? { CtlResponse::Photos { available: false, .. } => { println!("This account has no photos volume.") } CtlResponse::Photos { items, .. } if items.is_empty() => println!("No photos."), CtlResponse::Photos { items, .. } => print_photo_page(items)?, CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } Ok(()) } /// Print one page of photos as `capture-time uid thumbnail-path`. /// /// A timeline or album reply only carries thumbnails already in the cache, so /// the rest of the page's are pulled in one batch rather than left blank. fn cmd_favorite(uid: String, remove: bool) -> Result<()> { ok_or_bail(control_request(CtlRequest::SetPhotoFavorite { uid, favorite: !remove, })?) } fn print_photo_page(items: Vec) -> Result<()> { let uids: Vec = items.iter().map(|p| p.uid.clone()).collect(); let thumbs: HashMap> = match control_request(CtlRequest::PhotoThumbs { uids })? { CtlResponse::Thumbs { items } => items.into_iter().map(|t| (t.uid, t.path)).collect(), _ => HashMap::new(), }; for p in items { let thumb = thumbs .get(&p.uid) .and_then(|p| p.as_deref()) .or(p.thumb_path.as_deref()) .unwrap_or("(no thumbnail)"); let fav = if p.favorite { "*" } else { " " }; println!("{fav} {} {} {thumb}", p.capture_time, p.uid); } Ok(()) } fn cmd_albums() -> Result<()> { match control_request(CtlRequest::PhotoAlbums)? { CtlResponse::Albums { available: false, .. } => println!("This account has no photos volume."), CtlResponse::Albums { items, .. } if items.is_empty() => println!("No albums."), CtlResponse::Albums { items, .. } => { for album in items { let shared = if album.shared { " (shared with me)" } else { "" }; println!( "{:>6} {} {}{shared}", album.photo_count, album.uid, album.name ); } } CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } Ok(()) } fn cmd_import_google_photos(archives: Vec, dry_run: bool, wait: bool) -> Result<()> { // The daemon opens the archives itself off the shared filesystem, so it gets // absolute paths — the same arrangement `pdfs upload` uses. let mut absolute = Vec::with_capacity(archives.len()); for archive in &archives { let path = std::fs::canonicalize(archive) .with_context(|| format!("resolve {}", archive.display()))?; absolute.push( path.to_str() .ok_or_else(|| anyhow!("path is not valid UTF-8: {}", path.display()))? .to_owned(), ); } match control_request(CtlRequest::ImportTakeout { archives: absolute, dry_run, })? { CtlResponse::Ok { message } => println!("{message}"), CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } if !wait { println!("Run `pdfs import-status` to follow it."); return Ok(()); } // Poll rather than stream: the control protocol is request/response, and an // import runs for hours — a held-open socket would be the only long-lived // connection the daemon has. loop { std::thread::sleep(std::time::Duration::from_secs(2)); match control_request(CtlRequest::ImportStatus)? { CtlResponse::ImportStatus { running: true, summary: _, } => continue, CtlResponse::ImportStatus { summary, .. } => { print_import_summary(summary.as_ref()); return Ok(()); } CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } } } fn cmd_redate_photos( dry_run: bool, limit: Option, from: Option, to: Option, wait: bool, ) -> Result<()> { let range = match (from.as_deref(), to.as_deref()) { (None, None) => None, (from, to) => Some(( from.map(parse_day).transpose()?.unwrap_or(i64::MIN), to.map(parse_day).transpose()?.unwrap_or(i64::MAX), )), }; match control_request(CtlRequest::RedatePhotos { dry_run, limit, range, })? { CtlResponse::Ok { message } => println!("{message}"), CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } if !wait { println!("Run `pdfs redate-status` to follow it."); return Ok(()); } // Polled for the same reason the import is: request/response protocol, and a // run over a whole library is a download plus an upload per photo. loop { std::thread::sleep(std::time::Duration::from_secs(2)); match control_request(CtlRequest::RedateStatus)? { CtlResponse::RedateStatus { running: true, .. } => continue, CtlResponse::RedateStatus { summary, .. } => { print_redate_summary(summary.as_ref()); return Ok(()); } CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } } } /// A `YYYY-MM-DD` day as epoch seconds at UTC midnight. Reuses the Takeout /// scanner's date reader, which already accepts exactly this spelling. fn parse_day(day: &str) -> Result { pdfs_core::takeout::capture_time_from_name(day) .ok_or_else(|| anyhow!("not a date (expected YYYY-MM-DD): {day}")) } fn cmd_redate_status() -> Result<()> { match control_request(CtlRequest::RedateStatus)? { CtlResponse::RedateStatus { running, summary } => { if running { println!("Re-date running. `pdfs transfers` shows its progress."); } print_redate_summary(summary.as_ref()); } CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } Ok(()) } fn cmd_cancel_redate() -> Result<()> { match control_request(CtlRequest::CancelRedate)? { CtlResponse::Ok { message } => println!("{message}"), CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } Ok(()) } fn print_redate_summary(summary: Option<&pdfs_core::control::RedateSummary>) { let Some(summary) = summary else { println!("No re-date has run yet."); return; }; println!("Photos examined : {}", summary.examined); println!("Mis-dated : {}", summary.candidates); if summary.dry_run { println!("(dry run — nothing was uploaded or trashed)"); } else { println!("Re-dated : {}", summary.redated); if summary.album_links > 0 { println!("Albums refiled : {}", summary.album_links); } if summary.failed > 0 { println!("Failed : {}", summary.failed); } if summary.bytes > 0 { println!("Uploaded bytes : {}", summary.bytes); } } for (name, stored, wanted) in &summary.samples { // Epoch seconds, as `pdfs photos` prints capture times. println!(" {name}: {stored} -> {wanted}"); } if summary.candidates > summary.samples.len() { println!( " … and {} more", summary.candidates - summary.samples.len() ); } if summary.cancelled { println!("Run was cancelled before it finished."); } } fn cmd_import_status() -> Result<()> { match control_request(CtlRequest::ImportStatus)? { CtlResponse::ImportStatus { running, summary } => { if running { println!("Import running. `pdfs transfers` shows its progress."); } print_import_summary(summary.as_ref()); } CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } Ok(()) } fn cmd_cancel_import() -> Result<()> { match control_request(CtlRequest::CancelImport)? { CtlResponse::Ok { message } => println!("{message}"), CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } Ok(()) } fn print_import_summary(summary: Option<&pdfs_core::control::ImportSummary>) { let Some(summary) = summary else { println!("No import has run yet."); return; }; println!("Photos in export : {}", summary.found); println!("Uploaded : {}", summary.uploaded); println!("Already on Proton: {}", summary.duplicates); if summary.skipped_trashed > 0 { println!("Skipped (trash) : {}", summary.skipped_trashed); } if summary.albums_created > 0 || summary.album_links > 0 { println!( "Albums : {} created, {} photos filed", summary.albums_created, summary.album_links ); } if summary.failed > 0 { println!("Failed : {}", summary.failed); } if summary.bytes > 0 { println!("Uploaded bytes : {}", summary.bytes); } if summary.cancelled { println!("Import was cancelled before it finished."); } } fn cmd_album(uid: String, limit: usize, offset: usize) -> Result<()> { match control_request(CtlRequest::AlbumPhotos { uid, offset, limit })? { CtlResponse::Photos { items, .. } if items.is_empty() => println!("No photos."), CtlResponse::Photos { items, .. } => print_photo_page(items)?, CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } Ok(()) } fn cmd_open_photo(uid: String) -> Result<()> { match control_request(CtlRequest::OpenPhoto { uid })? { CtlResponse::FilePath { path } => println!("{path}"), CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } Ok(()) } fn cmd_search(query: String, limit: usize) -> Result<()> { match control_request(CtlRequest::Search { query, limit })? { CtlResponse::SearchResults { hits } if hits.is_empty() => println!("(no matches)"), CtlResponse::SearchResults { hits } => { for h in hits { let kind = if h.is_dir { "d" } else { "-" }; let pin = if h.pinned { "*" } else { " " }; println!("{kind}{pin} {:>12} {}", h.size, h.path); } } CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } Ok(()) } fn cmd_rename(path: PathBuf, new_name: String) -> Result<()> { match control_request(CtlRequest::Rename { path: path_arg(&path)?, new_name, })? { CtlResponse::Ok { message } => println!("{message}"), CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } Ok(()) } fn cmd_move(path: PathBuf, new_parent: PathBuf) -> Result<()> { match control_request(CtlRequest::Move { path: path_arg(&path)?, new_parent: path_arg(&new_parent)?, })? { CtlResponse::Ok { message } => println!("{message}"), CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } Ok(()) } fn cmd_rm(path: PathBuf) -> Result<()> { match control_request(CtlRequest::Delete { path: path_arg(&path)?, })? { CtlResponse::Ok { message } => println!("{message}"), CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } Ok(()) } /// Seconds as something a human reads at a glance: the ages here run from /// "still starting" to "wedged since yesterday". fn human_secs(secs: u64) -> String { match secs { s if s < 60 => format!("{s}s"), s if s < 3600 => format!("{}m{}s", s / 60, s % 60), s if s < 86400 => format!("{}h{}m", s / 3600, (s % 3600) / 60), s => format!("{}d{}h", s / 86400, (s % 86400) / 3600), } } fn cmd_diagnostics() -> Result<()> { let response = control_request(CtlRequest::Diagnostics)?; if emit_json(&response)? { return Ok(()); } match response { CtlResponse::Diagnostics { diagnostics: d } => { println!( "uptime {} rss {} pending ops {}", human_secs(d.uptime_secs), human_bytes(d.rss_bytes), d.pending_ops, ); println!( "control handlers {}/{}", d.control_handlers, d.control_handler_limit, ); println!( "queued: meta {} (done {}), transfer {} (done {})", d.meta_queued, d.meta_completed, d.transfer_queued, d.transfer_completed, ); println!("workers:"); for worker in &d.workers { let what = if worker.busy { worker.label.as_str() } else { "idle" }; println!( " {:<20} {:<12} {}", worker.name, what, human_secs(worker.age_secs) ); } if d.inflight.is_empty() { println!("in-flight requests: none"); } else { println!("in-flight requests (oldest first):"); for request in &d.inflight { println!(" {:<20} {}", request.kind, human_secs(request.age_secs)); } } } CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } Ok(()) } fn cmd_trash() -> Result<()> { match control_request(CtlRequest::ListTrash)? { CtlResponse::Entries { entries } if entries.is_empty() => println!("(trash is empty)"), CtlResponse::Entries { entries } => { for e in entries { let kind = if e.is_dir { "d" } else { "-" }; println!("{kind} {:>12} {} [{}]", e.size, e.name, e.uid); } } CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } Ok(()) } fn cmd_restore(uids: Vec) -> Result<()> { match control_request(CtlRequest::Restore { uids })? { CtlResponse::Ok { message } => println!("{message}"), CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } Ok(()) } fn cmd_delete_forever(uids: Vec) -> Result<()> { confirm(&format!( "Permanently delete {} item(s)? This cannot be undone.", uids.len() ))?; match control_request(CtlRequest::DeleteForever { uids })? { CtlResponse::Ok { message } => println!("{message}"), CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } Ok(()) } fn cmd_empty_trash() -> Result<()> { confirm("Permanently delete everything in the trash? This cannot be undone.")?; match control_request(CtlRequest::EmptyTrash)? { CtlResponse::Ok { message } => println!("{message}"), CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } Ok(()) } /// Drop a cached listing. `trash` and `photos` name those two listings; anything /// else is read as a folder path, so a folder actually called "trash" is still /// reachable as `./trash`. fn cmd_refresh(target: Option, full: bool) -> Result<()> { let scope = match target.as_deref() { Some("trash") => RefreshScope::Trash, Some("photos") => RefreshScope::Photos { full }, path => RefreshScope::Dir { path: path.unwrap_or("").to_string(), }, }; let photos = matches!(scope, RefreshScope::Photos { .. }); match control_request(CtlRequest::Refresh { scope })? { CtlResponse::Ok { message } => println!("{message}"), CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } if photos { wait_for_photos_refresh()?; } Ok(()) } /// How often [`wait_for_photos_refresh`] asks the daemon whether the timeline /// refresh it started is done. const PHOTOS_REFRESH_POLL: std::time::Duration = std::time::Duration::from_secs(2); /// Block until the daemon's timeline refresh has finished. /// /// The daemon answers `Refresh { Photos }` as soon as the refresh is running, /// because re-reading a large library takes minutes and a control reply may not. /// `pdfs refresh photos` means "the timeline is up to date when I return", so /// the waiting happens here, where no socket read bound applies. fn wait_for_photos_refresh() -> Result<()> { loop { std::thread::sleep(PHOTOS_REFRESH_POLL); match control_request(CtlRequest::PhotosRefreshStatus)? { CtlResponse::PhotosRefresh { running: true } => continue, CtlResponse::PhotosRefresh { running: false } => { println!("refreshed"); return Ok(()); } CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } } } /// Ask for a typed `yes` before an irreversible destroy. Errors (aborting the /// command) on anything else, including a non-interactive stdin: a piped or /// scripted invocation must not silently wipe the trash. fn confirm(prompt: &str) -> Result<()> { print!("{prompt} [type \"yes\" to confirm] "); std::io::stdout().flush()?; let mut answer = String::new(); std::io::stdin().read_line(&mut answer)?; if answer.trim() != "yes" { bail!("aborted"); } Ok(()) } fn cmd_mkdir(parent: PathBuf, name: String) -> Result<()> { match control_request(CtlRequest::CreateFolder { parent: path_arg(&parent)?, name, })? { CtlResponse::Ok { message } => println!("{message}"), CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } Ok(()) } fn cmd_upload(sources: Vec, parent: PathBuf) -> Result<()> { // The daemon reads the sources off its own (local) filesystem, so send it // absolute, canonicalized paths. let mut abs = Vec::with_capacity(sources.len()); for src in &sources { let p = std::fs::canonicalize(src).with_context(|| format!("resolve {}", src.display()))?; abs.push( p.to_str() .ok_or_else(|| anyhow!("path is not valid UTF-8: {}", p.display()))? .to_owned(), ); } match control_request(CtlRequest::UploadPaths { parent: path_arg(&parent)?, sources: abs, })? { CtlResponse::Ok { message } => println!("{message}"), CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } Ok(()) } /// Normalize a user-supplied path argument for sending to the daemon. Absolute /// paths are canonicalized (so the daemon can strip its mountpoint prefix); /// relative paths are passed through and resolved against the mount root. fn path_arg(path: &Path) -> Result { let p = if path.is_absolute() { std::fs::canonicalize(path).with_context(|| format!("resolve {}", path.display()))? } else { path.to_path_buf() }; p.to_str() .map(str::to_owned) .ok_or_else(|| anyhow!("path is not valid UTF-8")) } /// Format an age in seconds for a report line, coarsely — the point of the /// number is whether it is minutes or weeks. /// Parse a `sync pause --for` duration: a whole number followed by `m`, `h` or /// `d`. Returns seconds. fn parse_pause_duration(text: &str) -> Result { let text = text.trim(); let split = text.char_indices().last().map_or(0, |(at, _)| at); let (number, unit) = text.split_at(split); let per_unit: u64 = match unit { "m" => 60, "h" => 3600, "d" => 86_400, _ => return Err("use a number followed by m, h or d, e.g. 30m".to_string()), }; match number.parse::() { Ok(n) if n > 0 => Ok(n.saturating_mul(per_unit)), _ => Err("use a positive whole number, e.g. 30m".to_string()), } } fn unix_now() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs() as i64) .unwrap_or(0) } fn human_age(secs: u64) -> String { const UNITS: [(&str, u64); 4] = [("d", 86_400), ("h", 3_600), ("m", 60), ("s", 1)]; for (unit, scale) in UNITS { if secs >= scale { return format!("{}{unit}", secs / scale); } } "just now".to_string() } /// Format a byte count for a report line. fn human_bytes(bytes: u64) -> String { const UNITS: [(&str, f64); 4] = [ ("GiB", 1_073_741_824.0), ("MiB", 1_048_576.0), ("KiB", 1024.0), ("B", 1.0), ]; let b = bytes as f64; for (unit, scale) in UNITS { if b >= scale { return format!("{:.1} {unit}", b / scale); } } "0 B".to_string() } fn cmd_cache(action: CacheCmd) -> Result<()> { match action { CacheCmd::Inspect { deep } => cmd_cache_inspect(deep), CacheCmd::Vacuum => match control_request(CtlRequest::CacheVacuum)? { CtlResponse::Ok { message } => { println!("{message}"); Ok(()) } CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), }, CacheCmd::Clear => match control_request(CtlRequest::PurgeCache)? { CtlResponse::Ok { message } => { println!("{message}"); Ok(()) } CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), }, } } /// Ask for a deep integrity check and wait for it to finish. /// /// The daemon answers the request immediately and runs the check on a thread — /// it holds the single database connection for as long as it takes, which would /// otherwise stall the mount — so a caller that needs the verdict polls for it. /// `pdfs doctor` is that caller: it is a diagnostic the user is watching, so /// waiting is right, and the bound is generous enough for a large database /// without hanging forever if the daemon never finishes. fn await_integrity_check() -> Result { const POLL: std::time::Duration = std::time::Duration::from_secs(1); const GIVE_UP_AFTER: std::time::Duration = std::time::Duration::from_secs(120); let started = std::time::Instant::now(); loop { let response = control_request(CtlRequest::CacheInspect { deep: true })?; match &response { CtlResponse::CacheReport { integrity_running: true, integrity_checked: false, .. } if started.elapsed() < GIVE_UP_AFTER => std::thread::sleep(POLL), _ => return Ok(response), } } } fn cmd_cache_inspect(deep: bool) -> Result<()> { let response = control_request(CtlRequest::CacheInspect { deep })?; if emit_json(&response)? { return Ok(()); } match response { CtlResponse::CacheReport { schema_version, db_bytes, db_reclaimable_bytes, tables, cache_used, cache_budget, integrity_problems, integrity_checked, integrity_running, } => { println!( "Database schema v{schema_version}, {}", human_bytes(db_bytes) ); if db_reclaimable_bytes > 0 { println!( " {} reclaimable — run `pdfs cache vacuum`", human_bytes(db_reclaimable_bytes) ); } let budget = if cache_budget == 0 { "unlimited".to_string() } else { human_bytes(cache_budget) }; println!("Content {} cached of {budget}", human_bytes(cache_used)); println!("\nRows"); for (name, count) in tables { println!(" {name:<16} {count:>10}"); } if integrity_checked { if integrity_problems.is_empty() { println!("\nIntegrity ok"); } else { println!("\nIntegrity {} problem(s):", integrity_problems.len()); for problem in integrity_problems { println!(" {problem}"); } } } else if integrity_running { // The daemon runs the check on a thread rather than holding its // only database connection for the length of it, so the answer // belongs to a later request. println!("\nIntegrity checking in the background — run this again for the result"); } else { println!("\nIntegrity not checked (pass --deep)"); } Ok(()) } CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } } #[derive(Default)] struct DiagnoseReport { warnings: usize, failures: usize, } impl DiagnoseReport { fn finding(&mut self, level: DiagnoseLevel, label: &str, detail: impl AsRef) { let mark = match level { DiagnoseLevel::Ok => "ok ", DiagnoseLevel::Warn => { self.warnings += 1; "WARN" } DiagnoseLevel::Fail => { self.failures += 1; "FAIL" } }; let detail = detail.as_ref(); if detail.is_empty() { println!("[{mark}] {label}"); } else { println!("[{mark}] {label}: {detail}"); } } fn check(&mut self, label: &str, ok: bool, detail: impl AsRef) { self.finding( if ok { DiagnoseLevel::Ok } else { DiagnoseLevel::Fail }, label, detail, ); } } #[derive(Clone, Copy)] enum DiagnoseLevel { Ok, Warn, Fail, } fn executable_on_path(name: &str) -> bool { std::env::var_os("PATH").is_some_and(|paths| { std::env::split_paths(&paths) .any(|dir| std::fs::metadata(dir.join(name)).is_ok_and(|m| m.is_file())) }) } fn mounted_fs_type(path: &Path) -> Option { let wanted = path.canonicalize().ok()?; let mounts = std::fs::read_to_string("/proc/self/mountinfo").ok()?; mounts .lines() .filter_map(|line| { let fields: Vec<_> = line.split_whitespace().collect(); let separator = fields.iter().position(|field| *field == "-")?; let encoded = *fields.get(4)?; let decoded = encoded.replace("\\040", " ").replace("\\011", "\t"); let mountpoint = PathBuf::from(decoded); if mountpoint.canonicalize().ok()? == wanted { Some(fields.get(separator + 1)?.to_string()) } else { None } }) .next() } /// Create, sync, and remove a new file without ever replacing an existing one. fn writable_probe(dir: &Path) -> std::io::Result<()> { let name = format!(".pdfs-diagnose-probe-{}", std::process::id()); let path = dir.join(name); let mut file = OpenOptions::new() .write(true) .create_new(true) .open(&path)?; let result = (|| { file.write_all(b"probe")?; file.sync_all() })(); drop(file); let remove = std::fs::remove_file(path); result.and(remove) } /// Self-check the installation, reporting each finding and exiting non-zero if /// any check failed. /// /// Every check is written to work with no daemon running, because that is the /// situation a user runs this in. A missing daemon is reported as a finding, /// not an error that aborts the rest of the report. fn cmd_diagnose() -> Result<()> { let mut report = DiagnoseReport::default(); let dirs = AppDirs::new().context("resolve application directories")?; let config = dirs.load_config(); println!("Paths"); let state = dirs.state_dir(); report.check( " state dir", state.is_dir(), format!("{}", state.display()), ); let cache_dir = dirs.cache_dir(); report.check( " cache dir", cache_dir.is_dir(), format!("{}", cache_dir.display()), ); // Writability is checked by actually writing: permission bits do not // account for a full disk, a read-only remount, or an immutable flag. match writable_probe(&state) { Ok(()) => report.finding( DiagnoseLevel::Ok, " state dir writable", "write and fsync succeeded", ), Err(e) => report.finding(DiagnoseLevel::Fail, " state dir writable", e.to_string()), } let db_path = dirs.db_path(); let db_size = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0); report.check( " database", db_path.is_file(), format!("{} ({})", db_path.display(), human_bytes(db_size)), ); println!("\nFUSE"); let fuse = Path::new("/dev/fuse"); report.check(" /dev/fuse", fuse.exists(), fuse.display().to_string()); let has_fusermount = executable_on_path("fusermount3") || executable_on_path("fusermount"); report.check( " unmount helper", has_fusermount, "fusermount3 or fusermount on PATH", ); println!("\nAccount"); match pdfs_core::auth::load() { Ok(session) => { report.finding(DiagnoseLevel::Ok, " keyring session", session.username); } Err(e) => { // A locked keyring and an absent login look the same from here, so // the detail carries the distinction rather than the verdict. report.finding(DiagnoseLevel::Fail, " keyring session", format!("{e}")); } } println!("\nDaemon"); let socket = dirs.control_socket(); let socket_exists = socket.exists(); let socket_kind_ok = std::fs::symlink_metadata(&socket).is_ok_and(|m| m.file_type().is_socket()); report.finding( if socket_exists && !socket_kind_ok { DiagnoseLevel::Fail } else if socket_exists { DiagnoseLevel::Ok } else { DiagnoseLevel::Warn }, " control socket", socket.display().to_string(), ); // A stale socket file with no listener is the common failure, so the reply // is what decides this rather than the file's existence. match control_request(CtlRequest::Status) { Ok(CtlResponse::Status { mountpoint, online, pending_uploads, pending_changes, .. }) => { report.finding(DiagnoseLevel::Ok, " daemon responding", ""); let live_type = mounted_fs_type(Path::new(&mountpoint)); let is_fuse = live_type .as_deref() .is_some_and(|kind| kind.starts_with("fuse")); report.finding( if is_fuse { DiagnoseLevel::Ok } else { DiagnoseLevel::Fail }, " active mount", live_type.map_or_else( || format!("{mountpoint} is not mounted"), |kind| format!("{mountpoint} ({kind})"), ), ); report.finding( if online { DiagnoseLevel::Ok } else { DiagnoseLevel::Warn }, " network", if online { "online" } else { "offline; cached data remains available" }, ); let pending = pending_uploads + pending_changes; report.finding( if pending == 0 { DiagnoseLevel::Ok } else { DiagnoseLevel::Warn }, " queued writes", if pending == 0 { "none".to_string() } else { format!("{pending} waiting to upload") }, ); match await_integrity_check() { Ok(CtlResponse::CacheReport { integrity_problems, integrity_checked: true, .. }) if integrity_problems.is_empty() => report.finding( DiagnoseLevel::Ok, " database integrity", "SQLite integrity_check passed", ), Ok(CtlResponse::CacheReport { integrity_checked: false, .. }) => report.finding( DiagnoseLevel::Warn, " database integrity", "still checking — run `pdfs cache inspect --deep` for the result", ), Ok(CtlResponse::CacheReport { integrity_problems, .. }) => report.finding( DiagnoseLevel::Fail, " database integrity", format!( "{} problem(s): {}", integrity_problems.len(), integrity_problems.join("; ") ), ), Ok(other) => report.finding( DiagnoseLevel::Fail, " database integrity", format!("unexpected: {other:?}"), ), Err(e) => report.finding( DiagnoseLevel::Fail, " database integrity", format!("check failed: {e:#}"), ), } } Ok(other) => { report.finding( DiagnoseLevel::Fail, " daemon responding", format!("unexpected: {other:?}"), ); } Err(e) => { // Not a failure of the installation: a stopped daemon is a normal // state, and the rest of the report is still worth printing. report.finding( DiagnoseLevel::Warn, " daemon responding", format!("stopped or unreachable: {e:#}"), ); println!(" (start it with `systemctl --user start proton-drive.service`)"); } } let mountpoint = dirs.resolved_mountpoint(&config); report.finding( if mountpoint.is_dir() { DiagnoseLevel::Ok } else { DiagnoseLevel::Warn }, " configured mountpoint", mountpoint.display().to_string(), ); if report.failures == 0 { if report.warnings == 0 { println!("\nNo problems found."); } else { println!( "\nNo critical problems found; {} warning(s) need attention.", report.warnings ); } Ok(()) } else { bail!( "{} check(s) failed and {} warning(s) were reported; see above", report.failures, report.warnings ) } } /// Send one request to the running mount daemon's control socket and read its /// reply. Errors if no daemon is listening. fn control_request(req: CtlRequest) -> Result { let socket = AppDirs::new()?.control_socket(); pdfs_core::control::send(&socket, &req) .with_context(|| format!("talk to mount daemon at {}", socket.display())) } #[cfg(test)] mod pause_tests { use super::*; #[test] fn pause_durations_read_minutes_hours_and_days() { assert_eq!(parse_pause_duration("30m"), Ok(1800)); assert_eq!(parse_pause_duration("1h"), Ok(3600)); assert_eq!(parse_pause_duration("2d"), Ok(172_800)); } #[test] fn pause_durations_reject_what_they_cannot_read() { assert!(parse_pause_duration("").is_err()); assert!(parse_pause_duration("0h").is_err()); assert!(parse_pause_duration("1w").is_err()); assert!(parse_pause_duration("h").is_err()); assert!(parse_pause_duration("-1h").is_err()); assert!(parse_pause_duration("1ä").is_err()); } } #[cfg(test)] mod diagnose_tests { use super::*; #[test] fn finding_severity_is_counted_centrally() { let mut report = DiagnoseReport::default(); report.finding(DiagnoseLevel::Ok, "ok", ""); report.finding(DiagnoseLevel::Warn, "warn", ""); report.finding(DiagnoseLevel::Fail, "fail", ""); assert_eq!(report.warnings, 1); assert_eq!(report.failures, 1); } #[test] fn writable_probe_never_replaces_an_existing_file() { let dir = std::env::temp_dir().join(format!( "pdfs-diagnose-test-{}-{:?}", std::process::id(), std::thread::current().id() )); std::fs::create_dir_all(&dir).unwrap(); let collision = dir.join(format!(".pdfs-diagnose-probe-{}", std::process::id())); std::fs::write(&collision, b"keep me").unwrap(); assert_eq!( writable_probe(&dir).unwrap_err().kind(), std::io::ErrorKind::AlreadyExists ); assert_eq!(std::fs::read(&collision).unwrap(), b"keep me"); std::fs::remove_file(collision).unwrap(); std::fs::remove_dir(dir).unwrap(); } } #[cfg(test)] mod login_wait_tests { use super::*; use std::time::Duration; #[test] fn poll_delay_doubles_then_holds_at_the_ceiling() { assert_eq!(login_poll_delay(0), Duration::from_secs(3)); assert_eq!(login_poll_delay(1), Duration::from_secs(6)); assert_eq!(login_poll_delay(2), Duration::from_secs(12)); assert_eq!(login_poll_delay(3), Duration::from_secs(15)); assert_eq!(login_poll_delay(4), Duration::from_secs(15)); // A daemon left unauthenticated for weeks reaches attempt counts well // past the width of the shift; that must cap, not panic. assert_eq!(login_poll_delay(64), Duration::from_secs(15)); assert_eq!(login_poll_delay(u32::MAX), Duration::from_secs(15)); } #[test] fn waiting_for_a_session_stops_hammering_the_keyring() { let mut checks_left = 20; let mut slept: Vec = Vec::new(); wait_for_session( || { if checks_left == 0 { Ok(()) } else { checks_left -= 1; Err(pdfs_core::Error::NotLoggedIn) } }, |delay| slept.push(delay), ) .unwrap(); assert_eq!(slept.len(), 20, "one wait per failed check"); assert!( slept.windows(2).all(|w| w[1] >= w[0]), "the interval must never shrink: {slept:?}" ); // The point of the change. Every check is a fresh D-Bus session against // the Secret Service, so 20 of them must not fit inside a single minute // the way a flat 3s poll did. let total: Duration = slept.iter().sum(); assert!( total >= Duration::from_secs(4 * 60), "20 checks should span at least 4 minutes, spanned {total:?}" ); } #[test] fn a_stored_session_is_not_waited_for_at_all() { let mut slept = 0; wait_for_session(|| Ok(()), |_| slept += 1).unwrap(); assert_eq!(slept, 0); } #[test] fn epoch_seconds_format_as_utc_calendar_dates() { assert_eq!(format_epoch_utc(0), "1970-01-01"); assert_eq!(format_epoch_utc(951_782_400), "2000-02-29"); assert_eq!(format_epoch_utc(1_700_000_000), "2023-11-14"); assert_eq!(format_epoch_utc(-86_400), "1969-12-31"); } #[test] fn a_forged_looking_invitation_is_flagged_not_stated() { let mut entry = pdfs_core::control::DirEntry { name: "Budget.ods".into(), is_dir: false, size: 1, modified: 0, pinned: false, cached: false, uid: "vol~link".into(), path: String::new(), role: "viewer".into(), shared_by: String::new(), shared_at: 0, shared_by_unverified: false, }; assert_eq!(shared_by_line(&entry), None, "no invitation, no line"); entry.shared_by = "alice@proton.me".into(); entry.shared_at = 1_700_000_000; assert_eq!( shared_by_line(&entry).as_deref(), Some("shared by alice@proton.me at 2023-11-14") ); entry.shared_by_unverified = true; assert!(shared_by_line(&entry).unwrap().contains("WARNING")); } }