//! `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; #[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 the photo albums (including ones shared with this account). Albums, /// 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, /// 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, }, /// 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, }, /// 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, } } } #[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, }, /// 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 } => cmd_photos(limit, offset), Command::Albums => cmd_albums(), 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::Trash => cmd_trash(), Command::Restore { uids } => cmd_restore(uids), Command::DeleteForever { uids } => cmd_delete_forever(uids), Command::EmptyTrash => cmd_empty_trash(), Command::Refresh { target } => cmd_refresh(target), 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::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::Mode { id, mode } => { ok_or_bail(control_request(CtlRequest::SetSyncFolderMode { id, mode })?)? } SyncCmd::Restore { yes } => return cmd_sync_restore(yes), } 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_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); } } CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } Ok(()) } /// 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"), })?; 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, .. }) => { let state = if online { "" } else { ", offline" }; let queued = match pending_summary(pending_uploads, pending_changes) { Some(s) => format!(", {s}"), None => String::new(), }; println!("Mounted at {mountpoint} ({pinned} pinned{state}{queued})"); } 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(()) } /// 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. loop { match auth::load() { Ok(_) => break, Err(pdfs_core::Error::NotLoggedIn) => { tracing::info!("not logged in; waiting…"); std::thread::sleep(std::time::Duration::from_secs(3)); } Err(e) => return Err(e.into()), } } 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) -> Result<()> { match control_request(CtlRequest::PhotosTimeline { offset, limit, kind: None, range: None, })? { 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 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)"); println!("{} {} {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_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(()) } 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) -> Result<()> { let scope = match target.as_deref() { Some("trash") => RefreshScope::Trash, Some("photos") => RefreshScope::Photos, path => RefreshScope::Dir { path: path.unwrap_or("").to_string(), }, }; 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:?}"), } Ok(()) } /// 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 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:?}"), }, } } 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, } => { 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 { 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 control_request(CtlRequest::CacheInspect { deep: true }) { Ok(CtlResponse::CacheReport { integrity_problems, .. }) if integrity_problems.is_empty() => report.finding( DiagnoseLevel::Ok, " database integrity", "SQLite integrity_check passed", ), 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 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(); } }