Files
proton-drive-linux/crates/pdfs-cli/src/main.rs
T
nvrl 2b6c37f543 fix(fuse,core,cli,gui): say when the keyring refuses the refreshed sign-in
A rotation the keyring refused was retried and kept in the kernel
keyring, but only the log said so, and a reboot before the keyring took
it still signed the user out.

`Response::Status` carries `tokens_unsaved` while one waits. The tray
says "Sign-in not saved — unlock your keyring" and needs attention, the
app's status says the same, `pdfs status` prints a Keyring line and
`pdfs diagnose` warns. New strings are translated in every catalog
(B61).
2026-10-09 12:22:02 +02:00

3825 lines
140 KiB
Rust

//! `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::{
ConflictKeep, ErrorKind, MountKind, RefreshScope, Request as CtlRequest,
Response as CtlResponse, RestoreItem as CtlRestoreItem, ShareEntryKind, SyncIssue, SyncPhase,
pending_summary,
};
use pdfs_core::db::Db;
use pdfs_core::service;
/// `println!` for this binary, shadowing std's, which panics when stdout is
/// closed: `pdfs ls | head` ended in a "Broken pipe" panic once `head` had its
/// lines. This one hands the failure to [`stdout_written`] instead.
///
/// Restoring the default `SIGPIPE` would also stop the panic, but the same
/// binary runs the daemon, which must not die when a socket's peer goes away.
macro_rules! println {
($($arg:tt)*) => {{
use std::io::Write as _;
$crate::stdout_written(writeln!(std::io::stdout(), $($arg)*))
}};
}
/// [`println!`] without the newline.
macro_rules! print {
($($arg:tt)*) => {{
use std::io::Write as _;
$crate::stdout_written(write!(std::io::stdout(), $($arg)*))
}};
}
/// Exit on a failed write to stdout. A closed pipe means the reader has all it
/// wanted, so that exit is a success.
fn stdout_written(result: std::io::Result<()>) {
if let Err(error) = result {
if error.kind() == std::io::ErrorKind::BrokenPipe {
std::process::exit(0);
}
eprintln!("Error: writing to stdout: {error}");
std::process::exit(1);
}
}
#[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`, `trash`, `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<serde_json::Value> {
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<bool> {
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<String>,
/// For a two-password account, keep the mailbox password's unlock in the
/// system keyring so the daemon starts without asking after every login.
/// Without it the unlock lives in kernel memory for this session only.
#[arg(long)]
remember: bool,
},
/// Enter the mailbox password of a two-password account so the daemon can
/// unlock your files. Needed once per login session.
Unlock {
/// Keep the unlock in the system keyring instead of kernel memory only.
#[arg(long)]
remember: bool,
},
/// Forget the unlock held in kernel memory; the account is locked again.
Lock {
/// Also remove an unlock kept in the keyring with `--remember`.
#[arg(long)]
forget: bool,
},
/// 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<PathBuf>,
},
/// 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<PathBuf>,
},
/// 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<PathBuf>,
},
/// 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 favorited photos.
#[arg(long)]
favorites: bool,
},
/// Add or remove a photo's favorite 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<PathBuf>,
/// 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 — favorites 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<usize>,
/// 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<String>,
/// Only touch photos whose current capture time is before this date
/// (`YYYY-MM-DD`, UTC).
#[arg(long)]
to: Option<String>,
/// 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,
},
/// Create a photo album and print its uid.
NewAlbum {
/// Album name.
name: String,
},
/// Rename a photo album.
RenameAlbum {
/// Album node uid in `volume~link` form (from `pdfs albums`).
uid: String,
/// New name.
name: String,
},
/// Delete a photo album. Its photos stay in the timeline.
DeleteAlbum {
/// Album node uid in `volume~link` form (from `pdfs albums`).
uid: String,
/// Also delete photos that exist only in this album (saved from a
/// shared album); without it the server refuses to delete such an album.
#[arg(long)]
with_photos: bool,
},
/// Add photos to an album.
AddToAlbum {
/// Album node uid in `volume~link` form (from `pdfs albums`).
uid: String,
/// Photo node uids (from `pdfs photos`).
#[arg(required = true)]
photos: Vec<String>,
},
/// Take photos out of an album. They stay in the timeline.
RemoveFromAlbum {
/// Album node uid in `volume~link` form (from `pdfs albums`).
uid: String,
/// Photo node uids (from `pdfs album <uid>`).
#[arg(required = true)]
photos: Vec<String>,
},
/// 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,
/// Only search below this folder (under the mountpoint).
#[arg(long = "in", value_name = "FOLDER")]
scope: Option<PathBuf>,
},
/// Rename a file or folder via the running daemon.
Rename {
/// File/folder path, inside My files or an on-demand synced folder, or
/// relative to the mountpoint.
path: PathBuf,
/// New name (a single path component).
new_name: String,
},
/// Move files or folders into another folder via the running daemon.
///
/// The paths may be in different locations: My files and any synced
/// folder, on-demand or mirrored. The move happens on Proton Drive, so
/// nothing is downloaded or uploaded again, unlike `mv` between two
/// locations. The last path is the destination folder.
Move {
/// Files/folders to move, then the destination folder: each inside a
/// location, or relative to the mountpoint.
#[arg(required = true, num_args = 2.., value_name = "PATH")]
paths: Vec<PathBuf>,
},
/// Trash a file or folder via the running daemon.
Rm {
/// File/folder path, inside My files or an on-demand synced folder, or
/// relative to the mountpoint.
path: PathBuf,
},
/// Create a folder via the running daemon.
Mkdir {
/// Parent folder path, inside My files or an on-demand synced folder,
/// or relative to the mountpoint.
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<PathBuf>,
/// 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<String>,
},
/// 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<String>,
},
/// 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<String>,
/// 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,
},
/// List `(sync-conflict …)` copies, or resolve one.
Conflicts {
#[command(subcommand)]
action: Option<ConflictCmd>,
},
/// 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<String>,
/// 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<String>,
},
/// 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,
},
/// Stop sharing a node, or remove one member or pending invitation.
///
/// With only a path, removes the public link, every invitation and every
/// member. With an id and kind (from `pdfs members`), removes that entry.
Unshare {
/// File/folder path, inside the mountpoint or relative to it.
path: PathBuf,
/// Entry id (membership or invitation id), from `pdfs members`.
#[arg(requires = "kind")]
id: Option<String>,
/// Entry kind: member, proton or external.
kind: Option<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<String>,
},
/// 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<PathBuf>,
},
/// 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<String>,
/// Drop the adoption and go back to matching by hostname.
#[arg(long, conflicts_with = "uid")]
clear: bool,
},
/// Move another computer's folders into this computer's backup and sync
/// them here. The other computer stays registered, without those folders.
Migrate {
/// Device uid (from `devices list`).
uid: String,
/// Skip the confirmation and accept every proposed local path.
#[arg(long)]
yes: bool,
},
}
#[derive(Subcommand)]
enum ConflictCmd {
/// Decide which side of a conflict to keep. Whatever is removed goes to Trash.
Resolve {
/// The conflict copy, absolute or relative to the mountpoint.
path: PathBuf,
/// `original` trashes the copy; `copy` trashes the original and gives the
/// copy its name; `both` renames the copy to `--name`.
#[arg(long, value_parser = ["original", "copy", "both"])]
keep: String,
/// New name for the copy with `--keep both`.
#[arg(long, required_if_eq("keep", "both"))]
name: Option<String>,
},
}
#[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<i64>,
},
/// 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, conflicts_with = "folder")]
duration: Option<u64>,
/// Pause only this mirror folder (id from `sync list`) until resumed.
#[arg(long)]
folder: Option<i64>,
},
/// Resume syncing after `pdfs sync pause`.
Resume {
/// Resume only this folder, paused with `sync pause --folder`.
#[arg(long)]
folder: Option<i64>,
},
/// Cap upload and download speed, or show the caps with no options.
Limit {
/// Upload cap per second, e.g. `500K` or `2M`; `0` removes it.
#[arg(long, value_parser = parse_rate)]
up: Option<u64>,
/// Download cap per second, e.g. `500K` or `2M`; `0` removes it.
#[arg(long, value_parser = parse_rate)]
down: Option<u64>,
},
/// List uploads and changes not yet on Proton Drive.
Queue,
/// List queued changes Proton Drive refused or that keep failing, and what to do.
Issues,
/// Save the content of a queued upload (by id from `sync queue`) to a file.
Export {
/// Queue id.
id: i64,
/// Where to save it: a new file, or an existing folder to save it in.
dest: PathBuf,
},
/// Drop a queued change (by id from `sync queue`) and undo it here: a new file
/// or folder goes, anything else goes back to what Proton Drive has. Its
/// content is lost unless saved with `sync export` first.
Discard {
/// Queue id.
id: i64,
/// Do not ask first.
#[arg(long)]
yes: bool,
},
/// 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<i64>,
},
/// 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,
/// Restore another computer's folders instead, by device uid (from
/// `pdfs devices list`).
#[arg(long)]
device: Option<String>,
},
}
#[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<String>,
/// Optional expiry, Unix epoch seconds.
#[arg(long)]
expires: Option<i64>,
},
/// 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<String>,
},
/// 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, remember } => cmd_login(username, remember),
Command::Unlock { remember } => cmd_unlock(remember),
Command::Lock { forget } => cmd_lock(forget),
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::NewAlbum { name } => cmd_new_album(name),
Command::RenameAlbum { uid, name } => {
ok_or_bail(control_request(CtlRequest::RenameAlbum { uid, name })?)
}
Command::DeleteAlbum { uid, with_photos } => {
ok_or_bail(control_request(CtlRequest::DeleteAlbum {
uid,
delete_photos: with_photos,
})?)
}
Command::AddToAlbum { uid, photos } => {
cmd_album_change(CtlRequest::AddToAlbum { uid, photos }, "added")
}
Command::RemoveFromAlbum { uid, photos } => {
cmd_album_change(CtlRequest::RemoveFromAlbum { uid, photos }, "removed")
}
Command::OpenPhoto { uid } => cmd_open_photo(uid),
Command::Search {
query,
limit,
scope,
} => cmd_search(query, limit, scope),
Command::Rename { path, new_name } => cmd_rename(path, new_name),
Command::Move { mut paths } => {
let new_parent = paths.pop().expect("clap requires at least two paths");
cmd_move(paths, 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::Conflicts { action } => cmd_conflicts(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 })?)?
}
DeviceCmd::Migrate { uid, yes } => {
if !yes {
println!(
"Every folder that device backs up moves into this computer's backup.\n\
The device stays registered, without those folders. Stop Proton Drive\n\
on that computer first, or it sees its folders disappear."
);
confirm("Move them here?")?;
}
let response = control_request(CtlRequest::MigrateDevice { uid })?;
// A script gets the one reply and runs `sync restore` itself.
if emit_json(&response)? {
return Ok(());
}
ok_or_bail(response)?;
// The folders are this device's now, so the plain restore offers them.
cmd_sync_restore(yes, None)?
}
}
Ok(())
}
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(),
};
let mode = match f.paused {
true => format!("{mode}, paused"),
false => mode,
};
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 {
folder: Some(id), ..
} => ok_or_bail(control_request(CtlRequest::SetSyncFolderPaused {
id,
paused: true,
})?)?,
SyncCmd::Resume { folder: Some(id) } => {
ok_or_bail(control_request(CtlRequest::SetSyncFolderPaused {
id,
paused: false,
})?)?
}
SyncCmd::Pause {
duration,
folder: None,
} => {
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::Issues => return cmd_sync_issues(),
SyncCmd::Export { id, dest } => return cmd_sync_export(id, &dest),
SyncCmd::Limit { up, down } => return cmd_sync_limit(up, down),
SyncCmd::Discard { id, yes } => {
if !yes {
confirm(&format!(
"Discard queued change {id} and undo it here? Content not exported is lost."
))?;
}
ok_or_bail(control_request(CtlRequest::DiscardPendingOp { id })?)?
}
SyncCmd::Retry { id } => ok_or_bail(control_request(CtlRequest::RetryPendingOp { id })?)?,
SyncCmd::Resume { folder: None } => {
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, device } => return cmd_sync_restore(yes, device),
}
Ok(())
}
/// `pdfs conflicts`: list conflict copies, or resolve one.
fn cmd_conflicts(action: Option<ConflictCmd>) -> Result<()> {
if let Some(ConflictCmd::Resolve { path, keep, name }) = action {
let keep = match keep.as_str() {
"original" => ConflictKeep::Original,
"copy" => ConflictKeep::Copy,
_ => ConflictKeep::Both {
name: name.unwrap_or_default(),
},
};
let path = path_arg(&path)?;
return ok_or_bail(control_request(CtlRequest::ResolveConflict { path, keep })?);
}
let response = control_request(CtlRequest::ListConflicts)?;
if emit_json(&response)? {
return Ok(());
}
match response {
CtlResponse::Conflicts { items } if items.is_empty() => println!("No conflicts."),
CtlResponse::Conflicts { items } => {
for c in items {
println!("{}", c.path);
let verdict = match (c.original_exists, c.identical) {
(false, _) => "original is gone".to_string(),
(true, true) => format!("identical to {}", c.original_path),
(true, false) => format!(
"differs from {} ({} vs {})",
c.original_path,
human_bytes(c.size),
human_bytes(c.original_size.unwrap_or_default()),
),
};
println!(" {verdict}");
}
}
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
}
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(issue) = op.issue {
println!(" needs attention: {}", issue_text(issue));
}
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 issues`: the queued changes that need the user, and what to do.
fn cmd_sync_issues() -> Result<()> {
let response = control_request(CtlRequest::ListPendingOps)?;
let items = match response {
CtlResponse::PendingOps { items } => items,
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
};
let issues: Vec<_> = items.into_iter().filter(|op| op.failing).collect();
if json_enabled() {
println!("{}", serde_json::to_string_pretty(&issues)?);
return Ok(());
}
if issues.is_empty() {
println!("No sync issues.");
return Ok(());
}
for op in issues {
println!("[{}] {} {}", op.id, op.kind, op.path);
match op.issue {
Some(issue) => println!(" {}", issue_text(issue)),
None => println!(" keeps failing; it is retried."),
}
if let Some(error) = &op.last_error {
println!(" last error: {error}");
}
if op.exportable {
println!(
" save a copy: pdfs sync export {} <file or folder>",
op.id
);
}
}
Ok(())
}
/// What a sync issue means, and what the user can do about it.
fn issue_text(issue: SyncIssue) -> &'static str {
match issue {
SyncIssue::Quota => {
"Proton Drive storage is full. Free up space or upgrade; the change is retried."
}
SyncIssue::Access => {
"you can no longer change this on Proton Drive, for example because a share was \
revoked. Save a copy to keep the content."
}
SyncIssue::Missing => {
"the file or its folder is gone from Proton Drive. Save a copy to keep the content."
}
SyncIssue::Limit => {
"the folder holds too many items or is nested too deep. Move the file to another \
folder."
}
SyncIssue::Rejected => {
"Proton Drive rejected the change, for example the name. Rename the file."
}
SyncIssue::Unknown => "Proton Drive refused the change.",
}
}
/// `pdfs sync export`: save a queued upload's content. A folder as `dest`
/// gets the file under its own name.
fn cmd_sync_export(id: i64, dest: &Path) -> Result<()> {
let mut dest =
std::path::absolute(dest).with_context(|| format!("resolving {}", dest.display()))?;
if dest.is_dir() {
let items = match control_request(CtlRequest::ListPendingOps)? {
CtlResponse::PendingOps { items } => items,
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
};
let Some(op) = items.iter().find(|op| op.id == id) else {
bail!("no queued change {id}; see `pdfs sync queue`");
};
let name = Path::new(&op.path)
.file_name()
.with_context(|| format!("{} has no file name", op.path))?;
dest.push(name);
}
ok_or_bail(control_request(CtlRequest::ExportPendingOp {
id,
dest: dest.display().to_string(),
})?)
}
/// `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, device: Option<String>) -> Result<()> {
let response = control_request(match &device {
Some(device) => CtlRequest::ListDeviceRestorableFolders {
device: device.clone(),
},
None => 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<CtlRestoreItem> = 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(match device {
Some(device) => CtlRequest::RestoreDeviceFolders {
device,
items: chosen,
},
None => 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<String>,
role: String,
message: Option<String>,
) -> 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: Option<String>, kind: Option<ShareKindArg>) -> Result<()> {
let path = path_arg(&path)?;
let request = match (id, kind) {
(Some(id), Some(kind)) => CtlRequest::RemoveShareEntry {
path,
id,
kind: kind.to_kind(),
},
_ => CtlRequest::StopSharing { path },
};
ok_or_bail(control_request(request)?)
}
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<String>) -> 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<String> {
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<PathBuf>) -> 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<String> {
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<String>, remember: bool) -> 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<String> {
prompt_line("2FA code").map_err(|e| pdfs_core::Error::Other(format!("read 2FA code: {e}")))
};
// Only a two-password account is ever asked for this.
let get_mailbox = |attempt: u32| prompt_mailbox(attempt, remember);
// Proton may ask for a CAPTCHA first, usually on a VPN. The CLI has no
// browser engine, so the user solves the page in their own browser and says
// when they are done; the retry then carries the challenge token.
let get_hv = |hv: pdfs_core::proton_sdk::api::HumanVerification| {
println!(
"Proton is asking for a CAPTCHA before it will accept this sign-in.\n\
Open this page in your browser and complete it:\n\n {}\n",
auth::browser_verification_url(&hv)
);
prompt_line("Press Enter once the page says you are verified")
.map_err(|e| pdfs_core::Error::Other(format!("read verification answer: {e}")))?;
Ok(auth::browser_verified(&hv))
};
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(auth::login_interactive(
&username,
&password,
get_totp,
get_mailbox,
get_hv,
))
.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.");
report_key_state();
Ok(())
}
/// Ask for the mailbox password of a two-password account, without echo.
fn prompt_mailbox(attempt: u32, remember: bool) -> pdfs_core::Result<auth::Mailbox> {
if attempt == 0 {
eprintln!("This account has a separate mailbox password.");
} else {
eprintln!("That mailbox password did not unlock the account. Try again.");
}
let password = rpassword::prompt_password("Mailbox password: ")
.map_err(|e| pdfs_core::Error::Other(format!("read mailbox password: {e}")))?;
Ok(auth::Mailbox {
password: zeroize::Zeroizing::new(password),
remember,
})
}
/// Say where the account's keys are held, and what that means for the next login.
fn report_key_state() {
match auth::key_state() {
Ok(auth::KeyState::Session) => println!(
"Unlocked for this login session only. After you log out or reboot, run `pdfs unlock`."
),
Ok(auth::KeyState::Locked) => println!("The account is locked. Run `pdfs unlock`."),
_ => {}
}
}
fn cmd_unlock(remember: bool) -> Result<()> {
auth::load().context("not logged in (run `pdfs login` first)")?;
let rt = tokio::runtime::Runtime::new()?;
// Bounded like the login prompt: three wrong answers end the command.
let mut attempt = 0;
loop {
let mailbox = prompt_mailbox(attempt, remember)?;
match rt.block_on(auth::unlock(&mailbox.password, remember)) {
Ok(()) => break,
Err(pdfs_core::Error::WrongMailboxPassword) if attempt < 2 => attempt += 1,
Err(e) => return Err(anyhow::Error::new(e).context("unlock failed")),
}
}
// A daemon parked on the locked account would otherwise notice on its next
// poll; restarting it makes the mount appear now, as after `pdfs login`.
if service::is_active() {
service::restart();
}
println!("Unlocked.");
report_key_state();
Ok(())
}
fn cmd_lock(forget: bool) -> Result<()> {
auth::lock(forget)?;
println!(
"Locked. {}",
if forget {
"Unlock removed from memory and keyring."
} else {
"Run `pdfs unlock` to unlock again."
}
);
// The running daemon keeps its keys until it restarts; say so, since
// "locked" would otherwise suggest the mount stopped.
if service::is_active() {
println!(
"The running daemon keeps its keys until it restarts: `systemctl --user restart proton-drive`."
);
}
Ok(())
}
/// `stored`, `session` or `locked`: where the account's keys are held.
fn key_state_name() -> &'static str {
match auth::key_state() {
Ok(auth::KeyState::Stored) => "stored",
Ok(auth::KeyState::Session) => "session",
_ => "locked",
}
}
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()),
"keys": session.as_ref().map(|_| key_state_name()),
"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(", "));
match key_state_name() {
"locked" => println!("Keys: locked — run `pdfs unlock`"),
"session" => println!("Keys: unlocked for this login session"),
_ => {}
}
}
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,
session_expired,
tokens_unsaved,
..
}) => {
let state = match (online, paused) {
_ if session_expired => ", session expired: run `pdfs login`",
(_, 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"),
);
}
if tokens_unsaved {
println!(
"Keyring refuses the refreshed sign-in; unlock it, or a reboot needs `pdfs login`"
);
}
}
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<PathBuf>) -> 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 and unlocked, 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<L, S>(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(pdfs_core::Error::Locked) => {
let delay = login_poll_delay(attempts);
if attempts == 0 {
tracing::info!("account is locked; waiting for `pdfs unlock`…");
} else {
tracing::debug!(delay_secs = delay.as_secs(), "account still locked");
}
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<PathBuf>) -> Result<()> {
// The daemon holds the account's key passphrases in memory; keep them out
// of core dumps and away from same-user ptrace.
pdfs_core::kernelkey::forbid_dumps();
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::ready, pdfs_fuse::idle_sleep)?;
match mount_once(mountpoint.clone()) {
Ok(pdfs_fuse::MountOutcome::Shutdown) => {
tracing::info!("daemon stopping");
return Ok(());
}
Ok(pdfs_fuse::MountOutcome::ResumeStored) => {
tracing::info!("resuming the session stored in the keyring");
}
Ok(pdfs_fuse::MountOutcome::Unmounted) => {
tracing::warn!("mount ended externally; remounting in 2s");
pdfs_fuse::idle_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");
pdfs_fuse::idle_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<PathBuf>) -> Result<pdfs_fuse::MountOutcome> {
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(),
upload_limit: config.upload_limit.unwrap_or(0),
download_limit: config.download_limit.unwrap_or(0),
local_first: config.local_first.unwrap_or(true),
},
);
// Last chance for a rotation the keyring refused when it happened. Only
// that: by now the keyring may hold a newer login, the one this stop was
// restarted for, and the tokens of this session would overwrite it.
if let Err(e) = auth::persist(&session) {
tracing::error!(
error = %e,
"could not save the refreshed session tokens; the next start may need `pdfs login`"
);
}
// Dropping the runtime waits for every blocking task, however long; a call
// still stuck on Drive would hold the stop until systemd kills it (B165).
rt.shutdown_timeout(std::time::Duration::from_secs(2));
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<PathBuf>) -> 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,
not_in_album: false,
})? {
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<pdfs_core::control::PhotoItem>) -> Result<()> {
let uids: Vec<String> = items.iter().map(|p| p.uid.clone()).collect();
let thumbs: HashMap<String, Option<String>> =
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_new_album(name: String) -> Result<()> {
match control_request(CtlRequest::CreateAlbum { name })? {
CtlResponse::AlbumCreated { uid } => println!("{uid}"),
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
}
Ok(())
}
/// Send an album add or remove and report it per photo; any failed photo
/// fails the command.
fn cmd_album_change(request: CtlRequest, verb: &str) -> Result<()> {
match control_request(request)? {
CtlResponse::AlbumChanged { changed, failed } => {
println!("{} {verb}", photo_count(changed.len()));
for failure in &failed {
eprintln!("{}: {}", failure.uid, failure.message);
}
if !failed.is_empty() {
bail!("{} failed", photo_count(failed.len()));
}
}
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
}
Ok(())
}
fn photo_count(n: usize) -> String {
if n == 1 {
"1 photo".to_string()
} else {
format!("{n} photos")
}
}
fn cmd_import_google_photos(archives: Vec<PathBuf>, 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<usize>,
from: Option<String>,
to: Option<String>,
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<i64> {
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, scope: Option<PathBuf>) -> Result<()> {
let scope = scope.map(|p| path_arg(&p)).transpose()?;
match control_request(CtlRequest::Search {
query,
limit,
scope,
})? {
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(())
}
/// Move each of `sources` into `new_parent`, one request each. A refusal names
/// its source and the rest still move; the command fails if any did not.
fn cmd_move(sources: Vec<PathBuf>, new_parent: PathBuf) -> Result<()> {
let new_parent = path_arg(&new_parent)?;
let mut failed = 0;
for source in &sources {
let outcome = path_arg(source).and_then(|path| {
match control_request(CtlRequest::Move {
path,
new_parent: new_parent.clone(),
})? {
CtlResponse::Ok { message } => Ok(message),
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
}
});
match outcome {
Ok(message) => println!("{message}"),
Err(e) if sources.len() == 1 => return Err(e),
Err(e) => {
eprintln!("{}: {e:#}", source.display());
failed += 1;
}
}
}
if failed > 0 {
bail!("{failed} of {} could not be moved", sources.len());
}
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<()> {
let response = control_request(CtlRequest::ListTrash)?;
if emit_json(&response)? {
return Ok(());
}
match response {
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<String>) -> 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<String>) -> 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<String>, 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<PathBuf>, 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<String> {
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<u64, String> {
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::<u64>() {
Ok(n) if n > 0 => Ok(n.saturating_mul(per_unit)),
_ => Err("use a positive whole number, e.g. 30m".to_string()),
}
}
/// Read a bytes-per-second rate: a whole number with an optional `K`, `M` or
/// `G` suffix in the same binary units `human_bytes` prints.
fn parse_rate(text: &str) -> Result<u64, String> {
let text = text.trim();
let (number, scale) = match text.char_indices().last() {
Some((at, 'K' | 'k')) => (&text[..at], 1024),
Some((at, 'M' | 'm')) => (&text[..at], 1_048_576),
Some((at, 'G' | 'g')) => (&text[..at], 1_073_741_824),
_ => (text, 1),
};
number
.parse::<u64>()
.map(|n| n.saturating_mul(scale))
.map_err(|_| "use a whole number with an optional K, M or G, e.g. 2M".to_string())
}
fn human_rate(rate: u64) -> String {
if rate == 0 {
"unlimited".to_string()
} else {
format!("{}/s", human_bytes(rate))
}
}
/// `pdfs sync limit`: set the bandwidth caps, keeping whichever one is not
/// given, or print both when neither is.
fn cmd_sync_limit(up: Option<u64>, down: Option<u64>) -> Result<()> {
let config = AppDirs::new()?.load_config();
let upload = up.or(config.upload_limit).unwrap_or(0);
let download = down.or(config.download_limit).unwrap_or(0);
if up.is_none() && down.is_none() {
println!("Upload {}", human_rate(upload));
println!("Download {}", human_rate(download));
return Ok(());
}
ok_or_bail(control_request(CtlRequest::SetBandwidthLimits {
upload,
download,
})?)
}
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<CtlResponse> {
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<str>) {
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<str>) {
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<String> {
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);
match key_state_name() {
"locked" => report.finding(
DiagnoseLevel::Fail,
" account keys",
"locked — run `pdfs unlock` (the daemon waits for it)",
),
"session" => report.finding(
DiagnoseLevel::Ok,
" account keys",
"unlocked for this login session",
),
_ => report.finding(DiagnoseLevel::Ok, " account keys", "stored in the keyring"),
}
}
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,
session_expired,
tokens_unsaved,
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})"),
),
);
// The keyring check above read the blob, which still holds the
// tokens the daemon could not replace.
if tokens_unsaved {
report.finding(
DiagnoseLevel::Warn,
" session tokens",
"the keyring refuses the refreshed ones; unlock it, or a reboot needs `pdfs login`",
);
}
// A refused session reads as offline too, but no connection will
// bring it back, and the keyring check above cannot tell.
if session_expired {
report.finding(
DiagnoseLevel::Fail,
" session",
"Proton refused it (expired or revoked) — run `pdfs login`",
);
} else {
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<CtlResponse> {
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());
}
#[test]
fn rates_read_plain_bytes_and_binary_suffixes() {
assert_eq!(parse_rate("0"), Ok(0));
assert_eq!(parse_rate("1500"), Ok(1500));
assert_eq!(parse_rate("500K"), Ok(512_000));
assert_eq!(parse_rate("2m"), Ok(2_097_152));
assert!(parse_rate("").is_err());
assert!(parse_rate("M").is_err());
assert!(parse_rate("1.5M").is_err());
assert!(parse_rate("2MB").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<Duration> = 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_locked_account_is_waited_on_until_it_is_unlocked() {
let mut checks = vec![
Ok(()),
Err(pdfs_core::Error::Locked),
Err(pdfs_core::Error::NotLoggedIn),
Err(pdfs_core::Error::Locked),
];
let mut slept = 0;
wait_for_session(|| checks.pop().unwrap(), |_| slept += 1).unwrap();
assert_eq!(slept, 3, "locked and signed-out are both waited out");
}
#[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,
trashed_at: 0,
trashed_from: None,
issue: None,
};
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"));
}
}
#[cfg(test)]
mod unshare_tests {
use super::*;
use clap::Parser;
#[test]
fn unshare_with_only_a_path_stops_sharing() {
let cli = Cli::try_parse_from(["pdfs", "unshare", "docs/report.pdf"]).unwrap();
assert!(matches!(
cli.command,
Command::Unshare {
id: None,
kind: None,
..
}
));
}
#[test]
fn unshare_with_an_id_needs_its_kind() {
assert!(Cli::try_parse_from(["pdfs", "unshare", "docs", "abc"]).is_err());
let cli = Cli::try_parse_from(["pdfs", "unshare", "docs", "abc", "member"]).unwrap();
assert!(matches!(
cli.command,
Command::Unshare {
id: Some(_),
kind: Some(_),
..
}
));
}
}