//! IPC protocol between the CLI and a running mount daemon. //! //! The daemon listens on a Unix domain socket under the state dir; the CLI //! (`pdfs pin` / `unpin` / `status`) connects, sends one [`Request`] as a single //! JSON line, and reads one JSON-line [`Response`]. Keeping the wire format //! line-delimited JSON makes the socket trivially scriptable. use std::io::{BufRead, BufReader, Write}; use std::os::unix::net::UnixStream; use std::path::Path; use std::time::Duration; use serde::{Deserialize, Serialize}; use crate::cache::Pin; use crate::error::Result; pub use crate::mounts::{MountAccess, MountKind, MountMode, MountSpec}; /// Cap on the *write* half of a round-trip. A crashed daemon can leave its /// listening socket in the kernel (e.g. as a `` zombie): `connect` /// then succeeds but no one ever replies. A short write bound trips fast when /// the daemon is wedged before it has read anything. const WRITE_TIMEOUT: Duration = Duration::from_secs(2); /// Cap on the *read* half. Some requests make the daemon do real work before it /// replies — `PhotosTimeline` downloads a page of thumbnails, `OpenFile` /// downloads whole-file content — which easily takes more than a couple of /// seconds. A 2s read bound timed those out and the GUI mistook the timeout for /// "no daemon" (showing "Mount Proton Drive…" on a live mount). Give reads a /// generous bound that still protects against a daemon that accepts but never /// answers. const READ_TIMEOUT: Duration = Duration::from_secs(120); /// Cap on the read half for the requests front ends poll ([`Request::is_poll`]). /// The daemon answers those from memory or its database, so a reply that takes /// longer than this means it is stuck. Waiting the full [`READ_TIMEOUT`] would /// freeze the poller's read-out for two minutes; failing fast lets it say "not /// responding" and ask again on its next tick. const POLL_READ_TIMEOUT: Duration = Duration::from_secs(5); /// A command sent from the CLI to the daemon. #[derive(Serialize, Deserialize, Debug)] pub enum Request { /// Report mount status (account, mountpoint, pin count). Status, /// Pin a file (path relative to the mountpoint, or absolute under it). Pin { path: String }, /// Unpin a file, evicting its cached content. Unpin { path: String }, /// List pinned files. ListPins, /// List a directory for the in-app file browser. `path` is /// mountpoint-relative (empty or "." = the mount root). ListDir { path: String }, /// Fetch a page of the photos timeline, newest first. Returns metadata only: /// a thumbnail path comes back only for photos already in the cache, so the /// reply never waits on the network. Front-ends ask for the thumbnails they /// actually display with [`Request::PhotoThumbs`]. PhotosTimeline { offset: usize, limit: usize, /// Restrict the page to one kind (Photos / Videos / Raw). `None` (the /// default, and what older front-ends send) returns everything. The /// offset is relative to the filtered timeline, so paging a single tab /// doesn't have to walk past the other kinds. #[serde(default)] kind: Option, /// Restrict the page to a `[from, to)` capture-time window (epoch /// seconds) — the date scrubber's jump to a month. `None` spans the whole /// timeline. Like `kind`, the offset is relative to the filtered set. #[serde(default)] range: Option<(i64, i64)>, /// Restrict the page to favorited photos. Older front-ends omit it and /// get the whole timeline, as before. #[serde(default)] favorites: bool, /// Restrict the page to photos no album of ours holds — what is left to /// file. Older front-ends omit it and get the whole timeline, as before. #[serde(default)] not_in_album: bool, }, /// Every photo of one photo's group — the JPEG and the RAW of one shot, a /// live photo and its clip. Replies with [`Response::Photos`], holding one /// item per file in server order; a photo that stands alone comes back as a /// single item. The lightbox uses it to switch between a shot's files. PhotoGroup { uid: String }, /// The months the timeline spans (newest first, with per-month counts) so a /// front-end can build a date scrubber without paging the whole library. /// `kind` scopes the counts to one tab when set. PhotoMonths { #[serde(default)] kind: Option, }, /// Photos taken on today's date in earlier years, newest first, at most /// `limit` of them — the "On this day" strip above the timeline. Replies /// with [`Response::Photos`]; a front-end groups them by year itself. PhotosOnThisDay { limit: usize }, /// Sets of photos that are byte-identical copies of each other, going by /// the server's content hash. Replies with [`Response::PhotoDuplicates`]. PhotoDuplicates, /// Sets of shots that look alike going by their thumbnails: resized or /// re-encoded copies, and bursts of one moment. Starts hashing the /// thumbnails not hashed yet in the background and replies at once with /// the sets found so far; a front-end asks again until the reply's /// `hashing` is `None`. Replies with [`Response::PhotoSimilar`]. PhotoSimilar, /// The towns the photos were taken in, the one with the most photos first. /// Only photos whose uploader recorded a location count. Replies with /// [`Response::Places`]. PhotoPlaces, /// Fetch a page of the photos taken in one town, newest first. `id` is the /// town's id from [`Request::PhotoPlaces`]. Replies with /// [`Response::Photos`], like an album page. PlacePhotos { id: u32, offset: usize, limit: usize, }, /// Every photo taken in one town with where exactly it was taken, for the /// map's close-up. Replies with [`Response::PhotoLocations`]. PlaceLocations { id: u32 }, /// List the account's photo albums, newest activity first, including the /// albums other people share with us. Metadata only — an album's cover /// thumbnail is fetched with [`Request::PhotoThumbs`] like any other photo. /// Replies with [`Response::Albums`]. PhotoAlbums, /// Fetch a page of one album's photos, newest capture first. Same reply /// shape as [`Request::PhotosTimeline`] ([`Response::Photos`]), so a /// front-end paints an album with the gallery it already has. `uid` is the /// album node's uid, as reported by [`Request::PhotoAlbums`]. AlbumPhotos { uid: String, offset: usize, limit: usize, }, /// Create an album named `name`. Replies with [`Response::AlbumCreated`]. CreateAlbum { name: String }, /// Rename the album `uid`. Replies with [`Response::Ok`]. RenameAlbum { uid: String, name: String }, /// Delete the album `uid`. Its photos stay in the timeline; with /// `delete_photos` set, photos that exist only in the album (saved from a /// shared album) are deleted with it, which the server otherwise refuses. /// Replies with [`Response::Ok`]. DeleteAlbum { uid: String, #[serde(default)] delete_photos: bool, }, /// Add timeline photos to the album `uid`. Replies with /// [`Response::AlbumChanged`]. AddToAlbum { uid: String, photos: Vec }, /// Take photos out of the album `uid`. They stay in the timeline: this is /// not a trash. Replies with [`Response::AlbumChanged`]. RemoveFromAlbum { uid: String, photos: Vec }, /// Fetch thumbnails for the given photo uids, downloading the ones not /// already cached (one batched round-trip) and replying with their on-disk /// paths. Keep the batch small — it is served on demand, as tiles scroll in. PhotoThumbs { uids: Vec }, /// Trash photos by node uid, answered with [`Response::Trashed`]. /// /// Uid-addressed rather than path-addressed because the photos volume is not /// part of the FUSE mount: the gallery knows a photo only by its uid, and /// [`Request::Delete`] has no path to take for it. The nodes land in Proton /// trash, so this stays recoverable from the Trash page. /// /// A photo that belongs to a group takes the whole group with it: trashing /// the JPEG of a shot and leaving its RAW behind is not what anyone means by /// deleting that photo. With `files_only` set, exactly the listed files /// go and their groups stay: the duplicate finder removes one copy of a /// shot, not the shot. Older front-ends omit it and trash whole groups. TrashNodes { uids: Vec, #[serde(default)] files_only: bool, }, /// Fetch thumbnails for ordinary Drive image files shown outside the Photos /// timeline. The modification time is the cache validity tag, so replacing /// an image can never reuse its previous revision's thumbnail. FileThumbs { items: Vec, /// Identifies the front-end's current listing. Moving to another page or /// folder advances it, allowing the daemon to stop work for rows that /// are no longer visible. #[serde(default)] generation: u64, }, /// Reserve a daemon-issued generation for ordinary-file thumbnail work. /// The daemon is the ordering authority, so GUI restarts and wall-clock /// corrections cannot make a new listing look older than an earlier one. ReserveFileThumbGeneration, /// Stop ordinary-file thumbnail work belonging to older listings. Photos /// timeline work and an explicit recursive build are deliberately separate. CancelFileThumbs { generation: u64 }, /// Start building local thumbnails for every supported image below `path`. /// The request returns immediately; progress is read with /// [`Request::ThumbnailBuildStatus`]. StartThumbnailBuild { path: String }, /// Stop the current recursive ordinary-file thumbnail build, if any. CancelThumbnailBuild, /// Read progress for the recursive ordinary-file thumbnail build. ThumbnailBuildStatus, /// Whether a photos-timeline refresh is running right now. Replies with /// [`Response::PhotosRefresh`]. /// /// [`RefreshScope::Photos`] starts the refresh and returns at once, so this /// is how a front-end knows when the refreshed timeline is worth re-reading. PhotosRefreshStatus, /// Download a photo's full content into the cache; replies with its path. OpenPhoto { uid: String }, /// Add or remove Proton's `Favorite` tag on a photo. Replies with /// [`Response::Ok`]. /// /// Favouriting a photo that is *not* on this account's own photos volume — /// one shared with us, or one that lives only in an album — needs it /// re-encrypted for our timeline, which the SDK does not implement yet; the /// daemon reports that as an error rather than silently doing nothing. SetPhotoFavorite { uid: String, favorite: bool }, /// Import Google Photos **Takeout** archives into Proton Photos. /// /// `archives` are absolute paths to the export's `.zip` parts on the /// daemon's own filesystem — the whole set at once, because a photo and its /// metadata sidecar routinely land in different parts. Photos already on the /// account (matched by name *and* content digest) are skipped, so a library /// partly synced by Proton Photos elsewhere does not import twice, and an /// interrupted import resumes by simply being run again. /// /// With `dry_run` the daemon scans and reports what it *would* do without /// uploading anything. Otherwise it acks immediately with /// [`Response::Ok`] and works in the background — an export is hours of /// upload, far past any socket timeout. Progress shows up in /// [`Request::GetQueueStatus`] as a job, and the final counts in /// [`Request::ImportStatus`]. ImportTakeout { archives: Vec, #[serde(default)] dry_run: bool, }, /// Re-date photos whose capture time is when they were *imported* rather /// than when they were taken. /// /// Proton has no API for editing a photo's capture time — it is sealed into /// the revision — so the only repair is to upload the photo again with the /// right time and trash the original. That is what this does, for photos /// whose file name carries a date that disagrees with their stored capture /// time by more than a day: `IMG-20230219-WA0001.jpg` filed under 2026 is /// the signature of an import whose metadata sidecars did not match. /// /// Favorites and album memberships are carried across; the original is /// **trashed**, not deleted, so a bad run is recoverable from Proton's /// trash. With `dry_run` nothing is uploaded or trashed and the report says /// what would have been done. /// /// Acks immediately with [`Response::Ok`] and works in the background — each /// photo is a download plus an upload. Progress shows up in /// [`Request::GetQueueStatus`] as a job, and the counts in /// [`Request::RedateStatus`]. RedatePhotos { #[serde(default)] dry_run: bool, /// Stop after this many photos. `None` is the whole library. #[serde(default)] limit: Option, /// Only consider photos whose *stored* capture time falls in this /// `[from, to)` window — the way to aim the repair at the days an /// import landed on and leave the rest of the library untouched. #[serde(default)] range: Option<(i64, i64)>, }, /// How the running (or last finished) re-date is doing. Replies with /// [`Response::RedateStatus`]. RedateStatus, /// Ask the running re-date to stop. It finishes the photo on the wire, then /// reports `cancelled`. Replies with [`Response::Ok`]. CancelRedate, /// How the running (or last finished) Takeout import is doing. Replies with /// [`Response::ImportStatus`]. ImportStatus, /// Ask the running Takeout import to stop. It finishes the photo on the wire /// and files what it has uploaded into its albums, then reports /// `cancelled`. Replies with [`Response::Ok`]. CancelImport, /// Upload the photo at `source_path` under the given name and media type. /// /// A path, not the bytes: this protocol is line-delimited JSON, and /// `serde_json` writes a `Vec` as an array of decimal integers — a ~5-6x /// inflation that both peers then hold in memory at once, which turned a /// large photo or a video into an OOM of the GUI, the daemon, or both. The /// daemon shares a filesystem with every front-end it serves (the socket is /// a Unix socket), so it opens and streams the file itself. UploadPhoto { name: String, media_type: String, source_path: String, capture_time: Option, }, /// Download a Drive file's full content into the cache; replies with the /// on-disk path so the front-end can open it with the default app. `path` /// is mountpoint-relative. /// /// `uid` addresses the same node directly and is preferred when present: a /// search hit's path comes from the metadata index, which also indexes /// nodes the primary mount's tree does not expose (an on-demand sync /// folder, a mirror), and walking such a path from the mount root fails /// with "no such file or folder". Defaulted for wire-compat with clients /// predating the field; the daemon falls back to `path` without it. OpenFile { path: String, #[serde(default)] uid: Option, }, /// Full-text search node names against the daemon's local metadata index. /// `limit` caps the number of hits returned. `scope`, a mountpoint-relative /// folder, restricts hits to that subtree; older front-ends omit it and /// search everything. Replies with [`Response::SearchResults`]. Search { query: String, limit: usize, #[serde(default)] scope: Option, }, /// Search the daemon's index of *local* (non-Drive) files on this machine. /// Independent of [`Request::Search`] so a front-end can fire both at once and /// render whichever lands first. Replies with [`Response::LocalResults`]. SearchLocal { query: String, limit: usize }, /// Search both the Drive metadata index and the local-file index in one /// round-trip. This is the preferred prompt API; the two legacy search /// requests remain available for older clients. `limit` applies to each /// source independently. `filters` is applied before limiting, so selecting /// (for example) images cannot hide valid matches below an unfiltered top /// `limit`. Older clients omit it and therefore search both sources and all /// kinds. Replies with [`Response::SearchResultsV2`]. SearchV2 { query: String, limit: usize, #[serde(default)] filters: SearchFilters, }, /// Rename a file or folder. `path` is mountpoint-relative; `new_name` is a /// single path component (no separators). Replies with [`Response::Ok`]. Rename { path: String, new_name: String }, /// Move a file or folder into a new parent folder. Both `path` and /// `new_parent` are mountpoint-relative, or both absolute local paths; two /// absolute paths may lie in different locations (My files, an on-demand /// or a mirror synced folder), and the move is still made on Proton Drive /// without transferring content. Replies with [`Response::Ok`]. Move { path: String, new_parent: String }, /// Trash a file or folder. `path` is mountpoint-relative. Replies with /// [`Response::Ok`]. Delete { path: String }, /// Create a new folder named `name` under the mountpoint-relative `parent`. /// Replies with [`Response::Ok`]. CreateFolder { parent: String, name: String }, // There is deliberately no bytes-carrying single-file upload: it was // `UploadFile { parent, name, bytes }`, and it OOMed on anything large for // the reason described on `UploadPhoto`. `UploadPaths` covers the same case // by path, including the one-file batch. /// Bulk-upload local files and/or directory trees into the mountpoint-relative /// `parent` folder. `sources` are absolute paths on the daemon's own /// filesystem (the daemon is local): each file is uploaded, each directory is /// recreated remotely and walked recursively. The daemon acks immediately with /// [`Response::Ok`] and does the work in the background — a big tree far /// outlasts the socket read timeout — so progress is observed through /// [`Request::GetQueueStatus`] and completion through the transfer count /// falling to zero. UploadPaths { parent: String, sources: Vec, }, /// Delete all unpinned cached blobs and on-demand blocks, keeping pinned /// files intact. Replies with [`Response::Ok`] reporting the bytes freed. PurgeCache, /// Retune the on-disk cache's soft byte cap at runtime (`0` = unlimited) and /// persist it to config so the next mount keeps it. Replies with /// [`Response::Ok`]. SetCacheBudget { bytes: u64 }, /// Cap uploads and downloads at these bytes per second (`0` = no cap), /// live for running transfers, and persist both to config so the next /// mount keeps them. Replies with [`Response::Ok`]. SetBandwidthLimits { upload: u64, download: u64 }, /// Report on the health of the metadata database and content cache: sizes, /// row counts, and — when `deep` — SQLite's own integrity check. Replies /// with [`Response::CacheReport`]. /// /// `deep` reads every page of the database, so it is opt-in: worth it when /// diagnosing a suspected corruption, wasteful as a routine status call. CacheInspect { #[serde(default)] deep: bool, }, /// Checkpoint the write-ahead log and compact the database, replying with /// [`Response::Ok`] reporting the bytes reclaimed. /// /// Takes a write lock for the duration and needs room for a second copy of /// the database, which is why it is user-invoked rather than periodic. CacheVacuum, /// Snapshot what the daemon is working on: in-flight transfers (active /// uploads/downloads) and the longer jobs around them (scans, folder /// skeletons, the local index, sync passes). Replies with /// [`Response::Transfers`]. Cheap to poll: the daemon keeps the registry in /// memory, so a front-end can render a live progress widget. GetQueueStatus, /// Report what the daemon is doing *right now*, for debugging a daemon that /// stopped answering rather than one that is merely busy. Replies /// [`Response::Diagnostics`]. /// /// Answered from lock-free state on purpose: it has to come back while the /// daemon is wedged, which is the only moment anyone asks for it. Diagnostics, /// List what is in the account's trash. Replies with [`Response::Entries`]; /// a trashed node has no path inside the mount, so each entry carries only /// its `uid` — the handle for [`Request::Restore`] and /// [`Request::DeleteForever`] — and its `path` is empty. ListTrash, /// Restore trashed nodes, by uid, to the folders they were trashed from. /// Replies with [`Response::Ok`]. Restore { uids: Vec }, /// Permanently delete trashed nodes by uid. Irreversible: the content is /// gone from Proton Drive, not moved. Replies with [`Response::Ok`]. DeleteForever { uids: Vec }, /// Permanently delete everything in the trash. Irreversible. /// Replies with [`Response::Ok`]. EmptyTrash, /// Drop a cached listing so the *next* read of it re-enumerates from the /// server. Replies with [`Response::Ok`]. This is what a front-end's Refresh /// button raises: the daemon serves listings from its persisted cache, which /// only notices another client's changes when its TTL lapses, so a user who /// knows the cache is stale needs a way to say so. Cheap and idempotent — /// it invalidates, it does not fetch. Refresh { scope: RefreshScope }, // ---- locations -------------------------------------------------------- /// List every local Proton Drive location. Replies with /// [`Response::Locations`]. ListLocations, // ---- devices ---------------------------------------------------------- /// List the account's registered devices. Replies with [`Response::Devices`]. ListDevices, /// Rename a device by its uid. Replies with [`Response::Ok`]. RenameDevice { uid: String, name: String }, /// Delete (deregister) a device by its uid. Replies with [`Response::Ok`]. DeleteDevice { uid: String }, /// Adopt an existing device as this machine's, pinning its uid in /// `config.json` so a hostname change or reinstall re-attaches to it instead /// of registering a duplicate. `uid: None` clears the pin. /// Replies with [`Response::Ok`]. AdoptDevice { uid: Option }, /// Move every folder another device backs up into this machine's own /// device, as server-side moves on the main volume. The other device stays /// registered, without those folders. Sync folders here that already track /// one of them follow it to its new share. Nothing is downloaded: the moved /// folders are then offered by [`Request::ListRestorableFolders`]. /// Replies with [`Response::Ok`]. MigrateDevice { uid: String }, // ---- device folder sync ---------------------------------- /// Add a local folder to this machine's device, uploading its tree and /// registering the device on first use. Replies with [`Response::Ok`]. AddSyncFolder { local_path: String }, /// List this device's synced folders. Replies with [`Response::SyncFolders`]. ListSyncFolders, /// Remove a synced folder by id; `delete_remote` also trashes its cloud copy. /// Replies with [`Response::Ok`]. RemoveSyncFolder { id: i64, delete_remote: bool }, /// Switch a synced folder between `mirror` and `ondemand` (Phase 3). Replies /// with [`Response::Ok`]. SetSyncFolderMode { id: i64, mode: String }, /// Pause or resume one mirror folder's reconcile, independent of the /// global pause. Persisted until resumed. Replies with [`Response::Ok`]. SetSyncFolderPaused { id: i64, paused: bool }, /// Force a reconcile pass: one folder by id, or all when `id` is `None`. /// Replies with [`Response::Ok`]. SyncNow { id: Option }, /// Pause or resume syncing: queued uploads and changes stop draining and /// mirror folders stop reconciling, while reads through the mount keep /// working. `until` is the Unix second a pause ends by itself; `None` /// pauses until resumed. Ignored when resuming. Replies with /// [`Response::Ok`]. SetSyncPaused { paused: bool, until: Option }, /// List the queue of uploads and changes not yet on the remote. Replies /// with [`Response::PendingOps`]. ListPendingOps, /// Retry a backed-off queued op now instead of waiting out its backoff; /// every failed op when `id` is `None`. Replies with [`Response::Ok`]. RetryPendingOp { id: Option }, /// Copy the content of a queued upload (`id` from /// [`Request::ListPendingOps`]) to `dest`, an absolute path that must not /// exist yet. The op stays queued. Replies with [`Response::Ok`]. ExportPendingOp { id: i64, dest: String }, /// Drop a queued change (`id` from [`Request::ListPendingOps`]) and undo /// it locally: a node made here goes, a node Drive has goes back to what /// Drive has, along with every other change queued on it. Its content is /// lost unless it was exported first. Replies with [`Response::Ok`]. DiscardPendingOp { id: i64 }, /// List the `(sync-conflict …)` copies under My Files and in synced /// folders. Replies with /// [`Response::Conflicts`]. ListConflicts, /// Resolve one conflict copy (`path` mountpoint-relative, or absolute for a /// copy in a synced folder). /// Everything removed goes to the Proton trash. Replies with /// [`Response::Ok`]. ResolveConflict { path: String, keep: ConflictKeep }, /// List the folders under this machine's device that can be synced here, /// each with a proposed local path. Replies with /// [`Response::RestorableFolders`]. ListRestorableFolders, /// Attach the given remote device folders to local paths and sync them down. /// Replies with [`Response::Ok`]. RestoreSyncFolders { items: Vec }, /// [`Request::ListRestorableFolders`] for another registered device, by /// uid, so its folders can be synced down to this machine. Replies with /// [`Response::RestorableFolders`]. ListDeviceRestorableFolders { device: String }, /// [`Request::RestoreSyncFolders`] for folders under another device. A /// separate variant rather than an optional field: an older daemon that /// ignored the field would attach the folders under this machine's device /// share instead. Replies with [`Response::Ok`]. RestoreDeviceFolders { device: String, items: Vec, }, // ---- sharing a node --------------------------------------------------- /// Invite `emails` (Proton and/or external addresses, auto-detected) to the /// node at mountpoint-relative `path` at `role` ("viewer"|"editor"|"admin"), /// with an optional email `message`. Replies with [`Response::Ok`]. ShareNode { path: String, emails: Vec, role: String, message: Option, }, /// The by-uid twin of [`Request::ShareNode`], for nodes proven to belong to /// an on-demand or mirror device location. This is a distinct variant so an /// older daemon rejects it instead of ignoring `uid` and acting on `path`. ShareNodeByUid { uid: String, emails: Vec, role: String, message: Option, }, /// List the members, pending invitations and public link of the node at /// mountpoint-relative `path`. Replies with [`Response::Share`]. ListShare { path: String }, /// The by-uid twin of [`Request::ListShare`]. ListShareByUid { uid: String }, /// Change the role of a share entry (member or pending invitation) on the node /// at `path`. `id` and `kind` identify the entry (from [`Response::Share`]). /// Replies with [`Response::Ok`]. UpdateShareRole { path: String, id: String, kind: ShareEntryKind, role: String, }, /// The by-uid twin of [`Request::UpdateShareRole`]. UpdateShareRoleByUid { uid: String, id: String, kind: ShareEntryKind, role: String, }, /// Remove a share entry (member, pending Proton invite, or external invite) /// from the node at `path`. Replies with [`Response::Ok`]. RemoveShareEntry { path: String, id: String, kind: ShareEntryKind, }, /// The by-uid twin of [`Request::RemoveShareEntry`]. RemoveShareEntryByUid { uid: String, id: String, kind: ShareEntryKind, }, /// Create a public link on the node at `path`. `role` is "viewer" or "editor"; /// `password` optionally adds a custom password; `expires` is an optional Unix /// expiry. Replies with [`Response::PublicLink`] (carrying the shareable URL). CreatePublicLink { path: String, role: String, password: Option, expires: Option, }, /// The by-uid twin of [`Request::CreatePublicLink`]. CreatePublicLinkByUid { uid: String, role: String, password: Option, expires: Option, }, /// Remove the public link `id` from the node at `path`. Replies with /// [`Response::Ok`]. RemovePublicLink { path: String, id: String }, /// The by-uid twin of [`Request::RemovePublicLink`]. RemovePublicLinkByUid { uid: String, id: String }, /// Stop sharing the node at `path`: remove its public link, every pending /// invitation and every member. Each removal is attempted even if an /// earlier one fails. Replies with [`Response::Ok`], or an error naming /// what is still shared. StopSharing { path: String }, /// The by-uid twin of [`Request::StopSharing`]. StopSharingByUid { uid: String }, // ---- revisions -------------------------------------------------------- /// List the version history of the file at mountpoint-relative `path`, /// newest first. Replies with [`Response::Revisions`]. ListRevisions { path: String }, /// The by-uid twin of [`Request::ListRevisions`]. ListRevisionsByUid { uid: String }, /// Make revision `revision_id` of the file at `path` current again. The /// server applies this asynchronously, so the restored content may take a /// moment to appear. Replies with [`Response::Ok`]. RestoreRevision { path: String, revision_id: String }, /// The by-uid twin of [`Request::RestoreRevision`]. RestoreRevisionByUid { uid: String, revision_id: String }, /// Permanently delete revision `revision_id` of the file at `path`. The /// content is unrecoverable, and the server refuses to delete the revision /// that is currently active. Replies with [`Response::Ok`]. DeleteRevision { path: String, revision_id: String }, /// The by-uid twin of [`Request::DeleteRevision`]. DeleteRevisionByUid { uid: String, revision_id: String }, /// Write revision `revision_id` of the file at `path` to the absolute local /// path `dest`, leaving the file's current content untouched. Replies with /// [`Response::Ok`]. SaveRevisionAs { path: String, revision_id: String, dest: String, }, /// The by-uid twin of [`Request::SaveRevisionAs`]. SaveRevisionAsByUid { uid: String, revision_id: String, dest: String, }, // ---- shared by me ----------------------------------------------------- /// List the nodes I have shared with others — collaborative shares that still /// have members, pending invitations or a public link. Replies with /// [`Response::SharedByMe`]. Each item carries the share's state so the /// front-end can render it without a follow-up per node. ListSharedByMe, // ---- shared with me --------------------------------------------------- /// List nodes shared with me that I have accepted. Replies with /// [`Response::Entries`] (each entry carries its `uid`; `path` is empty since /// the item lives outside the mount tree). ListSharedWithMe, /// List the children of a folder shared with me, addressed by `uid`. Replies /// with [`Response::Entries`]; like [`Request::ListSharedWithMe`] the entries /// carry a `uid` and an empty `path`, since the subtree lives outside the /// mount. Lets a front-end browse into a shared folder. ListSharedFolder { uid: String }, /// Download a file shared with me into the content cache, addressed by `uid`. /// Replies with [`Response::FilePath`] so the front-end can open it with the /// default app. The by-uid twin of [`Request::OpenFile`], which can only /// address nodes inside the mount. OpenSharedFile { uid: String }, /// Leave a shared node by its `uid`, giving up my access. Replies with /// [`Response::Ok`]. LeaveShared { uid: String }, // ---- incoming invitations --------------------------------------------- /// List invitations addressed to me, pending accept or reject. Replies with /// [`Response::Invitations`]. ListInvitations, /// Accept the invitation `id`, gaining access to the shared node. Replies with /// [`Response::Ok`]. AcceptInvitation { id: String }, /// Reject the invitation `id`, declining access. Replies with [`Response::Ok`]. RejectInvitation { id: String }, // ---- bookmarks -------------------------------------------------------- /// List public links saved to my account. Replies with [`Response::Bookmarks`]. ListBookmarks, /// Save a public link `url` (optionally password-protected) as a bookmark. /// Replies with [`Response::Ok`]. CreateBookmark { url: String, password: Option, }, /// Remove a saved bookmark by its `token`. Replies with [`Response::Ok`]. DeleteBookmark { token: String }, // ---- account ---------------------------------------------------------- /// Total account storage usage (used/total, all Proton products, not just /// Drive). Replies with [`Response::AccountQuota`]. AccountQuota, // ---- activity --------------------------------------------------------- /// Fetch the daemon's recent activity log, newest first, capped at `limit` /// entries. Replies with [`Response::Activity`]. The log is persisted, so it /// survives a daemon restart: it records the mutations and transfers the /// daemon performs (uploads, downloads, deletes, renames, shares, sync /// passes, …), so a front-end can show a running "what happened" feed /// without re-deriving it from anywhere. ListActivity { limit: usize }, // ---- push ------------------------------------------------------------- /// Keep the connection open and stream [`Event`] lines instead of one /// [`Response`]. See [`subscribe`] for the client side. Subscribe, } /// Something a front-end shows that the daemon can say has changed, so the /// front-end reads it again. Carried by [`Event::Changed`]. #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] #[serde(rename_all = "snake_case")] pub enum Topic { /// Folder listings in the mount: something was created, renamed, moved, /// trashed or pinned, here or on another device. Files, /// The photos timeline or albums. Photos, Trash, /// Shares, links, invitations and saved links, in either direction. Shares, /// The account's computers. Devices, /// Synced folders, their mode and their live progress. Locations, /// The pending-op queue ([`Request::ListPendingOps`]). Queue, Conflicts, /// The activity log gained entries. Activity, /// A topic from a newer daemon, which this front-end cannot act on. #[serde(other)] Unknown, } impl Topic { /// Every topic a front-end can act on. A new subscription is told all of /// them have changed, so it catches up on whatever happened while it was /// not connected. pub const ALL: [Topic; 9] = [ Topic::Files, Topic::Photos, Topic::Trash, Topic::Shares, Topic::Devices, Topic::Locations, Topic::Queue, Topic::Conflicts, Topic::Activity, ]; } /// One line on a [`Request::Subscribe`] connection. /// /// The two things front ends show continuously, status and transfers, come /// with their contents, so following them costs no requests. Everything else is /// a [`Topic`] to re-read. #[derive(Serialize, Deserialize, Debug)] pub enum Event { /// A fresh [`Response::Status`]. Sent when a subscription starts and /// whenever any of its fields changes. Status(Box), /// A fresh [`Response::Transfers`] snapshot. Sent when a subscription /// starts and whenever the snapshot changes, which is continuously while /// something moves. Transfers { items: Vec, jobs: Vec, }, /// These topics changed. Changes close together arrive as one event. Changed { topics: Vec }, /// Nothing happened. Sent every [`HEARTBEAT`] so a client can tell a quiet /// daemon from a stuck one. Heartbeat, } /// How often a quiet subscription sends [`Event::Heartbeat`]. pub const HEARTBEAT: Duration = Duration::from_secs(10); /// How long [`Subscription::next_event`] waits for a line before it gives the /// daemon up as stuck: a few missed heartbeats. const SUBSCRIBE_READ_TIMEOUT: Duration = Duration::from_secs(25); /// Which kind of share entry a [`ShareEntry`] is, and which collection an /// [`Request::UpdateShareRole`]/[`Request::RemoveShareEntry`] targets. #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] pub enum ShareEntryKind { /// An accepted member (identified by its membership id). Member, /// A pending invitation to a Proton user (identified by its invitation id). ProtonInvite, /// A pending invitation to a non-Proton email (identified by its invitation id). ExternalInvite, } /// Which cached listing a [`Request::Refresh`] drops. /// /// Only the listings the daemon caches need naming here — the sharing, devices /// and activity listings are always fetched live, so a front-end refreshes those /// by simply re-asking. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] pub enum RefreshScope { /// One folder's child listing, by mountpoint-relative path (`""` = root). /// Only the folder itself, not its subtree: refreshing what the user is /// looking at shouldn't re-walk everything below it. Dir { path: String }, /// The trash listing. Trash, /// The photos timeline. The reply comes back as soon as the refresh has /// *started*, not when it has finished: re-reading a large library takes /// minutes, which is far longer than a control reply may take. Poll /// [`Request::PhotosRefreshStatus`] to know when the new timeline is there. /// /// A refresh normally only reads the photos the daemon has never read and /// the ones a remote event marked changed. `full` drops that knowledge and /// reads every photo's node again — the repair for a timeline that somehow /// disagrees with the account, and minutes of work on a large library. Photos { #[serde(default)] full: bool, }, } /// A registered device in a [`Response::Devices`] listing. #[derive(Serialize, Deserialize, Debug, Clone)] pub struct DeviceInfo { /// Device uid — the handle for [`Request::RenameDevice`]/[`Request::DeleteDevice`]. pub uid: String, /// Decrypted device name, or a placeholder when it could not be decrypted. pub name: String, /// Platform: "Windows", "MacOs" or "Linux". pub device_type: String, /// Last sync time, epoch seconds; `None` if it never synced. pub last_sync: Option, /// Whether this is the device *this* machine syncs to. Deleting it would /// delete the cloud copy of the folders this machine is syncing, so a /// front-end must not offer that as casually as removing another computer. #[serde(default)] pub this_device: bool, /// Whether this device is the one *explicitly adopted* in `config.json` /// ([`Request::AdoptDevice`]), as opposed to one matched by hostname. Only /// an adopted device survives a hostname change or a reinstall. #[serde(default)] pub adopted: bool, } /// One `(sync-conflict …)` copy and the file it is a copy of (in /// [`Response::Conflicts`]). #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct ConflictInfo { /// Mountpoint-relative path of the copy, or its absolute local path when it /// sits in a synced folder. pub path: String, /// Path of the file it is a copy of, in the same form as `path`. pub original_path: String, /// False when the original is gone and only the copy is left. pub original_exists: bool, pub size: u64, /// Unix seconds. pub modified: i64, pub original_size: Option, pub original_modified: Option, /// Same size and content hash as the original: keeping either loses nothing. pub identical: bool, } /// Which side of a conflict to keep (in [`Request::ResolveConflict`]). #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] pub enum ConflictKeep { /// Keep the original; the copy goes to Trash. Original, /// Keep the copy under the original's name; the original goes to Trash. Copy, /// Keep both; the copy is renamed to `name`. Both { name: String }, } /// One queued upload or change (in [`Response::PendingOps`]). #[derive(Serialize, Deserialize, Debug, Clone)] pub struct PendingOpInfo { /// Queue row id, for [`Request::RetryPendingOp`]. pub id: i64, /// `revision`, `create`, `mkdir`, `rename` or `trash`. pub kind: String, /// Mountpoint-relative path of the file or folder, or its name when the /// path is no longer known. pub path: String, /// Failed attempts so far. pub attempts: i64, /// The latest failure, when there has been one. pub last_error: Option, /// Unix second the op was queued. pub queued_at: i64, /// Unix second of the next attempt; `None` while parked. pub next_attempt_at: Option, /// Held back until a transient file gets its final name. pub parked: bool, /// Failed often enough to count as stuck. pub failing: bool, /// Why Drive refused it, when it did; such an op needs attention at once. #[serde(default)] pub issue: Option, /// Carries file content that [`Request::ExportPendingOp`] can save. #[serde(default)] pub exportable: bool, } /// Why Drive refused a queued change (in [`PendingOpInfo`]). The change stays /// queued and keeps being retried; nothing is deleted. #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum SyncIssue { /// The account's storage is full. Quota, /// The account may no longer change the file or its folder, for example /// because a share was revoked. Access, /// The file or the folder it goes into is gone from Drive. Missing, /// The folder holds too many items, or is nested too deep. Limit, /// Drive rejected the change, for example a name it does not accept. Rejected, /// An issue from a newer daemon. #[serde(other)] Unknown, } impl SyncIssue { /// The name stored in the database and sent on the wire. pub fn as_str(self) -> &'static str { match self { SyncIssue::Quota => "quota", SyncIssue::Access => "access", SyncIssue::Missing => "missing", SyncIssue::Limit => "limit", SyncIssue::Rejected => "rejected", SyncIssue::Unknown => "unknown", } } /// The issue stored as `name`. pub fn parse(name: &str) -> Self { match name { "quota" => SyncIssue::Quota, "access" => SyncIssue::Access, "missing" => SyncIssue::Missing, "limit" => SyncIssue::Limit, "rejected" => SyncIssue::Rejected, _ => SyncIssue::Unknown, } } } /// One synced local folder on this machine's device (in [`Response::SyncFolders`]). #[derive(Serialize, Deserialize, Debug, Clone)] pub struct SyncFolderInfo { /// Row id — the handle for [`Request::RemoveSyncFolder`]/[`Request::SetSyncFolderMode`]. pub id: i64, /// Absolute local folder path. pub local_path: String, /// The uid of the folder's remote root under the device root. pub remote_uid: String, /// `mirror` (full local copy, two-way synced) or `ondemand` (FUSE mount). pub mode: String, /// A mode switch the user asked for that the daemon has queued: it applies /// once the folder's current pass has pushed any local changes up. `None` /// when nothing is queued. A front-end should paint the folder as already /// heading there — the request was accepted, not rejected. #[serde(default)] pub pending_mode: Option, /// `idle` | `syncing` | `error` | `conflict`. pub state: String, /// Last successful sync, epoch seconds; `0` if never. pub last_sync: i64, /// What the folder's sync pass is doing right now, or `None` when no pass is /// running. Live daemon state, not a stored column. #[serde(default)] pub progress: Option, /// The folder is paused on its own (see [`Request::SetSyncFolderPaused`]). #[serde(default)] pub paused: bool, } /// A folder under this machine's device offered for restore (in /// [`Response::RestorableFolders`]). #[derive(Serialize, Deserialize, Debug, Clone)] pub struct RestorableFolder { /// Uid of the remote folder — the handle for [`RestoreItem`]. pub remote_uid: String, /// Its remote name. pub name: String, /// Where the daemon *proposes* to put it: the path the profile recorded when /// that path makes sense on this machine, else `~/`. A front-end shows /// this as an editable default, never applies it silently. pub local_path: String, /// Proposed mode, from the profile; `mirror` when unknown. pub mode: String, /// Already synced on this machine, so restoring it again would be a no-op. /// Listed anyway so the picker shows the whole device. #[serde(default)] pub already_synced: bool, } /// One folder to restore, in [`Request::RestoreSyncFolders`]. #[derive(Serialize, Deserialize, Debug, Clone)] pub struct RestoreItem { pub remote_uid: String, /// Absolute local path to sync it to; created if missing. pub local_path: String, /// `mirror` or `ondemand`. pub mode: String, } /// Which stage a running sync pass is in, in a [`SyncProgress`]. #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] pub enum SyncPhase { /// Walking the local tree, the remote tree and the stored baseline to work /// out what changed. `done` counts the items checked so far; `total` is how /// many the last pass saw, so it is an estimate the walk can overshoot. Scanning, /// Applying the diff: creating folders, uploading, downloading, deleting. Applying, } /// A snapshot of a sync pass in flight (in [`SyncFolderInfo::progress`]), so a /// front-end can say what the daemon is doing rather than just "syncing". #[derive(Serialize, Deserialize, Debug, Clone)] pub struct SyncProgress { pub phase: SyncPhase, /// Items checked ([`SyncPhase::Scanning`]) or applied ([`SyncPhase::Applying`]) /// so far this pass. pub done: usize, /// How many items `done` is counting towards. Neither phase can fix this up /// front, so it moves: while scanning it is the size of the last pass's /// baseline — an estimate the walk may overshoot when the folder has grown — /// and while applying it *grows*, because paths are classified depth by depth /// (a folder must exist remotely before its children can be queued). `0` means /// no estimate exists (a folder that has never synced), i.e. indeterminate. pub total: usize, /// The name of an item currently being applied, or empty between items. /// Several run at once; this is just the most recently started. pub current: String, } /// One member or pending invitation on a node's share (in [`Response::Share`]). #[derive(Serialize, Deserialize, Debug, Clone)] pub struct ShareEntry { /// Membership id (members) or invitation id (invitations) — the handle for /// [`Request::UpdateShareRole`]/[`Request::RemoveShareEntry`]. pub id: String, /// The member's / invitee's email address. pub email: String, /// Role: "viewer", "editor", "admin" or "inherited". pub role: String, /// Whether this is a member, a pending Proton invite, or an external invite. pub kind: ShareEntryKind, } /// A node's public link (in [`Response::Share`] / [`Response::PublicLink`]). #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct PublicLinkInfo { /// Public-link id — the handle for [`Request::RemovePublicLink`]. pub id: String, /// The shareable URL including the `#password` fragment, when known (always on /// creation; absent when only listed). pub url: Option, /// Role granted to anyone with the link ("viewer" or "editor"). pub role: String, /// Expiry, epoch seconds, if set. pub expires: Option, /// Whether a custom password additionally protects the link. pub has_password: bool, /// When the link was made, epoch seconds; 0 when unknown. #[serde(default)] pub created: i64, } /// One invitation addressed to me (in [`Response::Invitations`]). #[derive(Serialize, Deserialize, Debug, Clone)] pub struct InvitationInfo { /// Invitation id — the handle for [`Request::AcceptInvitation`]/[`Request::RejectInvitation`]. pub id: String, /// The email of the user who shared the item. pub inviter_email: String, /// The shared item's decrypted name, when available. pub name: Option, /// Whether the shared item is a folder. pub is_dir: bool, } /// One saved public link (in [`Response::Bookmarks`]). #[derive(Serialize, Deserialize, Debug, Clone)] pub struct BookmarkInfo { /// Bookmark token — the handle for [`Request::DeleteBookmark`]. pub token: String, /// The full public URL including the `#password` fragment. pub url: String, /// The bookmarked item's decrypted name, when available. pub name: Option, /// Whether the bookmarked item is a folder. pub is_dir: bool, } /// One node I have shared with others (in [`Response::SharedByMe`]). Summarizes /// the share's state so the "Shared" view renders in one pass: how many people /// have access, how many invitations are still pending, and the public link if /// the node has one. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct SharedItem { /// Node uid in `volume~link` form — the handle for opening the node's share /// (via its mount path) or its details. pub uid: String, /// The shared node's decrypted name. pub name: String, pub is_dir: bool, /// Modification time used to validate an image thumbnail. Older daemons /// omit it, in which case clients use `0` as a conservative fallback tag. #[serde(default)] pub modified: i64, /// Mountpoint-relative path, when the daemon can resolve it (the node lives in /// my own tree). Empty when the path is unknown. #[serde(default)] pub path: String, /// Accepted members (people who already have access), excluding me. pub member_count: usize, /// Pending invitations (Proton + external) not yet accepted. pub invite_count: usize, /// The node's public link, if it has one. pub link: Option, } /// One entry of a file's version history (in [`Response::Revisions`]). /// /// The size and modification time are the ones the *uploader* claimed in the /// revision's extended attributes, so an old client that wrote none leaves them /// `None`; `size_on_storage` is always known but counts ciphertext, which is /// larger than the file. A front-end shows the claimed size when it has one and /// falls back to the storage size. #[derive(Serialize, Deserialize, Debug, Clone)] pub struct RevisionInfo { /// Server-assigned id — the handle for [`Request::RestoreRevision`], /// [`Request::DeleteRevision`] and [`Request::SaveRevisionAs`]. pub id: String, /// True for the revision that is the file's current content. It cannot be /// deleted, and restoring it is a no-op. pub is_active: bool, /// When the revision was created, epoch seconds. pub created: i64, /// Encrypted size on Proton's storage, in bytes. pub size_on_storage: i64, /// Plaintext size as claimed by the uploader, when it wrote one. pub claimed_size: Option, /// Modification time as claimed by the uploader (ISO-8601, verbatim). pub claimed_modified: Option, /// The address that signed the revision, when it was not signed anonymously /// by the node key. pub signed_by: Option, /// Whether the revision carries thumbnails. pub has_thumbnails: bool, } /// What happened, in an [`ActivityEntry`]. Kept coarse: a front-end maps each to /// an icon and a verb, and the human detail lives in the entry's fields. #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] pub enum ActivityKind { Upload, Download, /// A whole sync pass over one folder, summarising what it moved. Sync, Rename, Move, CreateFolder, Trash, Restore, DeleteForever, EmptyTrash, Share, PublicLink, Unshare, /// A `(sync-conflict …)` copy that needs the user's attention: it diverges /// from the live file (or has no sibling), so the auto-sweep left it in place /// rather than removing it. An auto-*removed* identical copy is logged as a /// [`ActivityKind::Trash`] instead, since that is what happened to it. Conflict, } /// One line in the daemon's activity log (in [`Response::Activity`]). Newest /// first. Records a mutation or transfer the daemon performed, with enough /// context to read as a sentence: "Uploaded report.pdf to /docs". #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct ActivityEntry { /// When it happened, epoch seconds. pub time: i64, pub kind: ActivityKind, /// The primary item's name (a file/folder name, or a count like "3 items"). pub target: String, /// Extra context: a destination path, a new name, an invitee, an error /// message. Empty when there is nothing to add. #[serde(default)] pub detail: String, /// Whether the operation succeeded. A failed entry still appears, so the log /// shows what was attempted. pub ok: bool, } /// Which way an active transfer is moving, in a [`TransferItem`]. #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] pub enum TransferDirection { Download, Upload, } /// One long-running daemon job in a [`Response::Transfers`] snapshot: work that /// takes long enough to need reporting but doesn't move bytes over the wire — /// walking a local tree, building a remote folder skeleton, indexing `$HOME`. /// Byte-moving work is a [`TransferItem`] instead. #[derive(Serialize, Deserialize, Debug, Clone)] pub struct JobItem { /// What the job is, as a front-end would title it ("Uploading files"). pub title: String, /// What it is doing right now ("Scanning Photos/2024"), or empty. pub detail: String, /// Steps finished so far. pub done: u64, /// Steps known to need doing, or `0` when unknown (indeterminate progress). /// May *grow* mid-job as more work is discovered. pub total: u64, /// Housekeeping that changes nothing in the user's files: a mirror folder /// scan that finds nothing to do yet, an index rebuild, a cache check. Front /// ends may show it, but should not report the drive as busy over it. Absent /// from older daemons, which never set it. #[serde(default)] pub background: bool, } /// One in-flight transfer in a [`Response::Transfers`] snapshot. #[derive(Serialize, Deserialize, Debug, Clone)] pub struct TransferItem { /// Node uid in `volume~link` form (empty for an upload whose uid isn't known /// until the draft is sealed). pub uid: String, /// File name being transferred. pub name: String, pub direction: TransferDirection, /// Bytes moved so far. pub bytes_completed: u64, /// Total bytes expected, or `0` when unknown (indeterminate progress). pub bytes_total: u64, /// Average throughput since the transfer began, bytes per second. pub speed_bytes_sec: u64, } /// One entry in a [`Request::ListDir`] listing. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] pub struct DirEntry { /// Decrypted node name. pub name: String, pub is_dir: bool, /// Plaintext size in bytes (0 for folders). pub size: u64, /// Modification time, epoch seconds. pub modified: i64, /// Whether the file is pinned to this device. pub pinned: bool, /// Whether the file's full content is present in the local cache (a current, /// non-stale blob). Always false for folders. Defaulted for wire-compat with /// clients/daemons predating the field. #[serde(default)] pub cached: bool, /// Node uid in `volume~link` form, for follow-up requests. pub uid: String, /// Full mountpoint-relative path. Empty for a [`Request::ListDir`] listing /// (the entry lives in the requested directory, so the caller derives the /// path by joining its name) and for a [`Request::ListTrash`] listing (a /// trashed node has no path inside the mount at all); populated when an entry /// can live anywhere in the tree, as for search hits rendered through the /// browser. #[serde(default)] pub path: String, /// My role on this node when it is reached through a share I accepted — /// `"viewer"`, `"editor"`, `"admin"`, or empty for content I own (where the /// question does not arise) and for a share whose role the API did not /// report. Defaulted for wire-compat with clients/daemons predating it. #[serde(default)] pub role: String, /// Who shared this node with me, as the invitation claims it — empty for /// content I own, for a node reached *inside* a shared folder (only the share /// root carries the invitation), and when the API did not say. Trust it only /// as far as [`shared_by_unverified`](Self::shared_by_unverified) allows. /// Defaulted for wire-compat with clients/daemons predating it. #[serde(default)] pub shared_by: String, /// When it was shared with me, epoch seconds; 0 when unknown. #[serde(default)] pub shared_at: i64, /// The invitation's signature did not check out against `shared_by`'s keys, /// so the name may be forged. False when it verified *and* when it could not /// be checked at all (no signature, no keys): only an actual mismatch is a /// warning worth showing. #[serde(default)] pub shared_by_unverified: bool, /// When a [`Request::ListTrash`] entry went to the trash, epoch seconds; 0 /// when unknown, and for anything not in the trash. The API does not report /// it to this client, so the daemon notes when it first saw the node there. #[serde(default)] pub trashed_at: i64, /// The folder a [`Request::ListTrash`] entry goes back to on restore, as a /// mountpoint-relative path (`""` for the root). `None` when that folder is /// not known, and for anything not in the trash. #[serde(default)] pub trashed_from: Option, /// Why Drive refused a change queued on the node, for a [`Request::ListDir`] /// entry; `None` otherwise. #[serde(default)] pub issue: Option, } /// One hit in a [`Request::Search`] result. Like [`DirEntry`] but carries the /// full mountpoint-relative `path` (a hit can live anywhere in the tree), so the /// front-end can navigate to or open it directly. #[derive(Serialize, Deserialize, Debug, Clone)] pub struct SearchHit { pub name: String, /// Mountpoint-relative path (`/`-joined, no leading slash). pub path: String, pub is_dir: bool, pub size: u64, pub modified: i64, pub pinned: bool, /// Whether a valid copy of the content is in the local cache. Legacy /// daemons omit it; clients then treat the file as online-only. #[serde(default)] pub cached: bool, /// Node uid in `volume~link` form. pub uid: String, /// Absolute path through the most specific active on-demand mount, when the /// node is covered by one. Clients should prefer this for folders and /// streamable media, and fall back to `mountpoint/path` when absent. #[serde(default)] pub mounted_path: Option, /// Relevance assigned by the daemon. Higher scores sort first. Legacy /// daemons omit it, in which case clients retain the daemon's result order. #[serde(default)] pub score: i64, } /// One hit in a [`Request::SearchLocal`] result: a file on this machine, outside /// Proton Drive. Unlike [`SearchHit`] there is no uid or pin state — the file is /// already local, so the front-end opens `path` directly. #[derive(Serialize, Deserialize, Debug, Clone)] pub struct LocalHit { pub name: String, /// Absolute path on this machine. pub path: String, pub is_dir: bool, pub size: u64, /// Modification time, epoch seconds. pub modified: i64, /// Relevance assigned by the daemon. Higher scores sort first. Legacy /// daemons omit it, in which case clients retain the daemon's result order. #[serde(default)] pub score: i64, } /// A searchable corpus in [`Request::SearchV2`]. Kept explicit on the wire so /// future sources can be added without multiplying request variants. #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum SearchSource { Drive, Local, } /// Coarse result kind used by the prompt's facets. Classification belongs in /// core/the daemon and must happen before the per-source limit is applied. #[derive(Serialize, Deserialize, Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] pub enum SearchKind { #[default] All, Folders, Documents, Images, Media, } impl SearchKind { /// Classify a result before the caller applies its final limit. This stays /// beside the wire enum so every front end and both search corpora agree. pub fn accepts(self, name: &str, is_dir: bool) -> bool { if self == Self::All { return true; } if self == Self::Folders { return is_dir; } if is_dir { return false; } let extension = std::path::Path::new(name) .extension() .and_then(|value| value.to_str()) .unwrap_or_default() .to_ascii_lowercase(); match self { Self::Documents => matches!( extension.as_str(), "pdf" | "doc" | "docx" | "odt" | "rtf" | "txt" | "md" | "xls" | "xlsx" | "ods" | "csv" | "ppt" | "pptx" | "odp" | "epub" ), Self::Images => matches!( extension.as_str(), "png" | "jpg" | "jpeg" | "webp" | "gif" | "bmp" | "svg" | "avif" | "heic" | "tiff" ), Self::Media => { PhotoKind::classify(Some(name), None) == PhotoKind::Video || matches!( extension.as_str(), "mp3" | "flac" | "wav" | "ogg" | "opus" | "m4a" | "aac" ) } Self::All | Self::Folders => unreachable!(), } } } /// Optional constraints for a unified search. The default deliberately means /// the original SearchV2 behaviour, preserving old JSON requests. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] pub struct SearchFilters { /// Sources to query. An explicit empty list searches neither source. #[serde(default = "default_search_sources")] pub sources: Vec, #[serde(default)] pub kind: SearchKind, } impl Default for SearchFilters { fn default() -> Self { Self { sources: default_search_sources(), kind: SearchKind::All, } } } fn default_search_sources() -> Vec { vec![SearchSource::Drive, SearchSource::Local] } /// What kind of media a timeline entry is, so the Photos page can split into /// Photos / Videos / Raw tabs. Derived from a photo's media type when the daemon /// has resolved it, falling back to its file-name extension (see /// [`PhotoKind::classify`]). #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum PhotoKind { /// A normal, directly viewable still image (JPEG, PNG, HEIC, …). Photo, /// A video clip (mp4, mkv, mov, …). Video, /// A camera raw file (CR2, NEF, ARW, DNG, …) — an image, but one that needs /// developing and is worth separating from ready-to-view photos. Raw, } /// File-name extensions that denote a video, matched case-insensitively. const VIDEO_EXTS: &[&str] = &[ "mkv", "mp4", "mov", "avi", "webm", "m4v", "flv", "wmv", "mpg", "mpeg", "ts", "3gp", "m2ts", "mts", "ogv", ]; /// File-name extensions that denote a camera raw. The server media type for /// these is frequently a generic `application/octet-stream`, so the extension is /// the authoritative signal. const RAW_EXTS: &[&str] = &[ "cr2", "cr3", "nef", "nrw", "arw", "srf", "sr2", "dng", "raf", "orf", "rw2", "srw", "pef", "raw", "rwl", "iiq", "3fr", "dcr", "kdc", "mrw", "x3f", ]; /// File-name extensions that denote a ready-to-view still photo. Listed so a /// known image name classifies as a photo outright, without deferring to a media /// type that might disagree. const PHOTO_EXTS: &[&str] = &[ "jpg", "jpeg", "png", "gif", "webp", "heic", "heif", "avif", "bmp", "tif", "tiff", ]; impl PhotoKind { /// Classify a photo from what the daemon knows about it. A recognised /// file-name extension is authoritative — it is reliable even before the /// node's media type is resolved, and for raw files the media type is often a /// useless generic — so the media type is only consulted for names that carry /// no extension we know. Anything still unresolved is a still photo. pub fn classify(name: Option<&str>, media_type: Option<&str>) -> PhotoKind { if let Some(ext) = name .and_then(|n| n.rsplit_once('.')) .map(|(_, e)| e.to_ascii_lowercase()) { if RAW_EXTS.contains(&ext.as_str()) { return PhotoKind::Raw; } if VIDEO_EXTS.contains(&ext.as_str()) { return PhotoKind::Video; } if PHOTO_EXTS.contains(&ext.as_str()) { return PhotoKind::Photo; } } if let Some(mt) = media_type && mt.starts_with("video/") { return PhotoKind::Video; } PhotoKind::Photo } /// The stable integer this kind is persisted as (see the `kind` column of the /// `photos` table). Chosen once and never reordered. pub fn as_i64(self) -> i64 { match self { PhotoKind::Photo => 0, PhotoKind::Video => 1, PhotoKind::Raw => 2, } } /// Inverse of [`PhotoKind::as_i64`]; any unrecognised value reads as a still /// photo, the safe default for a tab that would otherwise show nothing. pub fn from_i64(v: i64) -> PhotoKind { match v { 1 => PhotoKind::Video, 2 => PhotoKind::Raw, _ => PhotoKind::Photo, } } } /// One month the timeline spans, with how many photos it holds — a tick on the /// date scrubber (reply to [`Request::PhotoMonths`]). #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] pub struct PhotoMonth { /// Local-time calendar year, e.g. `2026`. pub year: i32, /// Local-time month, `1..=12`. pub month: i32, /// Photos captured in that month (within the requested kind, if any). pub count: usize, } /// How a photo re-date is going, or how the last one ended /// ([`Response::RedateStatus`]). Cumulative for the run, so the same shape /// serves progress and the final report. #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)] pub struct RedateSummary { /// Photos examined. pub examined: usize, /// Photos whose name implies a capture time more than a day from the one /// they carry — what the run would (or did) repair. pub candidates: usize, /// Photos re-uploaded with the right capture time, their original trashed. pub redated: usize, /// Photos that could not be downloaded, re-uploaded, or trashed. The /// original is left alone in every failure case. pub failed: usize, /// Album memberships re-created on the new copies. pub album_links: usize, /// Bytes uploaded. pub bytes: u64, /// True when the run stopped early because it was cancelled. pub cancelled: bool, /// True when nothing was actually uploaded or trashed. pub dry_run: bool, /// Up to a handful of examples, as `(name, stored capture time, the time the /// name implies)` — what a dry run is read for. #[serde(default)] pub samples: Vec<(String, i64, i64)>, } /// How a Google Photos Takeout import is going, or how the last one ended /// ([`Response::ImportStatus`]). /// /// The counts are cumulative for the run, so the same shape serves progress and /// the final report. `found` counts *distinct* photos: Google stores a separate /// copy of a photo in each album folder it appears in, and those fold into one /// upload that joins several albums. #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)] pub struct ImportSummary { /// Distinct photos the export holds. pub found: usize, /// Photos uploaded by this run. pub uploaded: usize, /// Photos already on the account, matched by name *and* content digest. pub duplicates: usize, /// Photos that could not be read or uploaded. pub failed: usize, /// Photos in the export's trash bucket, which are never imported. pub skipped_trashed: usize, /// Albums created by this run. pub albums_created: usize, /// Album memberships added by this run. pub album_links: usize, /// Bytes uploaded. pub bytes: u64, /// True when the run stopped early because it was cancelled. pub cancelled: bool, } /// One album in a [`Response::Albums`] listing. /// /// An album is a folder node on the photos volume carrying album properties, so /// it has an ordinary name; everything else here comes from those properties. #[derive(Serialize, Deserialize, Debug, Clone)] pub struct AlbumInfo { /// Album node uid in `volume~link` form — what [`Request::AlbumPhotos`] /// takes. pub uid: String, pub name: String, /// How many photos the server says the album holds. pub photo_count: usize, /// The photo shown as the album's cover, when it has one. Its thumbnail is /// fetched with [`Request::PhotoThumbs`] like any other photo. pub cover_uid: Option, /// Epoch seconds of the last change to the album's contents, when the server /// reports one. The listing is ordered by it, newest first. pub last_activity: Option, /// True when this album lives on someone else's photos volume — it is shared /// with us rather than ours. pub shared: bool, } /// One town in a [`Response::Places`] listing. #[derive(Serialize, Deserialize, Debug, Clone)] pub struct PlaceInfo { /// The town's GeoNames id — what [`Request::PlacePhotos`] takes. pub id: u32, /// The town's name, as the town itself spells it. pub name: String, /// ISO 3166-1 alpha-2 code of the town's country. pub country: String, /// Where the town is, in decimal degrees, for the map. Zero from a daemon /// older than the map. #[serde(default)] pub latitude: f64, #[serde(default)] pub longitude: f64, /// How many photos were taken there, counting a RAW+JPEG shot once. pub photo_count: usize, /// The newest photo taken there, shown as the place's cover. pub cover: PhotoItem, } /// One photo and where it was taken, in decimal degrees. Part of /// [`Response::PhotoLocations`]. #[derive(Serialize, Deserialize, Debug, Clone)] pub struct PhotoLocation { pub photo: PhotoItem, pub latitude: f64, pub longitude: f64, } /// How many of the timeline's photos a running refresh has read the metadata /// (and so the location) of, out of how many. Part of [`Response::Places`], /// and of [`Response::PhotoSimilar`] for how many shots have been hashed. #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] pub struct MappingProgress { pub done: usize, pub total: usize, } /// One photo in a [`Request::PhotosTimeline`] page. #[derive(Serialize, Deserialize, Debug, Clone)] pub struct PhotoItem { /// Node uid in `volume~link` form. pub uid: String, /// Capture time, epoch seconds (the timeline is newest-first). pub capture_time: i64, /// On-disk path to the cached thumbnail, when one is *already* cached. A /// `None` here means "not fetched yet", not "has no thumbnail" — ask for it /// with [`Request::PhotoThumbs`]. pub thumb_path: Option, /// File name, when the daemon knows it. pub name: Option, /// Aspect ratio (w/h), remembered by the daemon from the last time this /// photo's thumbnail was decoded. Lets the gallery justify its rows correctly /// on the first frame instead of guessing and reflowing as images land. pub ratio: Option, /// True when this photo can never be given a thumbnail — the server has none /// and its bytes could not be decoded locally. The tile shows a placeholder /// rather than waiting for an image that will never come. pub no_thumb: bool, /// Which Photos-page tab this entry belongs to. Older daemons that predate /// the split omit it; a front-end then treats everything as a still photo. #[serde(default = "default_photo_kind")] pub kind: PhotoKind, /// Whether the photo is favorited. Older daemons omit it, which a front-end /// reads as "not favorited" — the same thing it would show for a photo whose /// tags it has not learned yet. #[serde(default)] pub favorite: bool, /// How many files this entry stands for: a shot stored as a RAW and a JPEG /// is one entry and two files. Older daemons omit it, and `0` reads the same /// as `1` — one file, nothing to indicate. #[serde(default)] pub group_size: u32, /// Whether one of those files is a camera raw. The tile badges it, because /// the JPEG it shows is not the whole of what is stored. #[serde(default)] pub has_raw: bool, } /// A daemon too old to classify a timeline entry is assumed to have served a /// still photo — that was the only kind the Photos page showed before the split. fn default_photo_kind() -> PhotoKind { PhotoKind::Photo } /// One node a [`Request::TrashNodes`] batch could not trash, and why. #[derive(Serialize, Deserialize, Debug, Clone)] pub struct TrashFailure { pub uid: String, pub message: String, } /// One thumbnail in a [`Response::Thumbs`] batch. #[derive(Serialize, Deserialize, Debug, Clone)] pub struct PhotoThumb { pub uid: String, /// On-disk path, or `None` when there is no thumbnail to serve *yet*. pub path: Option, /// True when the daemon is making this thumbnail itself, because the server /// has none: the photo's full file is downloading and will be scaled when it /// lands. A `None` path with `pending` set means "ask again shortly"; a `None` /// path *without* it means the photo can never have a thumbnail, and a /// front-end should stop asking. /// /// Generation is not made to block the reply: one 20 MB camera photo takes /// far longer to fetch than the whole rest of a batch, and holding the batch /// for it would leave a screenful of ready thumbnails unpainted. pub pending: bool, } /// One ordinary Drive file whose thumbnail a front-end wants to paint. #[derive(Serialize, Deserialize, Debug, Clone)] pub struct FileThumbRequest { /// Node uid in `volume~link` form. pub uid: String, /// File modification time, used as the thumbnail cache validity tag. pub modified: i64, /// Original Drive name. Local RAW-preview extraction uses the extension as a /// format hint; defaulted for wire compatibility with older front-ends. #[serde(default)] pub name: String, } /// Whether a Drive name is a raster image that the built-in decoder can read, /// or a concrete camera RAW format whose embedded preview exiftool can extract. /// Keep this list shared by the daemon and every GUI surface so a visible tile /// is never advertised without a matching generation path. pub fn is_thumbnail_image_name(name: &str) -> bool { let Some((_, extension)) = name.rsplit_once('.') else { return false; }; let extension = extension.to_ascii_lowercase(); STANDARD_THUMBNAIL_EXTENSIONS.contains(&extension.as_str()) || RAW_THUMBNAIL_EXTENSIONS.contains(&extension.as_str()) } /// Whether `name` is one of the concrete RAW formats handled by the exiftool /// embedded-preview path. Deliberately excludes generic/legacy grab-bags whose /// preview availability is not dependable. pub fn is_raw_image_name(name: &str) -> bool { let Some((_, extension)) = name.rsplit_once('.') else { return false; }; let extension = extension.to_ascii_lowercase(); RAW_THUMBNAIL_EXTENSIONS.contains(&extension.as_str()) } const STANDARD_THUMBNAIL_EXTENSIONS: &[&str] = &["bmp", "gif", "jpeg", "jpg", "png", "tif", "tiff", "webp"]; /// Concrete camera RAW formats with embedded previews handled by exiftool. /// This is the single source used by both RAW classification and the broader /// thumbnail capability check. const RAW_THUMBNAIL_EXTENSIONS: &[&str] = &[ "arw", "cr2", "cr3", "crw", "dng", "k25", "kdc", "mrw", "nef", "nrw", "orf", "pef", "raf", "raw", "rw2", "sr2", "srf", "x3f", ]; /// Progress of the one recursive ordinary-file thumbnail build the daemon may /// run at a time. #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)] pub struct ThumbnailBuildStatus { pub running: bool, /// True while folders are still being discovered, before the final image /// count is known and the progress bar can become determinate. pub scanning: bool, /// Mountpoint-relative root selected when the build started. pub path: String, pub folders_scanned: u64, pub images_found: u64, /// Images already cached or processed during this build. Includes failures, /// so `completed == images_found` still means the job has finished. pub completed: u64, /// Images that could not be downloaded or decoded. pub failed: u64, /// A traversal-level problem, such as a folder that could not be listed. #[serde(default)] pub message: Option, } /// A daemon too old to report connectivity is assumed online — it could not /// have mounted at all otherwise. fn default_online() -> bool { true } /// Phrase the queue depth of a [`Response::Status`] for a human, or `None` when /// there is nothing queued and the caller should say nothing at all. /// /// Lives here, next to the counts it describes, because the tray, the CLI and /// the manager window all have to draw the same distinction between bytes that /// have not reached the remote and metadata that has not. pub fn pending_summary(uploads: u64, changes: u64) -> Option { let part = |n: u64, one: &str, many: &str| match n { 0 => None, 1 => Some(format!("1 {one}")), n => Some(format!("{n} {many}")), }; let parts: Vec = [ part(uploads, "upload", "uploads"), part(changes, "change", "changes"), ] .into_iter() .flatten() .collect(); match parts.is_empty() { true => None, false => Some(format!("{} queued", parts.join(", "))), } } /// One worker thread in a [`Diagnostics`] report. #[derive(Serialize, Deserialize, Debug, Clone)] pub struct WorkerState { /// Thread name (`pdfs-fuse-meta-0`, `pdfs-fuse-3`). pub name: String, pub busy: bool, /// The job in hand (`read`, `lookup`), empty when idle. pub label: String, /// How long it has been in that state. A busy worker with a large age is /// the shape of a hang. pub age_secs: u64, } /// One control request the daemon is still serving, in a [`Diagnostics`] report. #[derive(Serialize, Deserialize, Debug, Clone)] pub struct InflightRequest { /// The request's variant name (`OpenFile`, `PhotosTimeline`). pub kind: String, pub age_secs: u64, } /// What the daemon is doing right now (reply to [`Request::Diagnostics`]). #[derive(Serialize, Deserialize, Debug, Clone, Default)] pub struct Diagnostics { /// How long this daemon process has been running. pub uptime_secs: u64, /// Resident set size, or `0` where it could not be read. The 2026-09-18 hang /// peaked at 5.2 GiB, so this is the number that says whether the daemon is /// growing again. pub rss_bytes: u64, pub workers: Vec, /// Jobs waiting for a worker, per lane. pub meta_queued: usize, pub transfer_queued: usize, /// Jobs finished per lane since start. Queue depth that does not move while /// these do not either is a stalled lane, not a loaded one. pub meta_completed: u64, pub transfer_completed: u64, /// Control requests still being served, oldest first. pub inflight: Vec, /// Control handler threads in use, and the ceiling. At the ceiling the /// daemon refuses new connections, which looks exactly like a dead daemon /// from the CLI. pub control_handlers: usize, pub control_handler_limit: usize, /// Queued mutations not yet drained. pub pending_ops: u64, } /// The daemon's reply to a [`Request`]. #[derive(Serialize, Deserialize, Debug)] pub enum Response { /// Current mount status. Carries the cache stats the daemon already holds /// (`used`/`budget` bytes and the pin list) so a front-end never has to open /// the on-disk cache itself on its UI thread. Status { username: String, mountpoint: String, pinned: usize, /// Bytes of cached content blobs (see [`crate::cache::ContentCache::usage`]). used: u64, /// Configured soft byte cap (`0` = unlimited). budget: u64, /// The pin registry. pins: Vec, /// False when the daemon is serving the cached tree because the API is /// unreachable. Cached and pinned content still /// reads; anything else fails until the network is back. #[serde(default = "default_online")] online: bool, /// Writes accepted locally but not yet uploaded. /// Non-zero means the mount is ahead of the remote — either a copy is /// still draining, or it cannot drain because we are offline. #[serde(default)] pending_uploads: u64, /// Queued mutations that carry no bytes: `mkdir`, `rename`, `trash`. /// Counted apart from `pending_uploads` because /// calling a queued `mkdir` an upload is a lie. #[serde(default)] pending_changes: u64, /// Queued writes held back because the file still wears a transient /// name (a browser's `*.crdownload`, an editor's `*.swp`). They are not /// waiting on the network and will not drain until the finalising /// rename, so they are counted apart from `pending_uploads`. #[serde(default)] parked_uploads: u64, /// Queued ops that have failed enough times to be considered stuck. /// They keep retrying forever — correctly, since their staged bytes are /// the only copy — which is why they have to be visible. #[serde(default)] failing_ops: u64, /// The most recent error from a stuck op, when there is one. #[serde(default)] failing_error: Option, /// Bytes held in `staging/`: writes accepted from the kernel whose only /// copy is on local disk. Never evictable, never counted against the /// cache budget — hence reported separately. #[serde(default)] staged_bytes: u64, /// Age of the oldest staged write, in seconds. #[serde(default)] staged_oldest_secs: u64, /// True while the user has sync paused (see [`Request::SetSyncPaused`]). #[serde(default)] paused: bool, /// The Unix second a timed pause ends; `None` when paused until /// resumed, or not paused. #[serde(default)] paused_until: Option, /// True when Proton refused the session itself (refresh token refused, /// account deleted or disabled). `online` is false then too, but no /// connection brings it back: only signing in again does. #[serde(default)] session_expired: bool, /// True while the keyring refuses the session's refreshed tokens. The /// daemon keeps retrying; until it succeeds, a reboot signs the user /// out. #[serde(default)] tokens_unsaved: bool, }, /// A human-readable success message. Ok { message: String }, /// Health report for the metadata database and content cache (reply to /// [`Request::CacheInspect`]). CacheReport { /// Schema version the database file is at. schema_version: i64, /// Bytes the database file accounts for. db_bytes: u64, /// Bytes a vacuum could hand back. db_reclaimable_bytes: u64, /// Row counts per table, in display order. tables: Vec<(String, i64)>, /// Bytes of cached content blobs, blocks, and thumbnails. cache_used: u64, /// Configured soft byte cap (`0` = unlimited). cache_budget: u64, /// Problems SQLite reported. Empty means sound; absent when the caller /// did not ask for a deep check. integrity_problems: Vec, /// Whether the integrity check actually ran, so an empty problem list /// is not mistaken for a clean bill of health it never gave. integrity_checked: bool, /// Whether a deep check is running right now. It reads every page of /// the database under the daemon's only connection, so it is answered /// asynchronously: this says "ask again shortly", which is a different /// thing to say than either of the two above. #[serde(default)] integrity_running: bool, }, /// The pin registry. Pins { pins: Vec }, /// A directory listing (reply to [`Request::ListDir`]) or a trash listing /// (reply to [`Request::ListTrash`]). Entries { entries: Vec }, /// A page of the photos timeline. `available` is false when the account /// has no photos volume. Photos { available: bool, items: Vec, /// Whole-timeline tab counts `(photos, videos, raw)`, so a front-end can /// label its Photos / Videos / Raw filter without paging the library. /// The counts describe the *whole* timeline regardless of the page's own /// `kind` filter. Older daemons omit it; a front-end then shows no counts. #[serde(default)] counts: Option<(usize, usize, usize)>, }, /// The account's photo albums (reply to [`Request::PhotoAlbums`]). /// `available` is false when the account has no photos volume, matching /// [`Response::Photos`]. Albums { available: bool, items: Vec, }, /// What the daemon is doing right now (reply to [`Request::Diagnostics`]). Diagnostics { diagnostics: Diagnostics }, /// How the Takeout import is doing (reply to [`Request::ImportStatus`]). /// `running` is false once it has finished, when `summary` is the final /// report; `summary` is `None` when no import has run this session. ImportStatus { running: bool, #[serde(default)] summary: Option, }, /// How the photo re-date is doing (reply to [`Request::RedateStatus`]). /// `running` is false once it has finished, when `summary` is the final /// report; `summary` is `None` when no re-date has run this session. RedateStatus { running: bool, #[serde(default)] summary: Option, }, /// The months the timeline spans (reply to [`Request::PhotoMonths`]), /// newest first. PhotoMonths { months: Vec }, /// The duplicate sets (reply to [`Request::PhotoDuplicates`]), newest /// first. Each set lists the copy worth keeping first. PhotoDuplicates { sets: Vec> }, /// The sets of alike shots (reply to [`Request::PhotoSimilar`]), newest /// first, each in capture order; and how far hashing has got, `None` /// once no pass is running. `uncompared` counts the shots left out /// because no thumbnail could be read for them. PhotoSimilar { sets: Vec>, #[serde(default)] hashing: Option, #[serde(default)] uncompared: usize, }, /// The towns the photos were taken in (reply to [`Request::PhotoPlaces`]), /// and how far a running timeline refresh has got reading photo locations /// — `None` when none is reading any. Places { items: Vec, #[serde(default)] mapping: Option, }, /// The photos of one town, each with its location (reply to /// [`Request::PlaceLocations`]). PhotoLocations { items: Vec }, /// Thumbnails for a [`Request::PhotoThumbs`] batch. Thumbs { items: Vec }, /// Per-node outcome of a [`Request::TrashNodes`] batch. Reported per uid /// rather than as one verdict: the server answers each node separately, and /// a front-end that removed all of them on a partial success would show a /// photo as gone that is still there. Trashed { trashed: Vec, failed: Vec, }, /// The album a [`Request::CreateAlbum`] made. AlbumCreated { uid: String }, /// Outcome of a [`Request::AddToAlbum`] or [`Request::RemoveFromAlbum`], /// per photo like [`Response::Trashed`]: `changed` photos were added or /// removed, `failed` ones were not. AlbumChanged { changed: Vec, failed: Vec, }, /// A [`Request::FileThumbs`] generation is no longer current. This is /// retryable after reserving a fresh generation and must never be interpreted /// as a permanent "no thumbnail" verdict. FileThumbsStale, /// Daemon-issued generation reserved by /// [`Request::ReserveFileThumbGeneration`]. FileThumbGeneration { generation: u64 }, /// Current recursive thumbnail-build progress. ThumbnailBuild { status: ThumbnailBuildStatus }, /// Whether a photos-timeline refresh is still running (reply to /// [`Request::PhotosRefreshStatus`]). PhotosRefresh { running: bool }, /// An on-disk path the front-end can open (e.g. a downloaded photo). FilePath { path: String }, /// Full-text search results (reply to [`Request::Search`]). SearchResults { hits: Vec }, /// Local-file search results (reply to [`Request::SearchLocal`]). `indexing` /// is true while a scan of the machine is still running, so a front-end can /// say "still indexing" instead of "no matches" on a cold first launch. LocalResults { hits: Vec, indexing: bool }, /// Drive and local-file search results returned together (reply to /// [`Request::SearchV2`]). `local_indexing` distinguishes a genuinely empty /// local result set from one observed while the initial scan is running. SearchResultsV2 { drive_hits: Vec, local_hits: Vec, local_indexing: bool, }, /// A snapshot of what the daemon is working on (reply to /// [`Request::GetQueueStatus`]): `items` are byte-moving transfers, `jobs` /// the longer non-transfer work around them (scans, folder skeletons, the /// local index, sync passes). Both empty means the daemon is idle. Transfers { items: Vec, #[serde(default)] jobs: Vec, }, /// The account's devices (reply to [`Request::ListDevices`]). Devices { items: Vec }, /// Every local Proton Drive location (reply to [`Request::ListLocations`]). Locations { items: Vec }, /// This device's synced folders (reply to [`Request::ListSyncFolders`]). SyncFolders { items: Vec }, /// The upload/change queue (reply to [`Request::ListPendingOps`]). PendingOps { items: Vec }, /// Conflict copies (reply to [`Request::ListConflicts`]). Conflicts { items: Vec }, /// Folders offered for restore (reply to [`Request::ListRestorableFolders`]). RestorableFolders { items: Vec }, /// A node's share: members + pending invitations, and its public link if any /// (reply to [`Request::ListShare`]). Share { entries: Vec, link: Option, }, /// A freshly created public link, carrying the shareable URL (reply to /// [`Request::CreatePublicLink`]). PublicLink { link: PublicLinkInfo }, /// Invitations addressed to me (reply to [`Request::ListInvitations`]). Invitations { items: Vec }, /// Saved public links (reply to [`Request::ListBookmarks`]). Bookmarks { items: Vec }, /// Account storage usage in bytes (reply to [`Request::AccountQuota`]). /// `max_space` is the total; `used_space` what is consumed. `max_space == 0` /// means the total is unknown, which a front-end shows as a plain used figure /// rather than a full bar. AccountQuota { max_space: i64, used_space: i64 }, /// Nodes I have shared with others (reply to [`Request::ListSharedByMe`]). SharedByMe { items: Vec }, /// A file's version history, newest first (reply to /// [`Request::ListRevisions`]). Revisions { items: Vec }, /// The daemon's recent activity, newest first (reply to /// [`Request::ListActivity`]). Activity { items: Vec }, /// The request failed. `message` is for the user; `kind` is for the code — /// a front-end decides its copy and whether to offer a retry from `kind`, /// never by matching on the text. Error { message: String, #[serde(default)] kind: ErrorKind, }, } impl Response { /// Build a failure reply from a classified error, so the `kind` a /// request-serving method decided survives the trip to the front-end. pub fn error(e: crate::error::CoreError) -> Self { Response::Error { message: e.message, kind: e.kind, } } } /// What class of thing went wrong, as opposed to what it read like. /// /// The daemon answers most calls with prose assembled from whatever layer /// failed (`"resolve path: ENOENT"`), which is fine to show and useless to /// branch on. This is the branchable half: enough to pick the right copy, to /// know whether retrying is meaningful, and to tell a caller's mistake apart /// from an outage. /// /// Deliberately coarse. A variant earns its place by changing what a front-end /// *does*, not by naming a distinct cause — anything finer belongs in `message`. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ErrorKind { /// The API is unreachable and the request needed it. The cached tree is /// still being served, so this is a "not right now", not a failure of the /// thing the user asked for. Offline, /// No node at that path or uid. Usually means the front-end's listing is /// stale, so the useful response is to reload it rather than to retry. NotFound, /// The account may not do that to that node — a viewer trying to write, a /// share whose role was downgraded. Retrying changes nothing. Denied, /// The remote moved underneath the request: a name already taken, a /// revision superseded. The caller has to decide, so never auto-retried. Conflict, /// The request itself was malformed — an empty name, a path with a `/` in /// it, an unparseable uid. A bug in the caller, not a condition to retry. Invalid, /// The API was reached and refused, or the transfer broke. The one class /// where an unchanged retry can legitimately succeed. Remote, /// The account is out of storage. Distinct from [`Denied`](Self::Denied) /// because the user *can* fix it, and distinct from [`Remote`](Self::Remote) /// because retrying an upload that did not fit will not make it fit. Quota, /// Something on this machine failed: the database, the content cache, the /// filesystem. Not the user's doing and not theirs to fix. #[default] Internal, } impl ErrorKind { /// Whether repeating the identical request could plausibly succeed. /// /// Drives whether a front-end offers "Try again" at all: offering it for a /// [`NotFound`](Self::NotFound) or an [`Invalid`](Self::Invalid) teaches the /// user that the button does nothing. pub fn retryable(self) -> bool { matches!(self, ErrorKind::Offline | ErrorKind::Remote) } } impl Request { /// Whether this is one of the cheap state reads that front ends repeat on a /// timer. The daemon answers these without touching the network, so they /// get the short [`POLL_READ_TIMEOUT`]. pub fn is_poll(&self) -> bool { matches!( self, Request::Status | Request::GetQueueStatus | Request::ListLocations | Request::ListPendingOps | Request::ListConflicts | Request::ListActivity { .. } | Request::ThumbnailBuildStatus | Request::PhotosRefreshStatus | Request::RedateStatus | Request::ImportStatus ) } /// How long to wait for the daemon's reply to this request. pub fn read_timeout(&self) -> Duration { if self.is_poll() { POLL_READ_TIMEOUT } else { READ_TIMEOUT } } } /// Send one [`Request`] to the daemon listening on `socket` and read its /// [`Response`]. Returns a crate [`crate::Error`] if no daemon is listening, and /// one for which [`crate::Error::is_timeout`] holds if the daemon took longer /// than [`Request::read_timeout`] to answer. /// /// Shared by the CLI and GUI so both speak the wire format identically. pub fn send(socket: &Path, req: &Request) -> Result { let mut stream = UnixStream::connect(socket)?; stream.set_read_timeout(Some(req.read_timeout()))?; stream.set_write_timeout(Some(WRITE_TIMEOUT))?; let mut line = serde_json::to_vec(req)?; line.push(b'\n'); stream.write_all(&line)?; stream.flush()?; let mut reader = BufReader::new(stream); let mut resp = String::new(); reader.read_line(&mut resp)?; Ok(serde_json::from_str(resp.trim())?) } /// An open [`Request::Subscribe`] connection. See [`subscribe`]. pub struct Subscription { reader: BufReader, line: String, } /// Start following the daemon listening on `socket`. Fails if no daemon is /// listening. A daemon too old to know [`Request::Subscribe`] answers it with an /// error, which the first [`Subscription::next_event`] returns as /// [`crate::Error::Other`]. pub fn subscribe(socket: &Path) -> Result { let mut stream = UnixStream::connect(socket)?; stream.set_read_timeout(Some(SUBSCRIBE_READ_TIMEOUT))?; stream.set_write_timeout(Some(WRITE_TIMEOUT))?; let mut line = serde_json::to_vec(&Request::Subscribe)?; line.push(b'\n'); stream.write_all(&line)?; stream.flush()?; Ok(Subscription { reader: BufReader::new(stream), line: String::new(), }) } impl Subscription { /// Wait for the next event. `Ok(None)` means the daemon closed the /// connection; a timeout means it went [`SUBSCRIBE_READ_TIMEOUT`] without /// even a heartbeat. A line from a newer daemon that this client cannot /// read is skipped rather than ending the subscription. pub fn next_event(&mut self) -> Result> { loop { self.line.clear(); if self.reader.read_line(&mut self.line)? == 0 { return Ok(None); } let line = self.line.trim(); if let Ok(event) = serde_json::from_str::(line) { return Ok(Some(event)); } if let Ok(Response::Error { message, .. }) = serde_json::from_str::(line) { return Err(crate::Error::Other(message)); } } } } /// What [`follow`] reports. #[derive(Debug)] pub enum Feed { /// A subscription started. The events after it describe the daemon's /// current state, so a front-end catches up from them. Connected, Event(Event), /// The subscription ended: the daemon stopped, or stopped answering. Disconnected, } /// How often [`follow`] polls a daemon that cannot push events. const LEGACY_POLL: Duration = Duration::from_secs(3); /// The longest [`follow`] waits between attempts to reach a daemon. const RECONNECT_MAX: Duration = Duration::from_secs(5); /// Follow the daemon on `socket` for as long as `on` returns true, across /// daemon restarts. Blocks, so run it on a thread of its own. /// /// A daemon that predates [`Request::Subscribe`] (still running after an /// upgrade, until its service restarts) is polled for its status and transfers /// instead, so a front-end sees the same events either way, only later. pub fn follow(socket: &Path, mut on: impl FnMut(Feed) -> bool) { let mut connected = false; let mut backoff = Duration::from_secs(1); loop { let ended = match subscribe(socket) { Ok(mut subscription) => { let mut first = true; loop { match subscription.next_event() { Ok(Some(event)) => { if first { first = false; connected = true; backoff = Duration::from_secs(1); if !on(Feed::Connected) { return; } } if !on(Feed::Event(event)) { return; } } Ok(None) => break Ok(()), Err(e) => break Err(e), } } } Err(e) => Err(e), }; if let Err(crate::Error::Other(_)) = ended { if !connected { connected = true; if !on(Feed::Connected) { return; } } if !poll_legacy(socket, &mut on) { return; } std::thread::sleep(LEGACY_POLL); continue; } if connected { connected = false; if !on(Feed::Disconnected) { return; } } std::thread::sleep(backoff); backoff = (backoff * 2).min(RECONNECT_MAX); } } /// One round of [`follow`]'s fallback: the status and transfers a subscription /// would have pushed. Returns what `on` returned. fn poll_legacy(socket: &Path, on: &mut impl FnMut(Feed) -> bool) -> bool { match send(socket, &Request::Status) { Ok(status @ Response::Status { .. }) => { if !on(Feed::Event(Event::Status(Box::new(status)))) { return false; } } _ => return true, } if let Ok(Response::Transfers { items, jobs }) = send(socket, &Request::GetQueueStatus) { return on(Feed::Event(Event::Transfers { items, jobs })); } true } #[cfg(test)] mod tests { use super::*; #[test] fn polls_wait_briefly_and_real_work_waits_long() { assert_eq!(Request::Status.read_timeout(), POLL_READ_TIMEOUT); assert_eq!( Request::ListActivity { limit: 200 }.read_timeout(), POLL_READ_TIMEOUT ); assert_eq!( Request::OpenFile { path: "a.txt".into(), uid: None, } .read_timeout(), READ_TIMEOUT ); } /// A daemon on a fresh socket that reads the subscription request and /// answers with `lines`, then hangs up. fn fake_daemon(name: &str, lines: &'static str) -> std::path::PathBuf { let socket = std::env::temp_dir().join(format!("pdfs-{name}-{}.sock", std::process::id())); let _ = std::fs::remove_file(&socket); let listener = std::os::unix::net::UnixListener::bind(&socket).unwrap(); std::thread::spawn(move || { let (stream, _) = listener.accept().unwrap(); let mut reader = BufReader::new(stream); let mut request = String::new(); reader.read_line(&mut request).unwrap(); assert!(matches!( serde_json::from_str::(request.trim()), Ok(Request::Subscribe) )); reader.get_mut().write_all(lines.as_bytes()).unwrap(); }); socket } #[test] fn a_subscription_skips_what_it_cannot_read() { let socket = fake_daemon( "subscribe", "not json\n\ {\"Changed\":{\"topics\":[\"files\",\"from_a_newer_daemon\"]}}\n\ {\"NewEvent\":{}}\n\ \"Heartbeat\"\n", ); let mut subscription = subscribe(&socket).unwrap(); let first = subscription.next_event().unwrap(); let second = subscription.next_event().unwrap(); let end = subscription.next_event().unwrap(); let _ = std::fs::remove_file(&socket); match first { Some(Event::Changed { topics }) => { assert_eq!(topics, vec![Topic::Files, Topic::Unknown]); } other => panic!("expected a change, got {other:?}"), } assert!(matches!(second, Some(Event::Heartbeat)), "{second:?}"); assert!(end.is_none(), "{end:?}"); } #[test] fn a_daemon_without_subscriptions_is_told_apart_from_a_dead_one() { let socket = fake_daemon("legacy", "{\"Error\":{\"message\":\"unknown request\"}}\n"); let mut subscription = subscribe(&socket).unwrap(); let answer = subscription.next_event(); let _ = std::fs::remove_file(&socket); assert!(matches!(answer, Err(crate::Error::Other(_))), "{answer:?}"); } #[test] fn a_daemon_that_never_answers_times_out_as_not_responding() { let socket = std::env::temp_dir().join(format!("pdfs-silent-{}.sock", std::process::id())); let _ = std::fs::remove_file(&socket); // Bound but never accepted: connect succeeds, no reply ever comes. let _listener = std::os::unix::net::UnixListener::bind(&socket).unwrap(); let started = std::time::Instant::now(); let err = send(&socket, &Request::Status).unwrap_err(); let _ = std::fs::remove_file(&socket); assert!(err.is_timeout(), "{err}"); assert!(started.elapsed() < READ_TIMEOUT); } #[test] fn photo_kind_classifies_by_extension_then_mime() { use PhotoKind::*; // Raw is extension-driven: the server media type is often generic. assert_eq!(PhotoKind::classify(Some("IMG_1.CR2"), None), Raw); assert_eq!( PhotoKind::classify(Some("shot.dng"), Some("application/octet-stream")), Raw ); // Video by extension or by mime when the name has no useful extension. assert_eq!(PhotoKind::classify(Some("anime.mkv"), None), Video); assert_eq!(PhotoKind::classify(Some("clip.MP4"), None), Video); assert_eq!(PhotoKind::classify(None, Some("video/quicktime")), Video); // Everything else is a still photo, including a mismatched raw mime whose // name says it is a normal JPEG. assert_eq!(PhotoKind::classify(Some("pic.jpg"), None), Photo); assert_eq!(PhotoKind::classify(Some("pic.heic"), None), Photo); assert_eq!(PhotoKind::classify(None, None), Photo); // Extension wins over mime: a name ending .jpg is a photo even if the mime // is nonsense. assert_eq!(PhotoKind::classify(Some("x.jpg"), Some("video/mp4")), Photo); } /// Keep every extension accepted by the shared classifier covered. Drive /// activation uses this classification before it knows whether opening a /// file should stream through FUSE or materialise a complete cache entry; /// silently dropping one of these formats would therefore regress large /// videos back to the expensive whole-file path. #[test] fn photo_kind_recognises_every_supported_video_extension() { for ext in VIDEO_EXTS { let lower = format!("movie.{ext}"); let upper = format!("MOVIE.{}", ext.to_ascii_uppercase()); assert_eq!( PhotoKind::classify(Some(&lower), None), PhotoKind::Video, "lower-case .{ext}" ); assert_eq!( PhotoKind::classify(Some(&upper), None), PhotoKind::Video, "upper-case .{ext}" ); } } #[test] fn photo_kind_uses_video_mime_only_when_extension_is_not_authoritative() { use PhotoKind::*; assert_eq!( PhotoKind::classify(Some("extensionless"), Some("video/mp4; codecs=avc1")), Video ); assert_eq!( PhotoKind::classify(Some("clip.unknown"), Some("video/x-matroska")), Video ); // Audio is not a video and must not accidentally take the video // streaming policy merely because both are time-based media. assert_eq!( PhotoKind::classify(Some("recording"), Some("audio/flac")), Photo ); // A known still/raw suffix remains authoritative over server metadata. assert_eq!( PhotoKind::classify(Some("poster.AVIF"), Some("video/mp4")), Photo ); assert_eq!( PhotoKind::classify(Some("negative.NEF"), Some("video/mp4")), Raw ); } #[test] fn photo_kind_i64_round_trips() { for k in [PhotoKind::Photo, PhotoKind::Video, PhotoKind::Raw] { assert_eq!(PhotoKind::from_i64(k.as_i64()), k); } // An unknown persisted value degrades to a still photo. assert_eq!(PhotoKind::from_i64(99), PhotoKind::Photo); } /// The whole point of the split counts: a queued `mkdir` is work, but it is /// not an upload and must never be reported as one. #[test] fn pending_summary_separates_uploads_from_other_changes() { assert_eq!(pending_summary(0, 0), None); assert_eq!(pending_summary(1, 0).as_deref(), Some("1 upload queued")); assert_eq!(pending_summary(3, 0).as_deref(), Some("3 uploads queued")); assert_eq!(pending_summary(0, 1).as_deref(), Some("1 change queued")); assert_eq!( pending_summary(2, 4).as_deref(), Some("2 uploads, 4 changes queued") ); } /// The mutation requests must survive a line-delimited JSON round-trip, since /// that is exactly how they cross the control socket. #[test] fn mutation_requests_roundtrip() { let reqs = [ Request::Rename { path: "a/b.txt".into(), new_name: "c.txt".into(), }, Request::Move { path: "a/b.txt".into(), new_parent: "d".into(), }, Request::Delete { path: "a/b.txt".into(), }, Request::CreateFolder { parent: "a".into(), name: "new".into(), }, Request::UploadPhoto { name: "p.jpg".into(), media_type: "image/jpeg".into(), source_path: "/home/u/p.jpg".into(), capture_time: Some(1_700_000_000), }, Request::UploadPaths { parent: "a".into(), sources: vec!["/home/u/x.txt".into(), "/home/u/pics".into()], }, ]; for req in reqs { let line = serde_json::to_string(&req).unwrap(); assert!(!line.contains('\n'), "wire form must be a single line"); let back: Request = serde_json::from_str(&line).unwrap(); // Round-trip is lossless: re-serializing yields the same bytes. assert_eq!(line, serde_json::to_string(&back).unwrap()); } } #[test] fn file_thumbnail_requests_keep_their_revision_tags() { let request = Request::FileThumbs { items: vec![ FileThumbRequest { uid: "volume~first".into(), modified: 1_700_000_001, name: "first.jpg".into(), }, FileThumbRequest { uid: "volume~second".into(), modified: 1_700_000_002, name: "second.nef".into(), }, ], generation: 42, }; let line = serde_json::to_string(&request).unwrap(); let decoded: Request = serde_json::from_str(&line).unwrap(); assert_eq!(line, serde_json::to_string(&decoded).unwrap()); } #[test] fn legacy_file_thumbnail_requests_default_the_name() { let decoded: Request = serde_json::from_str( r#"{"FileThumbs":{"items":[{"uid":"volume~first","modified":1700000001}],"generation":42}}"#, ) .unwrap(); let Request::FileThumbs { items, generation } = decoded else { panic!("decoded the wrong request variant"); }; assert_eq!(generation, 42); assert_eq!(items.len(), 1); assert!(items[0].name.is_empty()); } /// The lightbox asks for one shot's files by the uid of the tile that was /// clicked, and gets them back in the shape the gallery already paints. #[test] fn photo_group_requests_roundtrip() { let request = Request::PhotoGroup { uid: "volume~first".into(), }; let line = serde_json::to_string(&request).unwrap(); assert_eq!(line, r#"{"PhotoGroup":{"uid":"volume~first"}}"#); let decoded: Request = serde_json::from_str(&line).unwrap(); assert_eq!(line, serde_json::to_string(&decoded).unwrap()); } /// The group fields are new, so a page from a daemon that predates them has /// to keep painting: one file, nothing to badge. #[test] fn an_older_daemons_photo_has_no_group() { let item: PhotoItem = serde_json::from_str( r#"{"uid":"volume~first","capture_time":1700000000,"thumb_path":null, "name":"a.jpg","ratio":1.5,"no_thumb":false}"#, ) .unwrap(); assert_eq!(item.group_size, 0, "absent reads the same as one file"); assert!(!item.has_raw); } /// The gallery trashes by uid, and reports per-uid outcomes back: a partial /// failure has to survive the wire so the GUI can put those tiles back. #[test] fn trash_requests_and_outcomes_roundtrip() { let request = Request::TrashNodes { uids: vec!["volume~first".into(), "volume~second".into()], files_only: false, }; // An older front-end's request trashes whole groups. let old: Request = serde_json::from_str(r#"{"TrashNodes":{"uids":["v~a"]}}"#).unwrap(); assert!(matches!( old, Request::TrashNodes { files_only: false, .. } )); let line = serde_json::to_string(&request).unwrap(); assert!(!line.contains('\n'), "wire form must be a single line"); let decoded: Request = serde_json::from_str(&line).unwrap(); assert_eq!(line, serde_json::to_string(&decoded).unwrap()); let response = Response::Trashed { trashed: vec!["volume~first".into()], failed: vec![TrashFailure { uid: "volume~second".into(), message: "permission denied".into(), }], }; let line = serde_json::to_string(&response).unwrap(); let decoded: Response = serde_json::from_str(&line).unwrap(); let Response::Trashed { trashed, failed } = decoded else { panic!("decoded the wrong response variant"); }; assert_eq!(trashed, ["volume~first"]); assert_eq!(failed.len(), 1); assert_eq!(failed[0].uid, "volume~second"); assert_eq!(failed[0].message, "permission denied"); } #[test] fn thumbnail_build_requests_and_progress_roundtrip() { let requests = [ Request::ReserveFileThumbGeneration, Request::CancelFileThumbs { generation: 43 }, Request::StartThumbnailBuild { path: "pictures/events".into(), }, Request::CancelThumbnailBuild, Request::ThumbnailBuildStatus, ]; for request in requests { let line = serde_json::to_string(&request).unwrap(); let decoded: Request = serde_json::from_str(&line).unwrap(); assert_eq!(line, serde_json::to_string(&decoded).unwrap()); } let response = Response::ThumbnailBuild { status: ThumbnailBuildStatus { running: true, scanning: false, path: "pictures/events".into(), folders_scanned: 12, images_found: 80, completed: 25, failed: 1, message: None, }, }; let line = serde_json::to_string(&response).unwrap(); let decoded: Response = serde_json::from_str(&line).unwrap(); assert_eq!(line, serde_json::to_string(&decoded).unwrap()); let response = Response::FileThumbGeneration { generation: 44 }; let line = serde_json::to_string(&response).unwrap(); let decoded: Response = serde_json::from_str(&line).unwrap(); assert_eq!(line, serde_json::to_string(&decoded).unwrap()); let response = Response::FileThumbsStale; let line = serde_json::to_string(&response).unwrap(); let decoded: Response = serde_json::from_str(&line).unwrap(); assert_eq!(line, serde_json::to_string(&decoded).unwrap()); } /// `full` defaults, so a front-end built against the older wire shape still /// asks for the ordinary refresh rather than failing to parse. #[test] fn a_photos_refresh_scope_defaults_to_not_full() { let scope: RefreshScope = serde_json::from_str(r#"{"Photos":{}}"#).unwrap(); assert_eq!(scope, RefreshScope::Photos { full: false }); let line = serde_json::to_string(&RefreshScope::Photos { full: true }).unwrap(); assert_eq!(line, r#"{"Photos":{"full":true}}"#); } /// A photos refresh is followed, not awaited, so the request that reports /// its progress is part of the wire contract. #[test] fn photos_refresh_status_roundtrips() { let line = serde_json::to_string(&Request::PhotosRefreshStatus).unwrap(); assert_eq!(line, r#""PhotosRefreshStatus""#); let decoded: Request = serde_json::from_str(&line).unwrap(); assert_eq!(line, serde_json::to_string(&decoded).unwrap()); let line = serde_json::to_string(&Response::PhotosRefresh { running: true }).unwrap(); assert_eq!(line, r#"{"PhotosRefresh":{"running":true}}"#); let decoded: Response = serde_json::from_str(&line).unwrap(); assert_eq!(line, serde_json::to_string(&decoded).unwrap()); } #[test] fn unified_search_request_and_response_roundtrip() { let request = Request::SearchV2 { query: "quarterly report".into(), limit: 37, filters: SearchFilters::default(), }; let line = serde_json::to_string(&request).unwrap(); assert_eq!( line, r#"{"SearchV2":{"query":"quarterly report","limit":37,"filters":{"sources":["Drive","Local"],"kind":"All"}}}"# ); let decoded: Request = serde_json::from_str(&line).unwrap(); assert_eq!(line, serde_json::to_string(&decoded).unwrap()); let response = Response::SearchResultsV2 { drive_hits: vec![SearchHit { name: "report.pdf".into(), path: "Work/report.pdf".into(), is_dir: false, size: 12, modified: 123, pinned: true, cached: false, uid: "vol~link".into(), mounted_path: Some("/home/me/Videos/report.pdf".into()), score: 985, }], local_hits: vec![LocalHit { name: "notes.txt".into(), path: "/home/me/notes.txt".into(), is_dir: false, size: 7, modified: 456, score: 720, }], local_indexing: true, }; let line = serde_json::to_string(&response).unwrap(); assert!(!line.contains('\n')); let decoded: Response = serde_json::from_str(&line).unwrap(); match decoded { Response::SearchResultsV2 { drive_hits, local_hits, local_indexing, } => { assert_eq!(drive_hits.len(), 1); assert_eq!(drive_hits[0].uid, "vol~link"); assert_eq!(drive_hits[0].score, 985); assert_eq!(local_hits.len(), 1); assert_eq!(local_hits[0].path, "/home/me/notes.txt"); assert_eq!(local_hits[0].score, 720); assert!(local_indexing); } other => panic!("expected unified search results, got {other:?}"), } } #[test] fn search_v2_legacy_wire_form_gets_compatible_defaults() { let decoded: Request = serde_json::from_str(r#"{"SearchV2":{"query":"old client","limit":20}}"#).unwrap(); match decoded { Request::SearchV2 { query, limit, filters, } => { assert_eq!(query, "old client"); assert_eq!(limit, 20); assert_eq!(filters, SearchFilters::default()); } other => panic!("expected SearchV2, got {other:?}"), } // New daemons add scores, but old-daemon replies remain readable and // signal that their existing order should be retained with score 0. let decoded: Response = serde_json::from_str( r#"{"SearchResultsV2":{"drive_hits":[{"name":"a","path":"a","is_dir":false,"size":1,"modified":2,"pinned":false,"uid":"v~a"}],"local_hits":[{"name":"b","path":"/b","is_dir":false,"size":3,"modified":4}],"local_indexing":false}}"#, ) .unwrap(); match decoded { Response::SearchResultsV2 { drive_hits, local_hits, .. } => { assert_eq!(drive_hits[0].score, 0); assert_eq!(local_hits[0].score, 0); } other => panic!("expected SearchResultsV2, got {other:?}"), } } #[test] fn search_v2_filters_roundtrip() { let request = Request::SearchV2 { query: "holiday".into(), limit: 50, filters: SearchFilters { sources: vec![SearchSource::Drive], kind: SearchKind::Images, }, }; let line = serde_json::to_string(&request).unwrap(); let decoded: Request = serde_json::from_str(&line).unwrap(); assert!(matches!( decoded, Request::SearchV2 { filters: SearchFilters { sources, kind: SearchKind::Images }, .. } if sources == vec![SearchSource::Drive] )); } #[test] fn search_kind_classifies_before_results_are_limited() { assert!(SearchKind::Folders.accepts("movie.mp4", true)); assert!(!SearchKind::Folders.accepts("movie.mp4", false)); assert!(SearchKind::Documents.accepts("REPORT.PDF", false)); assert!(SearchKind::Images.accepts("scan.HEIC", false)); assert!(SearchKind::Media.accepts("film.mkv", false)); assert!(SearchKind::Media.accepts("recording.flac", false)); assert!(!SearchKind::Media.accepts("poster.jpg", false)); assert!(SearchKind::All.accepts("anything", true)); } #[test] fn legacy_search_wire_forms_still_decode() { let drive: Request = serde_json::from_str(r#"{"Search":{"query":"old","limit":20}}"#).unwrap(); assert!(matches!( drive, Request::Search { query, limit, scope: None } if query == "old" && limit == 20 )); let local: Request = serde_json::from_str(r#"{"SearchLocal":{"query":"old","limit":10}}"#).unwrap(); assert!(matches!( local, Request::SearchLocal { query, limit } if query == "old" && limit == 10 )); } #[test] fn delete_album_keeps_photos_unless_asked() { let request: Request = serde_json::from_str(r#"{"DeleteAlbum":{"uid":"v~a"}}"#).unwrap(); assert!(matches!( request, Request::DeleteAlbum { uid, delete_photos: false } if uid == "v~a" )); } /// The trash requests carry uids rather than paths; they must survive the same /// line-delimited round-trip, since a mangled uid would restore or destroy the /// wrong node. #[test] fn trash_requests_roundtrip() { let reqs = [ Request::ListTrash, Request::Restore { uids: vec!["vol~link".into(), "vol~other".into()], }, Request::DeleteForever { uids: vec!["vol~link".into()], }, Request::EmptyTrash, Request::Refresh { scope: RefreshScope::Dir { path: "a/b".into() }, }, Request::Refresh { scope: RefreshScope::Dir { path: String::new(), }, }, Request::Refresh { scope: RefreshScope::Trash, }, Request::Refresh { scope: RefreshScope::Photos { full: false }, }, Request::Refresh { scope: RefreshScope::Photos { full: true }, }, ]; for req in reqs { let line = serde_json::to_string(&req).unwrap(); assert!(!line.contains('\n'), "wire form must be a single line"); let back: Request = serde_json::from_str(&line).unwrap(); assert_eq!(line, serde_json::to_string(&back).unwrap()); } } /// A place listing and a place page cross the wire intact: a mangled id /// would open the wrong town. #[test] fn places_requests_and_reply_roundtrip() { for req in [ Request::PhotoPlaces, Request::PlacePhotos { id: 2950159, offset: 60, limit: 30, }, Request::PlaceLocations { id: 2950159 }, ] { let line = serde_json::to_string(&req).unwrap(); let back: Request = serde_json::from_str(&line).unwrap(); assert_eq!(line, serde_json::to_string(&back).unwrap()); } let line = r#"{"Places":{"items":[{"id":2950159,"name":"Berlin","country":"DE","photo_count":2, "cover":{"uid":"vol~b2","capture_time":1700000000,"thumb_path":null,"name":"b2.jpg","ratio":null,"no_thumb":false}}]}}"#; let Response::Places { items, mapping } = serde_json::from_str(line).unwrap() else { panic!("not a place listing"); }; assert_eq!(items.len(), 1); assert_eq!((items[0].id, items[0].country.as_str()), (2950159, "DE")); assert_eq!(items[0].cover.uid, "vol~b2"); // A daemon from before the progress field still parses: no mapping. assert_eq!(mapping, None); let reply = Response::Places { items: Vec::new(), mapping: Some(MappingProgress { done: 120, total: 480, }), }; let line = serde_json::to_string(&reply).unwrap(); let Response::Places { mapping, .. } = serde_json::from_str(&line).unwrap() else { panic!("not a place listing"); }; assert_eq!( mapping, Some(MappingProgress { done: 120, total: 480 }) ); } /// The sharing and devices requests must survive the same line-delimited JSON /// round-trip: a mangled id or role would touch the wrong share or member. #[test] fn sharing_requests_roundtrip() { let reqs = [ Request::ListLocations, Request::ListDevices, Request::RenameDevice { uid: "dev-1".into(), name: "laptop".into(), }, Request::DeleteDevice { uid: "dev-1".into(), }, Request::AddSyncFolder { local_path: "/home/me/Docs".into(), }, Request::ListSyncFolders, Request::RemoveSyncFolder { id: 3, delete_remote: true, }, Request::SetSyncFolderMode { id: 3, mode: "ondemand".into(), }, Request::SetSyncFolderPaused { id: 3, paused: true, }, Request::SetBandwidthLimits { upload: 1_000_000, download: 0, }, Request::SyncNow { id: Some(3) }, Request::AdoptDevice { uid: Some("dev-1".into()), }, Request::AdoptDevice { uid: None }, Request::MigrateDevice { uid: "dev-2".into(), }, Request::ListRestorableFolders, Request::RestoreSyncFolders { items: vec![RestoreItem { remote_uid: "vol~link".into(), local_path: "/home/me/Docs".into(), mode: "mirror".into(), }], }, Request::ListDeviceRestorableFolders { device: "dev-2".into(), }, Request::RestoreDeviceFolders { device: "dev-2".into(), items: vec![RestoreItem { remote_uid: "vol~link".into(), local_path: "/home/me/Work".into(), mode: "ondemand".into(), }], }, Request::ShareNode { path: "a/b".into(), emails: vec!["x@proton.me".into(), "y@example.com".into()], role: "editor".into(), message: Some("hi".into()), }, Request::ShareNodeByUid { uid: "vol~link".into(), emails: vec!["x@proton.me".into(), "y@example.com".into()], role: "editor".into(), message: Some("hi".into()), }, Request::ListShare { path: "a/b".into() }, Request::ListShareByUid { uid: "vol~link".into(), }, Request::UpdateShareRole { path: "a/b".into(), id: "mid-1".into(), kind: ShareEntryKind::Member, role: "admin".into(), }, Request::UpdateShareRoleByUid { uid: "vol~link".into(), id: "mid-1".into(), kind: ShareEntryKind::Member, role: "admin".into(), }, Request::RemoveShareEntry { path: "a/b".into(), id: "iid-1".into(), kind: ShareEntryKind::ExternalInvite, }, Request::RemoveShareEntryByUid { uid: "vol~link".into(), id: "iid-1".into(), kind: ShareEntryKind::ExternalInvite, }, Request::CreatePublicLink { path: "a/b".into(), role: "viewer".into(), password: Some("pw".into()), expires: Some(1_700_000_000), }, Request::CreatePublicLinkByUid { uid: "vol~link".into(), role: "viewer".into(), password: Some("pw".into()), expires: Some(1_700_000_000), }, Request::RemovePublicLink { path: "a/b".into(), id: "url-1".into(), }, Request::RemovePublicLinkByUid { uid: "vol~link".into(), id: "url-1".into(), }, Request::ListSharedByMe, Request::ListActivity { limit: 100 }, Request::ListSharedWithMe, Request::ListSharedFolder { uid: "vol~link".into(), }, Request::OpenSharedFile { uid: "vol~link".into(), }, Request::LeaveShared { uid: "vol~link".into(), }, Request::ListInvitations, Request::AcceptInvitation { id: "inv-1".into() }, Request::RejectInvitation { id: "inv-1".into() }, Request::ListBookmarks, Request::CreateBookmark { url: "https://drive.proton.me/urls/tok#pw".into(), password: None, }, Request::DeleteBookmark { token: "tok".into(), }, Request::AccountQuota, ]; for req in reqs { let line = serde_json::to_string(&req).unwrap(); assert!(!line.contains('\n'), "wire form must be a single line"); let back: Request = serde_json::from_str(&line).unwrap(); assert_eq!(line, serde_json::to_string(&back).unwrap()); } } #[test] fn locations_response_roundtrips_typed_mounts() { let response = Response::Locations { items: vec![ MountSpec { id: 1, kind: MountKind::MyFiles, local_path: "/home/me/ProtonDrive".into(), root_uid: "vol~root".into(), root_share_id: "share-main".into(), mode: MountMode::OnDemand, access: MountAccess::Rw, state: "idle".into(), last_sync: 0, pending_mode: None, mounted: true, progress: None, paused: false, }, MountSpec { id: 2, kind: MountKind::Device { sync_folder_id: 7 }, local_path: "/home/me/Work".into(), root_uid: "device-vol~work".into(), root_share_id: "device-share".into(), mode: MountMode::Mirror, access: MountAccess::Rw, state: "syncing".into(), last_sync: 42, pending_mode: Some(MountMode::OnDemand), mounted: false, progress: None, paused: false, }, MountSpec { id: 3, kind: MountKind::Shared { share_root_uid: "shared-vol~root".into(), }, local_path: "/home/me/Shared".into(), root_uid: "shared-vol~root".into(), root_share_id: "shared-share".into(), mode: MountMode::Unknown, access: MountAccess::Ro, state: "error".into(), last_sync: 0, pending_mode: Some(MountMode::Unknown), mounted: false, progress: Some(SyncProgress { phase: SyncPhase::Applying, done: 2, total: 5, current: "report.pdf".into(), }), paused: true, }, ], }; let wire = serde_json::to_string(&response).unwrap(); assert!(!wire.contains('\n'), "wire form must be a single line"); assert!(wire.contains(r#""mode":"ondemand""#)); assert!(wire.contains(r#""kind":"shared""#)); assert!(wire.contains(r#""access":"ro""#)); assert!(wire.contains(r#""mode":"unknown""#)); let back: Response = serde_json::from_str(&wire).unwrap(); match back { Response::Locations { items } => { assert_eq!(items.len(), 3); assert!(matches!( items[1].kind, MountKind::Device { sync_folder_id: 7 } )); assert_eq!(items[1].pending_mode, Some(MountMode::OnDemand)); assert!(matches!( &items[2].kind, MountKind::Shared { share_root_uid } if share_root_uid == "shared-vol~root" )); assert_eq!(items[2].access, MountAccess::Ro); assert_eq!(items[2].mode, MountMode::Unknown); assert_eq!(items[2].pending_mode, Some(MountMode::Unknown)); let progress = items[2].progress.as_ref().unwrap(); assert_eq!(progress.phase, SyncPhase::Applying); assert_eq!((progress.done, progress.total), (2, 5)); } other => panic!("unexpected response: {other:?}"), } } #[test] fn legacy_daemons_reject_list_locations_cleanly() { #[allow(dead_code)] #[derive(serde::Deserialize)] enum LegacyLocationRequest { ListSyncFolders, } let wire = serde_json::to_string(&Request::ListLocations).unwrap(); assert!( serde_json::from_str::(&wire).is_err(), "an old daemon must reject ListLocations instead of interpreting another request" ); } /// The revision requests come in path and by-uid pairs like the share ones, /// and carry a revision id that must survive the round-trip verbatim: acting /// on the wrong revision deletes content the user meant to keep. #[test] fn revision_requests_roundtrip_in_both_addressing_forms() { let cases = [ ( Request::ListRevisions { path: "a/b".into() }, r#"{"ListRevisions":{"path":"a/b"}}"#, ), ( Request::ListRevisionsByUid { uid: "vol~link".into(), }, r#"{"ListRevisionsByUid":{"uid":"vol~link"}}"#, ), ( Request::RestoreRevision { path: "a/b".into(), revision_id: "rev-1".into(), }, r#"{"RestoreRevision":{"path":"a/b","revision_id":"rev-1"}}"#, ), ( Request::RestoreRevisionByUid { uid: "vol~link".into(), revision_id: "rev-1".into(), }, r#"{"RestoreRevisionByUid":{"uid":"vol~link","revision_id":"rev-1"}}"#, ), ( Request::DeleteRevision { path: "a/b".into(), revision_id: "rev-2".into(), }, r#"{"DeleteRevision":{"path":"a/b","revision_id":"rev-2"}}"#, ), ( Request::DeleteRevisionByUid { uid: "vol~link".into(), revision_id: "rev-2".into(), }, r#"{"DeleteRevisionByUid":{"uid":"vol~link","revision_id":"rev-2"}}"#, ), ( Request::SaveRevisionAs { path: "a/b".into(), revision_id: "rev-3".into(), dest: "/tmp/out.bin".into(), }, r#"{"SaveRevisionAs":{"path":"a/b","revision_id":"rev-3","dest":"/tmp/out.bin"}}"#, ), ( Request::SaveRevisionAsByUid { uid: "vol~link".into(), revision_id: "rev-3".into(), dest: "/tmp/out.bin".into(), }, r#"{"SaveRevisionAsByUid":{"uid":"vol~link","revision_id":"rev-3","dest":"/tmp/out.bin"}}"#, ), ( Request::ExportPendingOp { id: 7, dest: "/tmp/out.bin".into(), }, r#"{"ExportPendingOp":{"id":7,"dest":"/tmp/out.bin"}}"#, ), ( Request::DiscardPendingOp { id: 7 }, r#"{"DiscardPendingOp":{"id":7}}"#, ), ]; for (request, wire) in cases { assert_eq!(serde_json::to_string(&request).unwrap(), wire); let decoded: Request = serde_json::from_str(wire).unwrap(); assert_eq!(serde_json::to_string(&decoded).unwrap(), wire); } } /// A daemon that predates sync issues sends neither field, and a newer one /// may send an issue this build does not know. #[test] fn a_queued_op_from_another_daemon_version_still_decodes() { let old = r#"{"id":1,"kind":"create","path":"a.txt","attempts":0,"last_error":null, "queued_at":5,"next_attempt_at":5,"parked":false,"failing":false}"#; let op: PendingOpInfo = serde_json::from_str(old).unwrap(); assert_eq!(op.issue, None); assert!(!op.exportable); let newer = old.replace(r#""failing":false"#, r#""failing":true,"issue":"embargo""#); let op: PendingOpInfo = serde_json::from_str(&newer).unwrap(); assert_eq!(op.issue, Some(SyncIssue::Unknown)); assert_eq!( SyncIssue::parse(SyncIssue::Quota.as_str()), SyncIssue::Quota ); } /// A front-end that predates the favorites filter sends no `favorites` /// field, and must keep getting the whole timeline rather than an empty page. #[test] fn timeline_requests_without_the_favorites_filter_still_decode() { let decoded: Request = serde_json::from_str(r#"{"PhotosTimeline":{"offset":0,"limit":50}}"#).unwrap(); assert!(matches!( decoded, Request::PhotosTimeline { favorites: false, not_in_album: false, kind: None, range: None, .. } )); let wire = serde_json::to_string(&Request::SetPhotoFavorite { uid: "vol~link".into(), favorite: true, }) .unwrap(); assert_eq!( wire, r#"{"SetPhotoFavorite":{"uid":"vol~link","favorite":true}}"# ); } #[test] fn path_share_requests_keep_their_existing_wire_shape() { let cases = [ ( Request::ShareNode { path: "a/b".into(), emails: vec!["x@example.com".into()], role: "viewer".into(), message: None, }, r#"{"ShareNode":{"path":"a/b","emails":["x@example.com"],"role":"viewer","message":null}}"#, ), ( Request::ListShare { path: "a/b".into() }, r#"{"ListShare":{"path":"a/b"}}"#, ), ( Request::UpdateShareRole { path: "a/b".into(), id: "member".into(), kind: ShareEntryKind::Member, role: "editor".into(), }, r#"{"UpdateShareRole":{"path":"a/b","id":"member","kind":"Member","role":"editor"}}"#, ), ( Request::RemoveShareEntry { path: "a/b".into(), id: "invite".into(), kind: ShareEntryKind::ProtonInvite, }, r#"{"RemoveShareEntry":{"path":"a/b","id":"invite","kind":"ProtonInvite"}}"#, ), ( Request::CreatePublicLink { path: "a/b".into(), role: "viewer".into(), password: None, expires: None, }, r#"{"CreatePublicLink":{"path":"a/b","role":"viewer","password":null,"expires":null}}"#, ), ( Request::RemovePublicLink { path: "a/b".into(), id: "link".into(), }, r#"{"RemovePublicLink":{"path":"a/b","id":"link"}}"#, ), ]; for (request, expected) in cases { assert_eq!(serde_json::to_string(&request).unwrap(), expected); assert!( !expected.contains("\"uid\""), "path variants must never acquire an optional uid" ); } } #[test] fn by_uid_share_requests_are_distinct_wire_variants() { let cases = [ ( Request::ShareNodeByUid { uid: "vol~link".into(), emails: vec!["x@example.com".into()], role: "viewer".into(), message: None, }, "ShareNodeByUid", ), ( Request::ListShareByUid { uid: "vol~link".into(), }, "ListShareByUid", ), ( Request::UpdateShareRoleByUid { uid: "vol~link".into(), id: "member".into(), kind: ShareEntryKind::Member, role: "editor".into(), }, "UpdateShareRoleByUid", ), ( Request::RemoveShareEntryByUid { uid: "vol~link".into(), id: "invite".into(), kind: ShareEntryKind::ProtonInvite, }, "RemoveShareEntryByUid", ), ( Request::CreatePublicLinkByUid { uid: "vol~link".into(), role: "viewer".into(), password: None, expires: None, }, "CreatePublicLinkByUid", ), ( Request::RemovePublicLinkByUid { uid: "vol~link".into(), id: "link".into(), }, "RemovePublicLinkByUid", ), ( Request::StopSharingByUid { uid: "vol~link".into(), }, "StopSharingByUid", ), ]; for (request, variant) in cases { let wire = serde_json::to_string(&request).unwrap(); let object = serde_json::from_str::(&wire) .unwrap() .as_object() .cloned() .unwrap(); assert_eq!(object.len(), 1); assert!(object.contains_key(variant)); let decoded = serde_json::from_str::(&wire).unwrap(); let decoded_uid = match &decoded { Request::ShareNodeByUid { uid, .. } | Request::ListShareByUid { uid } | Request::UpdateShareRoleByUid { uid, .. } | Request::RemoveShareEntryByUid { uid, .. } | Request::CreatePublicLinkByUid { uid, .. } | Request::RemovePublicLinkByUid { uid, .. } | Request::StopSharingByUid { uid } => uid, other => panic!("decoded into the wrong dispatch variant: {other:?}"), }; assert_eq!(decoded_uid, "vol~link"); assert_eq!(serde_json::to_string(&decoded).unwrap(), wire); } } #[test] fn open_file_accepts_a_request_from_a_client_predating_the_uid_field() { let legacy = r#"{"OpenFile":{"path":"Documents/ONBOARDING.md"}}"#; let Request::OpenFile { path, uid } = serde_json::from_str::(legacy).unwrap() else { panic!("decoded into the wrong variant"); }; assert_eq!(path, "Documents/ONBOARDING.md"); assert_eq!(uid, None); let with_uid = serde_json::to_string(&Request::OpenFile { path: "Documents/ONBOARDING.md".into(), uid: Some("vol~link".into()), }) .unwrap(); assert!(with_uid.contains("vol~link")); } #[test] fn legacy_daemons_reject_new_by_uid_variants() { #[allow(dead_code)] #[derive(serde::Deserialize)] enum LegacyShareRequest { ShareNode { path: String, emails: Vec, role: String, message: Option, }, ListShare { path: String, }, UpdateShareRole { path: String, id: String, kind: ShareEntryKind, role: String, }, RemoveShareEntry { path: String, id: String, kind: ShareEntryKind, }, CreatePublicLink { path: String, role: String, password: Option, expires: Option, }, RemovePublicLink { path: String, id: String, }, } let requests = [ Request::ShareNodeByUid { uid: "vol~link".into(), emails: Vec::new(), role: "viewer".into(), message: None, }, Request::ListShareByUid { uid: "vol~link".into(), }, Request::UpdateShareRoleByUid { uid: "vol~link".into(), id: "member".into(), kind: ShareEntryKind::Member, role: "viewer".into(), }, Request::RemoveShareEntryByUid { uid: "vol~link".into(), id: "member".into(), kind: ShareEntryKind::Member, }, Request::CreatePublicLinkByUid { uid: "vol~link".into(), role: "viewer".into(), password: None, expires: None, }, Request::RemovePublicLinkByUid { uid: "vol~link".into(), id: "link".into(), }, ]; for request in requests { let wire = serde_json::to_string(&request).unwrap(); assert!( serde_json::from_str::(&wire).is_err(), "an old daemon must reject {wire}, never reinterpret it as a path request" ); } } /// A daemon built before `kind` existed sends `Error` without the field. /// It must still parse, and land on the class that promises the least. #[test] fn an_error_without_a_kind_reads_as_internal() { let wire = r#"{"Error":{"message":"something broke"}}"#; let back: Response = serde_json::from_str(wire).unwrap(); match back { Response::Error { message, kind } => { assert_eq!(message, "something broke"); assert_eq!(kind, ErrorKind::Internal); } other => panic!("expected an error, got {other:?}"), } } #[test] fn error_kind_survives_the_wire() { for kind in [ ErrorKind::Offline, ErrorKind::NotFound, ErrorKind::Denied, ErrorKind::Conflict, ErrorKind::Invalid, ErrorKind::Remote, ErrorKind::Internal, ] { let line = serde_json::to_string(&Response::Error { message: "x".into(), kind, }) .unwrap(); let back: Response = serde_json::from_str(&line).unwrap(); match back { Response::Error { kind: got, .. } => assert_eq!(got, kind), other => panic!("expected an error, got {other:?}"), } } } /// Retry is offered to the user off the back of this, so it has to mean /// "an identical request could work", not "this looks recoverable". #[test] fn only_offline_and_remote_are_worth_retrying() { assert!(ErrorKind::Offline.retryable()); assert!(ErrorKind::Remote.retryable()); for kind in [ ErrorKind::NotFound, ErrorKind::Denied, ErrorKind::Conflict, ErrorKind::Invalid, ErrorKind::Internal, ] { assert!(!kind.retryable(), "{kind:?} must not offer a retry"); } } #[test] fn response_error_carries_a_core_errors_classification() { let r = Response::error(crate::error::CoreError::not_found("no such file")); match r { Response::Error { message, kind } => { assert_eq!(message, "no such file"); assert_eq!(kind, ErrorKind::NotFound); } other => panic!("expected an error, got {other:?}"), } } }