mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
4222 lines
155 KiB
Rust
4222 lines
155 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, LinkChange, MountKind, RefreshScope, Request as CtlRequest,
|
|
Response as CtlResponse, RestoreItem as CtlRestoreItem, ShareEntryKind, SyncIssue, SyncPhase,
|
|
daemon_outdated, pending_summary,
|
|
};
|
|
use pdfs_core::db::Db;
|
|
use pdfs_core::service;
|
|
|
|
mod journal;
|
|
|
|
/// `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 or folder on this device (download + cache it). A folder's
|
|
/// files download in the background; `pdfs transfers` shows the progress.
|
|
/// Needs a running mount.
|
|
Pin {
|
|
/// File or folder path, inside the mountpoint or relative to it.
|
|
path: PathBuf,
|
|
},
|
|
/// Stop keeping a file or folder on this device, evicting its cached content.
|
|
Unpin {
|
|
/// File or folder path, inside the mountpoint or relative to it.
|
|
path: PathBuf,
|
|
},
|
|
/// List files and folders 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>,
|
|
},
|
|
/// Copy files or folders into another folder in My files via the running
|
|
/// daemon.
|
|
///
|
|
/// The copy happens on Proton Drive, so nothing is downloaded or uploaded,
|
|
/// unlike `cp` inside the mount. A name already taken in the destination
|
|
/// gets a " (1)" suffix. The last path is the destination folder.
|
|
Copy {
|
|
/// Files/folders to copy, then the destination folder: inside My files,
|
|
/// or relative to the mountpoint.
|
|
#[arg(required = true, num_args = 2.., value_name = "PATH")]
|
|
paths: Vec<PathBuf>,
|
|
},
|
|
/// Show how much a folder holds on Proton Drive.
|
|
///
|
|
/// Proton Drive adds it up, so nothing is downloaded. The size is the
|
|
/// encrypted bytes on storage and includes items in the trash.
|
|
Du {
|
|
/// Folder path, inside My files or relative to the mountpoint.
|
|
path: 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,
|
|
},
|
|
/// Sync the same folders as another computer: this machine continues that
|
|
/// computer's device alongside it, and its folders are offered for syncing
|
|
/// here. Both computers keep them in step.
|
|
Join {
|
|
/// Device uid (from `devices list`).
|
|
uid: String,
|
|
/// Skip the confirmation and accept every proposed local path.
|
|
#[arg(long)]
|
|
yes: 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>,
|
|
},
|
|
/// Forget what a synced folder synced before and compare it with Proton
|
|
/// Drive afresh: what one side has alone is copied to the other, and a file
|
|
/// that differs keeps both versions. Nothing is deleted.
|
|
Reset {
|
|
/// Synced-folder id (from `sync list`).
|
|
id: 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>,
|
|
},
|
|
/// Change a node's public link. The URL stays the same; an option not
|
|
/// given is kept.
|
|
Update {
|
|
/// File/folder path, inside the mountpoint or relative to it.
|
|
path: PathBuf,
|
|
/// Public-link id, from `pdfs members`.
|
|
id: String,
|
|
/// New role: viewer or editor.
|
|
#[arg(long)]
|
|
role: Option<String>,
|
|
/// New custom password protecting the link.
|
|
#[arg(long, conflicts_with = "no_password")]
|
|
password: Option<String>,
|
|
/// Remove the custom password.
|
|
#[arg(long)]
|
|
no_password: bool,
|
|
/// New expiry, Unix epoch seconds.
|
|
#[arg(long, conflicts_with = "no_expiry")]
|
|
expires: Option<i64>,
|
|
/// Remove the expiry.
|
|
#[arg(long)]
|
|
no_expiry: bool,
|
|
},
|
|
/// 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<()> {
|
|
let 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());
|
|
if journal::stdout_is_journal() {
|
|
// journalctl colours lines by the priority the prefix gives them, so
|
|
// the escape codes would only be noise in the stored text.
|
|
tracing_subscriber::fmt()
|
|
.with_env_filter(filter)
|
|
.with_ansi(false)
|
|
.event_format(journal::Prioritized(tracing_subscriber::fmt::format()))
|
|
.init();
|
|
} else {
|
|
tracing_subscriber::fmt().with_env_filter(filter).init();
|
|
}
|
|
|
|
let cli = Cli::parse();
|
|
JSON_OUTPUT.store(cli.json, std::sync::atomic::Ordering::Relaxed);
|
|
// The commands that hold the password, the key passphrases or the session
|
|
// tokens in memory: keep them out of core dumps and away from same-user
|
|
// ptrace. Only the daemon did, though `mount` holds the same keys for as
|
|
// long as it runs.
|
|
if matches!(
|
|
cli.command,
|
|
Command::Login { .. }
|
|
| Command::Unlock { .. }
|
|
| Command::Logout
|
|
| Command::Mount { .. }
|
|
| Command::Daemon { .. }
|
|
) {
|
|
pdfs_core::kernelkey::forbid_dumps();
|
|
}
|
|
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::Copy { mut paths } => {
|
|
let new_parent = paths.pop().expect("clap requires at least two paths");
|
|
cmd_copy(paths, new_parent)
|
|
}
|
|
Command::Du { path } => cmd_du(path),
|
|
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::Join { uid, yes } => {
|
|
if !yes {
|
|
println!(
|
|
"This computer syncs into that device alongside it. Its folders are kept\n\
|
|
in step on both computers: an edit or a deletion on one reaches the other.\n\
|
|
New synced folders here go under that device too. Folders already synced\n\
|
|
here stay where they are."
|
|
);
|
|
confirm("Sync with it?")?;
|
|
}
|
|
let response = control_request(CtlRequest::AdoptDevice { uid: Some(uid) })?;
|
|
// A script gets the one reply and runs `sync restore` itself.
|
|
if emit_json(&response)? {
|
|
return Ok(());
|
|
}
|
|
ok_or_bail(response)?;
|
|
// The device is this machine's now, so the plain restore offers its
|
|
// folders.
|
|
cmd_sync_restore(yes, None)?
|
|
}
|
|
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 && p.done == 0 => {
|
|
println!(" scanning…")
|
|
}
|
|
// A first pass has no estimate yet; the count alone
|
|
// shows a long first scan is moving.
|
|
SyncPhase::Scanning if p.total == 0 => {
|
|
println!(" scanning: {} checked", p.done)
|
|
}
|
|
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::Reset { id } => ok_or_bail(control_request(CtlRequest::ResetSyncFolder { 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<()> {
|
|
let response = control_request(CtlRequest::ListShare {
|
|
path: path_arg(&path)?,
|
|
})?;
|
|
if emit_json(&response)? {
|
|
return Ok(());
|
|
}
|
|
match response {
|
|
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 { "" };
|
|
let expiry = l
|
|
.expires
|
|
.map(|at| format!(", expires {}", format_epoch_utc(at)))
|
|
.unwrap_or_default();
|
|
println!("public link ({}{pw}{expiry}): {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,
|
|
})? {
|
|
response if emit_json(&response)? => {}
|
|
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::Update {
|
|
path,
|
|
id,
|
|
role,
|
|
password,
|
|
no_password,
|
|
expires,
|
|
no_expiry,
|
|
} => match control_request(CtlRequest::UpdatePublicLink {
|
|
path: path_arg(&path)?,
|
|
id,
|
|
role,
|
|
password: link_change(password, no_password),
|
|
expires: link_change(expires, no_expiry),
|
|
})? {
|
|
response if emit_json(&response)? => {}
|
|
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(())
|
|
}
|
|
|
|
/// A `--x VALUE` / `--no-x` flag pair as one link option change.
|
|
fn link_change<T>(value: Option<T>, remove: bool) -> LinkChange<T> {
|
|
match (value, remove) {
|
|
(Some(value), _) => LinkChange::Set(value),
|
|
(None, true) => LinkChange::Remove,
|
|
(None, false) => LinkChange::Keep,
|
|
}
|
|
}
|
|
|
|
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",
|
|
Copy => "copy",
|
|
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,
|
|
))
|
|
.map_err(|e| {
|
|
// Proton words this block as "update the app", which the user can't
|
|
// act on: the release is current and only waiting helps.
|
|
let blocked =
|
|
matches!(&e, pdfs_core::Error::Proton(p) if pdfs_core::error::sign_in_blocked(p));
|
|
let e = anyhow::Error::new(e).context("login failed");
|
|
if blocked {
|
|
e.context(
|
|
"Proton is temporarily refusing sign-in from this device (code 2028). \
|
|
Your version is not the problem. Wait several hours, up to a day, \
|
|
without trying again, then sign in.",
|
|
)
|
|
} else {
|
|
e
|
|
}
|
|
})?;
|
|
|
|
// 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<()> {
|
|
if auth::logout()? {
|
|
println!("Signed out: the session is ended on Proton and cleared here.");
|
|
} else {
|
|
println!(
|
|
"Stored session cleared. Proton could not be reached to end the session; \
|
|
it stays valid until it expires or is signed out in your account's security settings."
|
|
);
|
|
}
|
|
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,
|
|
version,
|
|
..
|
|
}) => {
|
|
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`"
|
|
);
|
|
}
|
|
if daemon_outdated(&version) {
|
|
println!("Daemon {}", outdated_daemon_note(&version));
|
|
}
|
|
}
|
|
Ok(other) => println!("Mount: unexpected response {other:?}"),
|
|
Err(_) => println!("Mount: not running."),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// What `pdfs status` and `pdfs diagnose` say about a daemon still running
|
|
/// an older release than this CLI.
|
|
fn outdated_daemon_note(version: &str) -> String {
|
|
let running = if version.is_empty() {
|
|
"an older release".to_string()
|
|
} else {
|
|
version.to_string()
|
|
};
|
|
format!(
|
|
"still runs {running}, this is {}; run `systemctl --user restart proton-drive` to finish the update",
|
|
env!("CARGO_PKG_VERSION"),
|
|
)
|
|
}
|
|
|
|
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<()> {
|
|
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 finished_run(
|
|
"the import",
|
|
summary.map(|s| (s.failed, s.cancelled, s.error)),
|
|
);
|
|
}
|
|
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 finished_run(
|
|
"the re-date",
|
|
summary.map(|s| (s.failed, s.cancelled, s.error)),
|
|
);
|
|
}
|
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
|
other => bail!("unexpected response: {other:?}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// How a run `--wait` followed ended, as the exit status: an error when it
|
|
/// failed, was cancelled, or left photos failed, so a script can tell.
|
|
/// `outcome` is `(failed, cancelled, error)`; `None` when the daemon has no
|
|
/// result, which happens when it restarted during the run.
|
|
fn finished_run(what: &str, outcome: Option<(usize, bool, Option<String>)>) -> Result<()> {
|
|
match outcome {
|
|
None => bail!("{what} ended without a result; the service may have restarted"),
|
|
Some((_, _, Some(error))) => bail!("{what} failed: {error}"),
|
|
Some((_, true, None)) => bail!("{what} was cancelled"),
|
|
Some((failed, false, None)) if failed > 0 => {
|
|
bail!("{what} finished, but {failed} photo(s) failed")
|
|
}
|
|
Some(_) => Ok(()),
|
|
}
|
|
}
|
|
|
|
/// 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;
|
|
};
|
|
if let Some(error) = &summary.error {
|
|
println!("Failed : {error}");
|
|
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;
|
|
};
|
|
if let Some(error) = &summary.error {
|
|
println!("Failed : {error}");
|
|
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_copy(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::Copy {
|
|
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 copied", sources.len());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn cmd_du(path: PathBuf) -> Result<()> {
|
|
let response = control_request(CtlRequest::FolderSize {
|
|
path: path_arg(&path)?,
|
|
})?;
|
|
if emit_json(&response)? {
|
|
return Ok(());
|
|
}
|
|
match response {
|
|
CtlResponse::FolderSize { size, items } => {
|
|
let noun = if items == 1 { "item" } else { "items" };
|
|
println!(
|
|
"{} in {items} {noun} (on Proton Drive, trash included)",
|
|
human_bytes(size)
|
|
);
|
|
}
|
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
|
other => bail!("unexpected response: {other:?}"),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn cmd_rm(path: PathBuf) -> Result<()> {
|
|
match control_request(CtlRequest::Delete {
|
|
path: path_arg(&path)?,
|
|
})? {
|
|
CtlResponse::Ok { message } => println!("{message}"),
|
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
|
other => bail!("unexpected response: {other:?}"),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Seconds as something a human reads at a glance: the ages here run from
|
|
/// "still starting" to "wedged since yesterday".
|
|
fn human_secs(secs: u64) -> String {
|
|
match secs {
|
|
s if s < 60 => format!("{s}s"),
|
|
s if s < 3600 => format!("{}m{}s", s / 60, s % 60),
|
|
s if s < 86400 => format!("{}h{}m", s / 3600, (s % 3600) / 60),
|
|
s => format!("{}d{}h", s / 86400, (s % 86400) / 3600),
|
|
}
|
|
}
|
|
|
|
/// The sync engine's line in `pdfs diagnostics`: whether it runs, and which
|
|
/// folder's pass it is on.
|
|
fn sync_engine_line(sync: &pdfs_core::control::SyncEngineState) -> String {
|
|
let mut line = match (sync.running, sync.pass_folder) {
|
|
(false, _) => "STOPPED; synced folders do not sync until the daemon restarts".to_string(),
|
|
(true, None) => "idle".to_string(),
|
|
(true, Some(folder)) => format!(
|
|
"pass on folder {folder} for {}, last progress {} ago",
|
|
human_secs(sync.pass_age_secs),
|
|
human_secs(sync.pass_idle_secs),
|
|
),
|
|
};
|
|
if sync.panics > 0 {
|
|
line.push_str(&format!("; {} pass(es) panicked", sync.panics));
|
|
}
|
|
line
|
|
}
|
|
|
|
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 let Some(sync) = &d.sync {
|
|
println!("sync engine: {}", sync_engine_line(sync));
|
|
}
|
|
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();
|
|
if db_path.exists() {
|
|
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)),
|
|
);
|
|
} else {
|
|
// The daemon creates it on its first start, which waits for a sign-in,
|
|
// so a fresh install has none yet (issue #39 read this as the fault).
|
|
report.finding(
|
|
DiagnoseLevel::Warn,
|
|
" database",
|
|
format!(
|
|
"{} not created yet (the daemon creates it on first start)",
|
|
db_path.display()
|
|
),
|
|
);
|
|
}
|
|
|
|
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");
|
|
let mut keys_unlocked = false;
|
|
match pdfs_core::auth::load() {
|
|
Ok(session) => {
|
|
report.finding(DiagnoseLevel::Ok, " keyring session", session.username);
|
|
let key_state = key_state_name();
|
|
keys_unlocked = key_state != "locked";
|
|
match key_state {
|
|
"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.
|
|
let mut daemon_responding = false;
|
|
match control_request(CtlRequest::Status) {
|
|
Ok(CtlResponse::Status {
|
|
mountpoint,
|
|
online,
|
|
session_expired,
|
|
tokens_unsaved,
|
|
pending_uploads,
|
|
pending_changes,
|
|
version,
|
|
..
|
|
}) => {
|
|
daemon_responding = true;
|
|
report.finding(DiagnoseLevel::Ok, " daemon responding", "");
|
|
if daemon_outdated(&version) {
|
|
report.finding(
|
|
DiagnoseLevel::Warn,
|
|
" daemon version",
|
|
outdated_daemon_note(&version),
|
|
);
|
|
}
|
|
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(),
|
|
);
|
|
|
|
// The daemon opens its control socket only once My Files is mounted, so a
|
|
// daemon that answers has the keys it needs. One that does not may be stuck
|
|
// on a key that does not open (#40); only then is Proton asked.
|
|
if keys_unlocked && !daemon_responding {
|
|
println!("\nKeys");
|
|
diagnose_keys(&mut report);
|
|
}
|
|
|
|
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
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Ask Proton which account and address keys open, and which of them the My
|
|
/// Files share needs. The findings hold key IDs and no secrets.
|
|
fn diagnose_keys(report: &mut DiagnoseReport) {
|
|
use pdfs_core::keycheck::{self, Level};
|
|
|
|
let checked = tokio::runtime::Runtime::new()
|
|
.map_err(anyhow::Error::from)
|
|
.and_then(|rt| {
|
|
rt.block_on(async {
|
|
tokio::time::timeout(std::time::Duration::from_secs(30), keycheck::run())
|
|
.await
|
|
.map_err(|_| anyhow!("Proton did not answer within 30 seconds"))?
|
|
.map_err(anyhow::Error::from)
|
|
})
|
|
});
|
|
let (keys, share) = match checked {
|
|
Ok(checked) => checked,
|
|
Err(e) => {
|
|
report.finding(DiagnoseLevel::Warn, " key check", format!("{e:#}"));
|
|
return;
|
|
}
|
|
};
|
|
let findings = keycheck::findings(&keys, &share);
|
|
let any_failed = findings.iter().any(|f| f.level != Level::Ok);
|
|
for finding in findings {
|
|
let level = match finding.level {
|
|
Level::Ok => DiagnoseLevel::Ok,
|
|
Level::Warn => DiagnoseLevel::Warn,
|
|
Level::Fail => DiagnoseLevel::Fail,
|
|
};
|
|
report.finding(level, &format!(" {}", finding.label), finding.detail);
|
|
}
|
|
if any_failed {
|
|
println!(" (key IDs are not secret; include this section when you report it)");
|
|
}
|
|
}
|
|
|
|
/// 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));
|
|
}
|
|
|
|
/// The file-manager script passes each selected path after `--`, so a
|
|
/// file named like an option is still the path.
|
|
#[test]
|
|
fn a_path_after_a_double_dash_is_a_path_even_when_it_looks_like_a_flag() {
|
|
let cli = Cli::try_parse_from(["pdfs", "pin", "--", "--json"]).unwrap();
|
|
assert!(!cli.json);
|
|
match cli.command {
|
|
Command::Pin { path } => assert_eq!(path, PathBuf::from("--json")),
|
|
_ => panic!("not a pin"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_waited_for_run_that_did_not_fully_succeed_exits_with_an_error() {
|
|
assert!(finished_run("the import", Some((0, false, None))).is_ok());
|
|
assert!(finished_run("the import", Some((3, false, None))).is_err());
|
|
assert!(finished_run("the import", Some((0, true, None))).is_err());
|
|
let failed = finished_run("the import", Some((0, false, Some("not a zip".into()))));
|
|
assert!(failed.unwrap_err().to_string().contains("not a zip"));
|
|
assert!(finished_run("the import", None).is_err());
|
|
}
|
|
|
|
#[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 a_link_flag_pair_keeps_removes_or_sets() {
|
|
assert_eq!(link_change::<i64>(None, false), LinkChange::Keep);
|
|
assert_eq!(link_change::<i64>(None, true), LinkChange::Remove);
|
|
assert_eq!(link_change(Some(5), false), LinkChange::Set(5));
|
|
}
|
|
|
|
#[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,
|
|
pinned_by_folder: 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(_),
|
|
..
|
|
}
|
|
));
|
|
}
|
|
}
|