Files
proton-drive-linux/crates/pdfs-fuse/src/lib.rs
T

5365 lines
210 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! On-demand FUSE filesystem for Proton Drive.
//!
//! Phase 1 is a read-only placeholder mount: directories are enumerated lazily
//! from the remote on first access and file content is hydrated on `read` via
//! [`ProtonDriveClient::download_range`]. Nothing is mirrored to local disk;
//! every byte is fetched on demand.
//!
//! Phase 2 adds live remote sync: a background task polls the volume event
//! cursor ([`ProtonDriveClient::enumerate_events`]) and pushes invalidations
//! into the kernel via a fuser [`Notifier`], so the cache TTL can be long while
//! remote changes still show up promptly.
//!
//! Phase 3 makes the mount writable. Each file opened for writing gets a
//! [`WriteHandle`] whose buffer accumulates the full plaintext; on flush/release
//! the buffer is sealed as a new revision via
//! [`ProtonDriveClient::upload_new_revision`] (the SDK uploads whole revisions,
//! not byte ranges). New files are created empty up front so they get a real
//! uid; namespace ops map to `create_folder`, `trash_nodes`, `rename_node` and
//! `move_node`.
//!
//! Phase 4 adds Files-On-Demand pinning. A control socket (see [`control`])
//! lets the CLI pin/unpin files; a pinned file's plaintext is downloaded once
//! into the on-disk [`ContentCache`] and every later `read` is served from disk
//! instead of the network. Writes and remote events evict the cache so it never
//! goes stale.
//!
//! Reads of unpinned files no longer hit the network per call: [`Core::read_range`]
//! fetches and caches [`BLOCK_SIZE`]-aligned blocks, so sequential/sparse reads
//! reuse the on-disk block cache. Writes are disk-backed: each [`WriteHandle`]
//! stages authored bytes in a scratch file and tracks them with an [`Intervals`]
//! set, so a multi-GiB write never buffers in RAM and only the untouched
//! remainder of the file is pulled from the remote — lazily, at commit.
use std::collections::{HashMap, HashSet};
use std::ffi::OsStr;
use std::fs::File;
use std::io::{BufRead, BufReader, Write as _};
use std::os::unix::fs::FileExt;
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Component, Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use fuser::ReplyXattr;
use fuser::{
BackgroundSession, BsdFileFlags, Config, Errno, FileAttr, FileHandle, FileType, Filesystem,
FopenFlags, Generation, INodeNo, LockOwner, MountOption, Notifier, OpenAccMode, OpenFlags,
RenameFlags, ReplyAttr, ReplyCreate, ReplyData, ReplyDirectory, ReplyEmpty, ReplyEntry,
ReplyOpen, ReplyWrite, Request, Session, TimeOrNow, WriteFlags,
};
use pdfs_core::cache::{BLOCK_SIZE, ContentCache};
use pdfs_core::config::AppDirs;
use pdfs_core::control::{
ActivityEntry, ActivityKind, BookmarkInfo, DeviceInfo, DirEntry, InvitationInfo, JobItem,
LocalHit, PhotoItem, PhotoThumb, PublicLinkInfo, Request as CtlRequest,
Response as CtlResponse, SearchHit, ShareEntry, ShareEntryKind, SharedItem, SyncFolderInfo,
SyncPhase, SyncProgress, TransferDirection,
};
use pdfs_core::db::{
self, Db, StoredDevice, StoredNode, StoredPhoto, StoredSyncFolder, StoredTrash,
};
use pdfs_core::localindex;
use proton_drive_rs::proton_sdk::error::ProtonError;
use proton_drive_rs::proton_sdk::ids::{DeviceUid, DriveEventId, LinkId, NodeUid, VolumeId};
use proton_drive_rs::{
DeviceType, DriveEvent, DriveEventScopeId, MemberRole, Node, NodeKind, ProtonDriveClient,
ProtonPhotosClient, ThumbnailType,
};
mod sync;
mod transfers;
use tracing::{debug, error, info, warn};
use transfers::{CountingReader, CountingWriter, JobGuard, OwnedCountingReader, TransferRegistry};
/// Attribute/entry cache lifetime handed back to the kernel. Long because the
/// Phase 2 event poller actively invalidates changed inodes; without a remote
/// change this is how long the kernel may serve stale metadata.
const TTL: Duration = Duration::from_secs(30);
/// How often the background task polls the remote event cursor.
const POLL_INTERVAL: Duration = Duration::from_secs(10);
/// How long the persisted photos timeline stays good before a page request
/// revalidates it. The SDK hands back the whole timeline at once, so it is stored
/// in the DB and every page is sliced from there; a stale one is still served
/// immediately and refreshed in the background.
const TIMELINE_TTL: Duration = Duration::from_secs(5 * 60);
/// The same, for the persisted trash listing. Shorter, because the trash is the
/// one listing a user changes and then immediately looks at — though our own
/// mutations also invalidate it outright, so this only covers other clients.
const TRASH_TTL: Duration = Duration::from_secs(60);
/// `sync_state` keys for the freshness stamps of the two persisted listings, and
/// for whether the account has a photos volume at all (so an account without one
/// doesn't re-ask the server on every page request).
const PHOTOS_SYNCED_MS: &str = "photos_synced_ms";
const PHOTOS_AVAILABLE: &str = "photos_available";
const TRASH_SYNCED_MS: &str = "trash_synced_ms";
/// Longest edge, in px, of a thumbnail generated locally for a photo the server
/// has none for. Matches the server's own thumbnail scale closely enough that a
/// tile can't tell them apart.
const THUMB_EDGE: u32 = 512;
/// JPEG quality of a locally generated thumbnail.
const THUMB_QUALITY: u8 = 82;
/// How many photos may be downloaded at once to generate their missing
/// thumbnails. Bounded: a screenful of 20 MB digicam JPEGs would otherwise
/// saturate the link and starve the rest of the daemon.
const THUMB_GEN_CONCURRENCY: usize = 4;
/// How stale the local-file index may get before the background scanner rebuilds
/// it. A rescan is a full walk of `$HOME`, so this trades index freshness against
/// disk churn; the scanner also always rebuilds once per daemon start when the
/// index is older than this.
const LOCAL_INDEX_TTL: Duration = Duration::from_secs(15 * 60);
/// How often the scanner thread wakes to check whether the index went stale.
const LOCAL_INDEX_CHECK: Duration = Duration::from_secs(60);
/// The FUSE root inode is always 1.
const ROOT_INO: u64 = 1;
/// Parent inode for a persisted node whose parent row is missing from the DB.
/// No folder carries this inode, so such a node is listed by nobody and stays
/// inert until a live enumeration re-parents it.
const ORPHAN_INO: u64 = 0;
/// Extended attribute exposing the small server-side thumbnail of a file.
const XATTR_THUMBNAIL: &str = "user.proton.thumbnail";
/// Extended attribute exposing the larger server-side preview image of a file.
const XATTR_PREVIEW: &str = "user.proton.preview";
/// A node known to the filesystem, addressed by its kernel inode.
struct Entry {
uid: NodeUid,
parent: u64,
node: Node,
}
/// A set of non-overlapping `[start, end)` byte ranges, kept sorted and merged.
/// Tracks which bytes of a [`WriteHandle`]'s scratch file were authored locally
/// (vs. still living only in the remote base), so reads and the commit gap-fill
/// know which regions to pull from the network.
#[derive(Clone, Default)]
struct Intervals(Vec<(u64, u64)>);
impl Intervals {
/// Mark `[start, end)` as authored, coalescing with any touching ranges.
fn add(&mut self, start: u64, end: u64) {
if start >= end {
return;
}
self.0.push((start, end));
self.0.sort_by_key(|&(s, _)| s);
let mut merged: Vec<(u64, u64)> = Vec::with_capacity(self.0.len());
for &(s, e) in &self.0 {
match merged.last_mut() {
Some(last) if s <= last.1 => last.1 = last.1.max(e),
_ => merged.push((s, e)),
}
}
self.0 = merged;
}
/// Drop everything at or beyond `len` (a shrink/truncate).
fn clip(&mut self, len: u64) {
self.0.retain(|&(s, _)| s < len);
for iv in &mut self.0 {
iv.1 = iv.1.min(len);
}
}
/// Split `[start, end)` into contiguous `(s, e, authored)` segments, in
/// order. `authored == true` means the bytes live in the scratch file;
/// `false` means they must come from the remote base (or are a hole).
fn segments(&self, start: u64, end: u64) -> Vec<(u64, u64, bool)> {
let mut out = Vec::new();
let mut pos = start;
for &(s, e) in &self.0 {
if e <= start {
continue;
}
if s >= end {
break;
}
let ws = s.max(start);
let we = e.min(end);
if pos < ws {
out.push((pos, ws, false));
}
out.push((ws, we, true));
pos = we;
}
if pos < end {
out.push((pos, end, false));
}
out
}
}
/// State for a file opened for writing. Authored bytes are staged in an on-disk
/// scratch file (positional reads/writes) rather than RAM, so a multi-GiB write
/// never balloons the daemon. On flush/release the scratch file — gap-filled
/// from the remote base where untouched — is streamed up as one new revision,
/// since the SDK seals whole revisions rather than byte ranges.
struct WriteHandle {
ino: u64,
uid: NodeUid,
/// Disk-backed staging buffer. Shared (`Arc`) so reads can use it without
/// holding the state lock across I/O. Accessed positionally (`read_at`/
/// `write_at`), so a clone never disturbs another's file offset.
file: Arc<File>,
/// Scratch file path, removed on release.
path: PathBuf,
/// Byte ranges authored into `file`. Everything else in `[0, len)` is base.
written: Intervals,
/// Logical file size (may exceed authored bytes after a truncate-extend).
len: u64,
/// Size of the remote base at open, for serving untouched ranges.
base_size: u64,
/// Modification time of the remote base at open, validating its block cache.
base_mtime: i64,
/// Whether anything diverged from the remote and needs an upload.
dirty: bool,
}
/// Mutable inode bookkeeping, guarded by a mutex because fuser drives the
/// `Filesystem` trait through `&self`.
struct State {
/// inode -> node metadata.
entries: HashMap<u64, Entry>,
/// Dedupe inodes by node uid so a node keeps a stable inode across lookups.
by_uid: HashMap<NodeUid, u64>,
/// Cached directory listings: parent inode -> child inodes. Presence of a
/// key means the directory has been enumerated.
children: HashMap<u64, Vec<u64>>,
next_ino: u64,
/// Open write handles keyed by file handle id. Read-only opens use fh 0 and
/// have no entry here.
handles: HashMap<u64, WriteHandle>,
next_fh: u64,
/// Unified SQLite metadata cache. Every map mutation below writes through to
/// it inside the `State` lock so the DB stays the authoritative copy across
/// restarts (see plan.md P1).
db: Arc<Db>,
}
impl State {
/// Allocate (or reuse) a stable inode for a node and store its metadata,
/// writing the node through to the DB.
fn intern(&mut self, parent: u64, node: Node) -> u64 {
if let Err(e) = self.db.upsert_node(&node) {
warn!(uid = %node.uid, error = %e, "db upsert_node failed");
}
if let Some(&ino) = self.by_uid.get(&node.uid) {
if let Some(e) = self.entries.get_mut(&ino) {
e.node = node;
e.parent = parent;
}
return ino;
}
let ino = self.next_ino;
self.next_ino += 1;
self.by_uid.insert(node.uid.clone(), ino);
self.entries.insert(
ino,
Entry {
uid: node.uid.clone(),
parent,
node,
},
);
ino
}
/// Forget a node entirely: drop its inode, its uid mapping, its own cached
/// listing, its slot in its parent's listing, and its DB row. Returns
/// `(parent_ino, name)` when the node was known, so the caller can notify
/// the kernel.
fn forget(&mut self, uid: &NodeUid) -> Option<(u64, String)> {
let ino = self.by_uid.remove(uid)?;
if let Err(e) = self.db.delete_node(uid) {
warn!(%uid, error = %e, "db delete_node failed");
}
let entry = self.entries.remove(&ino)?;
self.children.remove(&ino);
if let Some(kids) = self.children.get_mut(&entry.parent) {
kids.retain(|&k| k != ino);
}
Some((entry.parent, entry.node.name))
}
/// Drop a directory's cached child listing and mark it unlisted in the DB,
/// so the next access re-enumerates instead of trusting a stale listing.
fn invalidate_listing(&mut self, ino: u64) {
if self.children.remove(&ino).is_none() {
return;
}
if let Some(e) = self.entries.get(&ino) {
let uid = e.uid.clone();
if let Err(err) = self.db.set_listed(&uid, false) {
warn!(%uid, error = %err, "db set_listed(false) failed");
}
}
}
/// Update a file entry's recorded plaintext size so `getattr` reflects an
/// in-progress write before the new revision is sealed.
fn set_size(&mut self, ino: u64, size: u64) {
if let Some(e) = self.entries.get_mut(&ino)
&& let NodeKind::File { claimed_size, .. } = &mut e.node.kind
{
*claimed_size = Some(size as i64);
}
}
/// Update a file entry's modification time (epoch seconds).
fn touch_mtime(&mut self, ino: u64, secs: i64) {
if let Some(e) = self.entries.get_mut(&ino) {
e.node.modification_time = secs;
}
}
}
/// How many files the bulk uploader ships at once. Overlaps the per-file network
/// round-trips without letting an unbounded number of block buffers pile up.
const UPLOAD_CONCURRENCY: usize = 4;
/// One file queued for bulk upload, resolved during the directory walk so the
/// concurrent phase carries everything it needs (no shared state, no `block_on`).
struct UploadTask {
/// Inode of the (already-created) remote parent folder, for interning the
/// uploaded node afterwards.
parent_ino: u64,
parent_uid: NodeUid,
name: String,
/// Local filesystem path to stream from.
path: PathBuf,
size: u64,
}
/// Tally of a completed [`Core::upload_paths`] batch, for the daemon log.
#[derive(Default)]
struct UploadStats {
uploaded: usize,
failed: usize,
/// Total plaintext bytes of the files that uploaded successfully.
bytes: u64,
/// Folders created (or reused) to mirror the local tree.
folders: usize,
}
/// Upload every [`UploadTask`] with at most `limit` in flight at once, each
/// streamed straight from disk and ticking its own transfer-registry guard.
/// Returns, per file, either `(parent_ino, new_uid)` for the caller to intern or
/// `(name, error)` to log — one failure never sinks the batch.
///
/// `job` counts files finished (either way: a failure is still one file the batch
/// no longer waits on), so a front-end can show "12 of 40" over the per-file bars.
async fn run_uploads(
core: Core,
tasks: Vec<UploadTask>,
limit: usize,
job: Arc<JobGuard>,
) -> Vec<Result<(u64, NodeUid, u64), (String, String)>> {
let sem = Arc::new(tokio::sync::Semaphore::new(limit));
let mut set = tokio::task::JoinSet::new();
for t in tasks {
let core = core.clone();
let sem = sem.clone();
set.spawn(async move {
let _permit = sem.acquire_owned().await.expect("semaphore not closed");
let file = match std::fs::File::open(&t.path) {
Ok(f) => f,
Err(e) => return Err((t.name, format!("open {}: {e}", t.path.display()))),
};
let mtime = std::fs::metadata(&t.path)
.and_then(|m| m.modified())
.ok()
.and_then(|mt| mt.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs() as i64);
let guard = core
.transfers
.begin(&t.name, "", TransferDirection::Upload, t.size);
let reader = OwnedCountingReader::new(file, guard);
match core
.client
.upload_file_from(
&t.parent_uid,
&t.name,
media_type_for(&t.name),
reader,
t.size as i64,
Vec::new(),
mtime,
false,
)
.await
{
Ok(uid) => Ok((t.parent_ino, uid, t.size)),
Err(e) => Err((t.name, format!("upload: {e}"))),
}
});
}
let mut out = Vec::new();
while let Some(joined) = set.join_next().await {
job.step();
match joined {
Ok(result) => out.push(result),
Err(e) => warn!(error = %e, "upload task panicked"),
}
}
out
}
/// Shared engine behind both the FUSE callbacks and the control socket: the
/// Drive client, a Tokio handle to bridge the synchronous FUSE/socket threads
/// to the async SDK, the inode bookkeeping, and the on-disk content cache.
///
/// Cheaply cloneable (every field is a handle/`Arc`), so the control-socket task
/// gets its own copy while the FUSE session keeps another.
#[derive(Clone)]
struct Core {
client: ProtonDriveClient,
rt: tokio::runtime::Handle,
state: Arc<Mutex<State>>,
cache: Arc<ContentCache>,
/// Unified SQLite metadata cache: the persistence layer behind the in-memory
/// `State` maps. Every mutation writes through here, and the maps rehydrate
/// from it on mount (plan.md P1).
db: Arc<Db>,
/// True while a background refresh of the photos timeline (resp. the trash) is
/// already running, so a burst of page requests against a stale listing kicks
/// off one refresh rather than one per request.
timeline_refreshing: Arc<AtomicBool>,
trash_refreshing: Arc<AtomicBool>,
/// Photos whose missing thumbnail is being generated right now. A tile that is
/// still on screen asks for its thumbnail again every few seconds, and each of
/// those downloads is a full-size photo — so an in-flight uid is never started
/// twice.
thumb_gen: Arc<Mutex<HashSet<NodeUid>>>,
/// In-flight upload/download progress, served to `GetQueueStatus`. Shared
/// across the FUSE session and the control-socket task.
transfers: Arc<TransferRegistry>,
/// True while the background scanner is rebuilding the local-file index, so
/// `SearchLocal` can tell a front-end "still indexing" apart from "no match".
indexing: Arc<AtomicBool>,
/// Live per-folder sync progress, keyed by sync-folder id, so `ListSyncFolders`
/// can say what a pass is doing rather than just "syncing". An entry exists
/// only while that folder's reconcile pass is running.
sync_progress: Arc<Mutex<HashMap<i64, SyncProgress>>>,
/// Channel to the folder-sync engine (devices.md Phase 2): nudges it to
/// reconcile a folder, reconcile everything, or re-scan its watch set.
sync_tx: std::sync::mpsc::Sender<sync::SyncMsg>,
/// Secondary FUSE sessions for `ondemand` sync folders, keyed by sync-folder
/// id (devices.md Phase 3). Each is a `ProtonFs` rooted at the folder's remote
/// node, mounted over its local path, sharing this Core's client/cache/db but
/// with its own inode space (`fork_state`). Held so we can unmount on toggle
/// back to `mirror` and on daemon shutdown.
mounts: Arc<Mutex<HashMap<i64, BackgroundSession>>>,
/// Per-sync-folder locks, held for a whole reconcile pass and for a whole
/// mode switch. A `mirror→ondemand` flip evicts the local tree and mounts
/// FUSE over it, so it must never overlap a pass that is walking and
/// uploading that same tree — the engine would upload files as they vanish
/// and then walk the FUSE mount as if it were local.
sync_locks: Arc<Mutex<HashMap<i64, Arc<Mutex<()>>>>>,
}
impl Core {
/// Rehydrate the in-memory `State` maps from the DB on mount, so a cold
/// start serves previously-seen metadata (stable inodes, instant listings)
/// without re-hitting the API. The root inode is already installed by
/// [`ProtonFs::new`]; this fills in every other persisted node and rebuilds
/// the child listings of folders the DB records as fully enumerated.
fn hydrate(&self) {
let stored = match self.db.load_all() {
Ok(s) => s,
Err(e) => {
warn!(error = %e, "db load_all failed; mounting cold");
return;
}
};
if stored.is_empty() {
return;
}
let mut st = self.state.lock().unwrap();
// Pass 1: assign a stable inode to every uid (root is already mapped).
for sn in &stored {
if st.by_uid.contains_key(&sn.node.uid) {
continue;
}
let ino = st.next_ino;
st.next_ino += 1;
st.by_uid.insert(sn.node.uid.clone(), ino);
}
// Pass 2: materialize entries, resolving each parent's inode by uid.
// Track folders flagged complete so their listings rebuild in pass 3.
let mut listed_dirs: Vec<u64> = Vec::new();
for sn in stored {
let StoredNode { node, listed } = sn;
let Some(&ino) = st.by_uid.get(&node.uid) else {
continue;
};
if listed && node.is_folder() {
listed_dirs.push(ino);
}
// The root entry is owned by `ProtonFs::new`; don't overwrite it.
if ino == ROOT_INO {
continue;
}
// A node whose parent row never made it to disk must not be adopted
// by the root: it would surface as a phantom top-level entry.
let parent = node
.parent_uid
.as_ref()
.and_then(|p| st.by_uid.get(p).copied())
.unwrap_or(ORPHAN_INO);
let uid = node.uid.clone();
st.entries.insert(ino, Entry { uid, parent, node });
}
// Pass 3: rebuild child listings for fully-enumerated folders. The root
// is its own parent (that is what `..` resolves to), so it would match
// its own filter; a directory listed inside itself makes the kernel fail
// the lookup with EIO, taking the whole listing down with it.
for dir_ino in listed_dirs {
let kids: Vec<u64> = st
.entries
.iter()
.filter(|&(&ino, e)| ino != dir_ino && e.parent == dir_ino && !e.node.trashed)
.map(|(&ino, _)| ino)
.collect();
st.children.insert(dir_ino, kids);
}
info!(nodes = st.entries.len(), "hydrated metadata cache from db");
}
/// Enumerate `ino`'s children from the remote and cache them. No-op if the
/// directory has already been listed. Network I/O happens without the lock
/// held so concurrent metadata reads aren't blocked behind a fetch.
fn ensure_children(&self, ino: u64) -> Result<(), Errno> {
let folder_uid = {
let st = self.state.lock().unwrap();
if st.children.contains_key(&ino) {
return Ok(());
}
match st.entries.get(&ino) {
Some(e) => e.uid.clone(),
None => return Err(Errno::ENOENT),
}
};
// Offline fast path: a folder the DB still records as fully enumerated
// can be rebuilt from disk without hitting the API, even if its listing
// was trimmed from the hot cache mid-run.
match self.db.children_if_listed(&folder_uid) {
Ok(Some(nodes)) => {
let mut st = self.state.lock().unwrap();
if st.children.contains_key(&ino) {
return Ok(());
}
let mut child_inos = Vec::with_capacity(nodes.len());
for node in nodes {
if node.trashed || node.uid == folder_uid {
continue;
}
child_inos.push(st.intern(ino, node));
}
st.children.insert(ino, child_inos);
return Ok(());
}
Ok(None) => {}
Err(e) => warn!(%folder_uid, error = %e, "db children_if_listed failed"),
}
let uids = self
.rt
.block_on(self.client.enumerate_folder_children_node_uids(&folder_uid))
.map_err(|e| {
error!(%folder_uid, error = %e, "enumerate folder children failed");
Errno::EIO
})?;
let nodes = self
.rt
.block_on(self.client.enumerate_nodes(&uids))
.map_err(|e| {
error!(%folder_uid, error = %e, "enumerate nodes failed");
Errno::EIO
})?;
let mut st = self.state.lock().unwrap();
// Lost the race? Another thread already populated it.
if st.children.contains_key(&ino) {
return Ok(());
}
let mut child_inos = Vec::with_capacity(nodes.len());
for node in nodes {
// A folder listed among its own children would alias its inode into
// its own listing, which the kernel rejects with EIO.
if node.trashed || node.uid == folder_uid {
continue;
}
child_inos.push(st.intern(ino, node));
}
st.children.insert(ino, child_inos);
// Record the listing as complete so a later restart (or a trimmed hot
// cache) can rebuild it from the DB without the API.
if let Err(e) = self.db.set_listed(&folder_uid, true) {
warn!(%folder_uid, error = %e, "db set_listed(true) failed");
}
Ok(())
}
/// Resolve a child `name` within `parent` to its `(inode, uid)`, ensuring
/// the parent's listing is cached first.
fn lookup_child(&self, parent: u64, name: &str) -> Result<(u64, NodeUid), Errno> {
self.ensure_children(parent)?;
let st = self.state.lock().unwrap();
st.children
.get(&parent)
.and_then(|kids| {
kids.iter().copied().find_map(|ino| {
st.entries
.get(&ino)
.filter(|e| e.node.name == name)
.map(|e| (ino, e.uid.clone()))
})
})
.ok_or(Errno::ENOENT)
}
/// Walk a mountpoint-relative path to its `(inode, uid)`, enumerating each
/// directory on the way as needed. Leading `/` and `.` components are
/// ignored; `..` is rejected.
fn resolve_path(&self, rel: &Path) -> Result<(u64, NodeUid), Errno> {
let mut ino = ROOT_INO;
let mut uid = {
let st = self.state.lock().unwrap();
st.entries
.get(&ROOT_INO)
.map(|e| e.uid.clone())
.ok_or(Errno::ENOENT)?
};
for comp in rel.components() {
match comp {
Component::RootDir | Component::CurDir => continue,
Component::Normal(name) => {
let (child_ino, child_uid) = self.lookup_child(ino, &name.to_string_lossy())?;
ino = child_ino;
uid = child_uid;
}
_ => return Err(Errno::EINVAL),
}
}
Ok((ino, uid))
}
/// Fetch a single node's current metadata from the remote.
fn fetch_node(&self, uid: &NodeUid) -> Result<Node, Errno> {
let nodes = self
.rt
.block_on(self.client.enumerate_nodes(std::slice::from_ref(uid)))
.map_err(|e| {
error!(%uid, error = %e, "enumerate node failed");
Errno::EIO
})?;
nodes.into_iter().next().ok_or(Errno::ENOENT)
}
/// Serve bytes `[offset, offset + len)` of `uid`'s active revision, hitting
/// the on-disk caches before the network: a whole-file blob (pinned files)
/// first, then the block cache — fetching only the [`BLOCK_SIZE`]-aligned
/// blocks that overlap the request and caching each. `mtime`/`fsize` validate
/// both caches. Network I/O runs without any lock held.
fn read_range(
&self,
uid: &NodeUid,
mtime: i64,
fsize: u64,
offset: u64,
len: u64,
) -> Result<Vec<u8>, Errno> {
if let Some(bytes) = self.cache.read_range(uid, mtime, fsize, offset, len) {
return Ok(bytes);
}
if offset >= fsize || len == 0 {
return Ok(Vec::new());
}
let end = offset.saturating_add(len).min(fsize);
let mut out = Vec::with_capacity((end - offset) as usize);
let first = offset / BLOCK_SIZE;
let last = (end - 1) / BLOCK_SIZE;
// Collect the blocks overlapping the request, serving any already cached
// and fetching the rest concurrently. A multi-block read (e.g. a media
// player buffering, or a large sequential read split into one FUSE call)
// would otherwise stall on each block round-trip in turn; downloading the
// misses in parallel saturates the connection and bounds latency at the
// slowest single block instead of their sum.
let mut blocks: Vec<Option<Vec<u8>>> = Vec::with_capacity((last - first + 1) as usize);
let mut misses: Vec<u64> = Vec::new();
for bidx in first..=last {
match self.cache.cached_block(uid, mtime, fsize, bidx) {
Some(b) => blocks.push(Some(b)),
None => {
blocks.push(None);
misses.push(bidx);
}
}
}
if !misses.is_empty() {
let fetched = self.rt.block_on(async {
let mut set = tokio::task::JoinSet::new();
for &bidx in &misses {
let client = self.client.clone();
let uid = uid.clone();
let bstart = bidx * BLOCK_SIZE;
let blen = BLOCK_SIZE.min(fsize - bstart);
set.spawn(async move {
client
.download_range(&uid, bstart, blen)
.await
.map(|bytes| (bidx, bytes))
.map_err(|e| {
warn!(%uid, bstart, blen, error = %e, "download_range failed");
Errno::EIO
})
});
}
let mut out = Vec::with_capacity(misses.len());
while let Some(joined) = set.join_next().await {
// A join error means the task panicked; surface it as EIO.
out.push(joined.map_err(|_| Errno::EIO)??);
}
Ok::<_, Errno>(out)
})?;
for (bidx, bytes) in fetched {
let _ = self.cache.store_block(uid, mtime, fsize, bidx, &bytes);
blocks[(bidx - first) as usize] = Some(bytes);
}
}
for (i, block) in blocks.into_iter().enumerate() {
let bidx = first + i as u64;
let bstart = bidx * BLOCK_SIZE;
// Every slot is populated: cache hits up front, misses by the fetch above.
let block = block.expect("block fetched or cached");
let s = (offset.max(bstart) - bstart) as usize;
let e = (end.min(bstart + block.len() as u64) - bstart) as usize;
if s < e {
out.extend_from_slice(&block[s..e]);
}
}
Ok(out)
}
/// Serve a read against an open write handle: stitch authored ranges (from
/// the scratch file) and untouched ranges (from the remote base via the
/// block cache) in order, zero-filling any region past the base.
#[allow(clippy::too_many_arguments)]
fn serve_open_read(
&self,
file: &Arc<File>,
len: u64,
uid: &NodeUid,
base_mtime: i64,
base_size: u64,
written: &Intervals,
offset: u64,
size: u64,
) -> Result<Vec<u8>, Errno> {
if offset >= len || size == 0 {
return Ok(Vec::new());
}
let end = offset.saturating_add(size).min(len);
let mut out = Vec::with_capacity((end - offset) as usize);
for (s, e, authored) in written.segments(offset, end) {
if authored {
let mut buf = vec![0u8; (e - s) as usize];
file.read_exact_at(&mut buf, s).map_err(|err| {
warn!(%uid, error = %err, "scratch read failed");
Errno::EIO
})?;
out.extend_from_slice(&buf);
} else {
let bend = e.min(base_size);
if s < bend {
out.extend_from_slice(&self.read_range(
uid,
base_mtime,
base_size,
s,
bend - s,
)?);
}
// Anything past the base is a hole: zero-fill.
let zeros = e.saturating_sub(s.max(base_size));
out.resize(out.len() + zeros as usize, 0);
}
}
Ok(out)
}
/// Upload a write handle's scratch file as a new revision if it is dirty.
/// Untouched bytes within the base are filled from the remote first (reusing
/// the block cache), so a partial overwrite never had to pre-download the
/// whole file. On success the handle's base is advanced to the just-sealed
/// revision and `written` cleared, so later reads of untouched regions see
/// the new content. No-op for a clean (or unknown) handle. Network I/O runs
/// without the lock held.
fn commit(&self, fh: u64) -> Result<(), Errno> {
let (uid, file, path, len, base_mtime, base_size, written, ino) = {
let st = self.state.lock().unwrap();
match st.handles.get(&fh) {
Some(h) if h.dirty => (
h.uid.clone(),
h.file.clone(),
h.path.clone(),
h.len,
h.base_mtime,
h.base_size,
h.written.clone(),
h.ino,
),
_ => return Ok(()),
}
};
// Materialize the full new content in the scratch file: ensure its length,
// then fill every untouched range that overlaps the base with base bytes.
file.set_len(len).map_err(|e| {
error!(%uid, error = %e, "resize scratch file failed");
Errno::EIO
})?;
for (s, e, authored) in written.segments(0, len) {
if authored {
continue;
}
let bend = e.min(base_size);
if s >= bend {
continue; // wholly past the base: already zero-filled on disk
}
let bytes = self.read_range(&uid, base_mtime, base_size, s, bend - s)?;
file.write_all_at(&bytes, s).map_err(|err| {
error!(%uid, error = %err, "scratch gap-fill write failed");
Errno::EIO
})?;
}
// Stream the scratch file up as one revision (fresh handle reads from 0).
let reader = File::open(&path).map_err(|e| {
error!(%uid, error = %e, "reopen scratch file failed");
Errno::EIO
})?;
let name = {
let st = self.state.lock().unwrap();
st.entries
.get(&ino)
.map(|e| e.node.name.clone())
.unwrap_or_default()
};
let guard = self
.transfers
.begin(name, uid.to_string(), TransferDirection::Upload, len);
let reader = CountingReader::new(reader, &guard);
self.rt
.block_on(self.client.upload_new_revision_from(
&uid,
reader,
len as i64,
Vec::new(),
None,
))
.map_err(|e| {
error!(%uid, error = %e, "upload new revision failed");
Errno::EIO
})?;
drop(guard);
let now = now_secs();
{
let mut st = self.state.lock().unwrap();
if let Some(h) = st.handles.get_mut(&fh) {
h.dirty = false;
// The scratch file now equals the sealed revision; treat it all as
// base so further reads of untouched bytes hit the new content.
h.written = Intervals::default();
h.base_mtime = now;
h.base_size = len;
}
st.set_size(ino, len);
st.touch_mtime(ino, now);
}
// The sealed content differs from any cached blob/blocks; refresh a pinned
// file's whole-file blob, otherwise evict so reads re-fetch fresh.
if self.cache.is_pinned(&uid) {
if let Ok(bytes) = std::fs::read(&path) {
let _ = self.cache.store(&uid, now, len, &bytes);
}
} else {
self.cache.evict(&uid);
}
Ok(())
}
/// Download a whole file's plaintext, registering the transfer so
/// `GetQueueStatus` can report its progress. `total` is the expected size
/// (`0` if unknown). Streams through [`download_file_to`] so each block ticks
/// the progress counter.
///
/// [`download_file_to`]: ProtonDriveClient::download_file_to
fn download_file_tracked(
&self,
uid: &NodeUid,
name: &str,
total: u64,
) -> std::result::Result<Vec<u8>, ProtonError> {
let guard = self
.transfers
.begin(name, uid.to_string(), TransferDirection::Download, total);
let mut out = CountingWriter::new(Vec::with_capacity(total as usize), &guard);
self.rt
.block_on(self.client.download_file_to(uid, &mut out))?;
Ok(out.into_inner())
}
/// Like [`download_file_tracked`] for a photo, streaming through the photos
/// client's [`download_photo_to`].
///
/// [`download_file_tracked`]: Core::download_file_tracked
/// [`download_photo_to`]: ProtonPhotosClient::download_photo_to
fn download_photo_tracked(
&self,
photos: &ProtonPhotosClient,
uid: &NodeUid,
name: &str,
total: u64,
) -> std::result::Result<Vec<u8>, ProtonError> {
let guard = self
.transfers
.begin(name, uid.to_string(), TransferDirection::Download, total);
let mut out = CountingWriter::new(Vec::with_capacity(total as usize), &guard);
self.rt.block_on(photos.download_photo_to(uid, &mut out))?;
Ok(out.into_inner())
}
/// Pin the node at mountpoint-relative `rel`. A file downloads its full
/// plaintext into the content cache; a folder records a recursive pin and
/// downloads every descendant file (selective sync). Returns a human message.
fn pin(&self, rel: &Path) -> Result<String, String> {
let (ino, uid) = self
.resolve_path(rel)
.map_err(|e| format!("resolve path: {e:?}"))?;
let (name, is_folder, mtime, size) = {
let st = self.state.lock().unwrap();
let e = st.entries.get(&ino).ok_or("node vanished")?;
(
e.node.name.clone(),
e.node.is_folder(),
e.node.modification_time,
node_size(&e.node),
)
};
if is_folder {
// Record the recursive pin first so every descendant is eviction-
// exempt before we start filling the cache with the subtree.
self.cache
.add_pin(&uid, rel, true)
.map_err(|e| format!("pin: {e}"))?;
let n = self.pin_subtree(ino)?;
return Ok(format!("{name} ({n} files)"));
}
let bytes = self
.download_file_tracked(&uid, &name, size)
.map_err(|e| format!("download: {e}"))?;
self.cache
.store(&uid, mtime, size, &bytes)
.map_err(|e| format!("cache store: {e}"))?;
self.cache
.add_pin(&uid, rel, false)
.map_err(|e| format!("pin: {e}"))?;
Ok(name)
}
/// Download and cache every file in the subtree rooted at folder `ino`,
/// returning the count cached (already-fresh blobs counted, not re-fetched).
/// Walks the tree depth-first, enumerating each folder so a cold subtree is
/// fully discovered; the lock is dropped before each network download.
fn pin_subtree(&self, ino: u64) -> Result<usize, String> {
let mut files: Vec<(NodeUid, String, i64, u64)> = Vec::new();
let mut stack = vec![ino];
while let Some(dir) = stack.pop() {
self.ensure_children(dir)
.map_err(|e| format!("enumerate: {e:?}"))?;
let st = self.state.lock().unwrap();
if let Some(kids) = st.children.get(&dir) {
for &k in kids {
if let Some(e) = st.entries.get(&k) {
if e.node.is_folder() {
stack.push(k);
} else {
files.push((
e.uid.clone(),
e.node.name.clone(),
e.node.modification_time,
node_size(&e.node),
));
}
}
}
}
}
let mut count = 0;
for (uid, name, mtime, size) in files {
if self.cache.is_cached(&uid, mtime, size) {
count += 1;
continue;
}
match self.download_file_tracked(&uid, &name, size) {
Ok(bytes) => {
if self.cache.store(&uid, mtime, size, &bytes).is_ok() {
count += 1;
}
}
Err(e) => warn!(%uid, error = %e, "pin subtree: download failed"),
}
}
Ok(count)
}
/// Fetch a thumbnail of `ttype` for the file at `ino`, served from the cache
/// when fresh and otherwise downloaded from the remote and cached. Returns
/// `Ok(None)` when the node is not a file or has no thumbnail of that type.
fn thumbnail(&self, ino: u64, ttype: ThumbnailType) -> Result<Option<Vec<u8>>, Errno> {
let (uid, mtime) = {
let st = self.state.lock().unwrap();
match st.entries.get(&ino) {
Some(e) if e.node.is_file() => (e.uid.clone(), e.node.modification_time),
Some(_) => return Ok(None),
None => return Err(Errno::ENOENT),
}
};
if let Some(bytes) = self.cache.read_thumbnail(&uid, ttype.as_i32(), mtime) {
return Ok(Some(bytes));
}
let bytes = self
.rt
.block_on(self.client.download_thumbnail(&uid, ttype))
.map_err(|e| {
warn!(%uid, error = %e, "download thumbnail failed");
Errno::EIO
})?;
if let Some(bytes) = &bytes {
let _ = self
.cache
.store_thumbnail(&uid, ttype.as_i32(), mtime, bytes);
}
Ok(bytes)
}
/// Unpin the node at `rel`, evicting its cached content. For a folder, also
/// evicts every descendant's cached blob (the subtree is no longer kept).
fn unpin(&self, rel: &Path) -> Result<String, String> {
let (ino, uid) = self
.resolve_path(rel)
.map_err(|e| format!("resolve path: {e:?}"))?;
let (name, is_folder) = {
let st = self.state.lock().unwrap();
st.entries
.get(&ino)
.map(|e| (e.node.name.clone(), e.node.is_folder()))
.unwrap_or_default()
};
self.cache
.remove_pin(&uid)
.map_err(|e| format!("unpin: {e}"))?;
// A recursively-pinned folder's descendants were eviction-exempt; now
// that the pin is gone, reclaim their blobs eagerly instead of waiting
// for budget pressure. Descendants come from the DB node tree.
if is_folder && let Ok(uids) = self.db.descendants(&uid.to_string()) {
for s in uids {
if let Some(u) = parse_uid(&s) {
self.cache.evict(&u);
}
}
}
Ok(name)
}
/// A Photos API handle sharing this Core's Drive client and session, so it
/// reuses the daemon's single authenticated session rather than logging in
/// again (Proton refresh tokens are single-use). Cheap — the Drive client
/// is `Clone` over `Arc`-backed state.
fn photos(&self) -> ProtonPhotosClient {
ProtonPhotosClient::from_drive_client(self.client.clone())
}
/// List the directory at mountpoint-relative `rel` for the in-app browser:
/// the same lazy remote enumeration `readdir` uses, projected into
/// serializable [`DirEntry`]s (with per-file pin state).
fn list_dir(&self, rel: &Path) -> Result<Vec<DirEntry>, String> {
let (ino, _uid) = self
.resolve_path(rel)
.map_err(|e| format!("resolve path: {e:?}"))?;
self.ensure_children(ino)
.map_err(|e| format!("enumerate: {e:?}"))?;
// Snapshot the listing, then drop the lock before touching the on-disk
// pin registry so a slow disk read doesn't block FUSE metadata ops.
let rows: Vec<(String, bool, u64, i64, NodeUid)> = {
let st = self.state.lock().unwrap();
st.children
.get(&ino)
.map(|kids| {
kids.iter()
.filter_map(|k| st.entries.get(k))
.map(|e| {
(
e.node.name.clone(),
e.node.is_folder(),
node_size(&e.node),
e.node.modification_time,
e.uid.clone(),
)
})
.collect()
})
.unwrap_or_default()
};
Ok(rows
.into_iter()
.map(|(name, is_dir, size, modified, uid)| DirEntry {
name,
is_dir,
size,
modified,
pinned: self.cache.is_pinned(&uid),
cached: !is_dir && self.cache.is_cached(&uid, modified, size),
uid: uid.to_string(),
// Listing entries live in the requested dir; the caller derives
// the path from its name. Left empty.
path: String::new(),
})
.collect())
}
/// Full-text search node names against the local SQLite index, mapping each
/// DB hit to the wire [`SearchHit`] (resolving live pin state from the cache,
/// which the DB doesn't track). Pure local lookup — never hits the network.
fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchHit>, String> {
let hits = self
.db
.search(query, limit)
.map_err(|e| format!("search: {e}"))?;
Ok(hits
.into_iter()
.map(|h| SearchHit {
name: h.node.name.clone(),
path: h.path,
is_dir: h.node.is_folder(),
size: node_size(&h.node),
modified: h.node.modification_time,
pinned: self.cache.is_pinned(&h.node.uid),
uid: h.node.uid.to_string(),
})
.collect())
}
/// Search the index of files on this machine (outside Drive), built by the
/// background scanner in [`run_local_index`]. Pure local lookup, never hits
/// the network — and never touches the FUSE mount, which the scanner excludes.
fn search_local(&self, query: &str, limit: usize) -> Result<Vec<LocalHit>, String> {
let hits = self
.db
.search_local(query, limit)
.map_err(|e| format!("local search: {e}"))?;
Ok(hits
.into_iter()
.map(|h| LocalHit {
name: h.name,
path: h.path,
is_dir: h.is_dir,
size: h.size.max(0) as u64,
modified: h.mtime,
})
.collect())
}
/// A page of the photos timeline (newest first), sliced out of the DB.
/// `Ok(None)` when the account has no photos volume.
///
/// Stale-while-revalidate: a persisted timeline is served *immediately*, and
/// refreshed on the runtime if it is older than [`TIMELINE_TTL`] — so opening
/// the app paints from disk rather than waiting on a full `enumerate_timeline`
/// (which returns the whole timeline, not a page). Only an empty DB blocks on
/// the network, i.e. the very first run.
///
/// Metadata only: a thumbnail path is attached for photos whose thumbnail is
/// already cached, and nothing is downloaded here — the front-end pulls the
/// thumbnails it actually paints via [`Core::photo_thumbs`].
fn photos_timeline(
&self,
offset: usize,
limit: usize,
) -> Result<Option<Vec<PhotoItem>>, String> {
let count = self.db.photos_count().map_err(|e| e.to_string())?;
if count == 0 {
// Nothing to serve, so this one request has to wait for the fetch —
// unless we already know the account has no photos volume and the
// answer is a fresh "no".
let known_empty = self.db.state_i64(PHOTOS_AVAILABLE).ok().flatten() == Some(0);
if known_empty && !self.listing_stale(PHOTOS_SYNCED_MS, TIMELINE_TTL) {
return Ok(None);
}
if !self.rt.block_on(self.refresh_timeline())? {
return Ok(None);
}
} else if self.listing_stale(PHOTOS_SYNCED_MS, TIMELINE_TTL) {
self.spawn_timeline_refresh();
}
let page = self
.db
.photos_page(offset, limit)
.map_err(|e| e.to_string())?;
Ok(Some(page.into_iter().map(|p| self.photo_item(p)).collect()))
}
/// Project a persisted photo into the wire item the front-end paints: its
/// learned aspect ratio, its thumbnail verdict, and the on-disk path of its
/// thumbnail when one is cached (tagged with the capture time, which is the
/// only revision handle a photo carries).
fn photo_item(&self, photo: StoredPhoto) -> PhotoItem {
let thumb_path = parse_uid(&photo.uid).and_then(|uid| {
self.cache
.cached_thumbnail_path(&uid, ThumbnailType::Thumbnail.as_i32(), photo.capture_time)
.map(|p| p.display().to_string())
});
PhotoItem {
uid: photo.uid,
capture_time: photo.capture_time,
thumb_path,
name: photo.name,
ratio: photo.ratio,
no_thumb: photo.thumb_state == db::THUMB_NONE,
}
}
/// Thumbnails for `uids`, served from the cache, fetched from the server for
/// whatever is missing, and — for the photos the server has no thumbnail for
/// at all — *generated locally* from the full file (see
/// [`Core::generate_thumbs`]). Requested on demand as tiles scroll into view,
/// so a cold timeline paints immediately and only the photos actually on
/// screen cost a round-trip.
///
/// A photo absent from the persisted timeline is skipped: its capture time is
/// the cache's validity tag, and guessing that would poison the cache.
fn photo_thumbs(&self, uids: &[NodeUid]) -> Vec<PhotoThumb> {
let ttype = ThumbnailType::Thumbnail.as_i32();
let keys: Vec<String> = uids.iter().map(|u| u.to_string()).collect();
let stored = self.db.photos_by_uid(&keys).unwrap_or_default();
let tags: HashMap<String, i64> = stored
.iter()
.map(|p| (p.uid.clone(), p.capture_time))
.collect();
// Ask the server only for photos that are missing a cached thumbnail and
// haven't already been written off as un-thumbnailable.
let want: Vec<NodeUid> = uids
.iter()
.filter(|uid| {
let key = uid.to_string();
stored
.iter()
.find(|p| p.uid == key)
.is_some_and(|p| p.thumb_state != db::THUMB_NONE)
&& tags.get(&key).is_some_and(|&tag| {
self.cache.cached_thumbnail_path(uid, ttype, tag).is_none()
})
})
.cloned()
.collect();
if !want.is_empty() {
let mut missing = want.clone();
match self.rt.block_on(
self.photos()
.enumerate_thumbnails(&want, ThumbnailType::Thumbnail),
) {
Ok(thumbs) => {
for ft in thumbs {
let Some(&tag) = tags.get(&ft.file_uid.to_string()) else {
continue;
};
let Ok(bytes) = ft.result else { continue };
if bytes.is_empty() {
continue;
}
if self
.cache
.store_thumbnail(&ft.file_uid, ttype, tag, &bytes)
.is_ok()
{
missing.retain(|uid| uid != &ft.file_uid);
self.record_thumb(&ft.file_uid, db::THUMB_HAVE, ratio_of(&bytes));
}
}
}
// A failed batch is not a verdict: leave every uid in `missing` so
// the local fallback still gives those tiles an image.
Err(e) => warn!(error = %e, "batch photo thumbnails failed"),
}
// Whatever the server had nothing for gets a thumbnail made from its
// own bytes — this is what fills in camera photos uploaded by clients
// that never generated one. Off the request path: a full-size photo
// takes far longer to fetch than the whole rest of the batch, and the
// thumbnails that *are* ready must not wait behind it.
if !missing.is_empty() {
self.spawn_generate_thumbs(missing, &tags);
}
}
let pending = self.thumb_gen.lock().unwrap();
uids.iter()
.map(|uid| PhotoThumb {
uid: uid.to_string(),
path: tags.get(&uid.to_string()).and_then(|&tag| {
self.cache
.cached_thumbnail_path(uid, ttype, tag)
.map(|p| p.display().to_string())
}),
pending: pending.contains(uid),
})
.collect()
}
/// Generate the missing thumbnails on the runtime, skipping any photo already
/// being generated. The uids are marked in-flight before the task starts, so
/// the reply this call is about to send already reports them as pending.
fn spawn_generate_thumbs(&self, uids: Vec<NodeUid>, tags: &HashMap<String, i64>) {
let fresh: Vec<NodeUid> = {
let mut inflight = self.thumb_gen.lock().unwrap();
uids.into_iter()
.filter(|uid| inflight.insert(uid.clone()))
.collect()
};
if fresh.is_empty() {
return;
}
let core = self.clone();
let tags = tags.clone();
// `generate_thumbs` blocks on the runtime itself, so it belongs on the
// blocking pool rather than on an async worker.
self.rt.spawn_blocking(move || {
core.generate_thumbs(&fresh, &tags);
let mut inflight = core.thumb_gen.lock().unwrap();
for uid in &fresh {
inflight.remove(uid);
}
});
}
/// Make thumbnails for photos the server has none for: download each full
/// file once, scale it to [`THUMB_EDGE`], and store the result in the thumbnail
/// cache exactly as if the server had served it.
///
/// Bounded by [`THUMB_GEN_CONCURRENCY`] — these are full-size originals, and a
/// screenful of them at once would saturate the link. A photo whose bytes
/// can't be decoded (a codec `image` doesn't speak) is marked
/// [`db::THUMB_NONE`] and never attempted again.
fn generate_thumbs(&self, uids: &[NodeUid], tags: &HashMap<String, i64>) {
info!(
count = uids.len(),
"generating thumbnails the server has none for"
);
let results: Vec<(NodeUid, ThumbAttempt)> = self.rt.block_on(async {
let mut out = Vec::with_capacity(uids.len());
for chunk in uids.chunks(THUMB_GEN_CONCURRENCY) {
let mut set = tokio::task::JoinSet::new();
for uid in chunk {
let client = self.client.clone();
let uid = uid.clone();
set.spawn(async move {
let bytes = match client.download_file(&uid).await {
Ok(bytes) => bytes,
Err(e) => {
warn!(%uid, error = %e, "photo download for thumbnail failed");
return (uid, ThumbAttempt::Unavailable);
}
};
// Decoding + scaling a 20 MP JPEG is CPU-bound and would
// stall the runtime's worker; hand it to the blocking pool.
let made = tokio::task::spawn_blocking(move || scale_thumbnail(&bytes))
.await
.unwrap_or(None);
match made {
Some(thumb) => (uid, ThumbAttempt::Made(thumb)),
None => (uid, ThumbAttempt::Undecodable),
}
});
}
while let Some(joined) = set.join_next().await {
if let Ok(result) = joined {
out.push(result);
}
}
}
out
});
let ttype = ThumbnailType::Thumbnail.as_i32();
for (uid, attempt) in results {
match attempt {
ThumbAttempt::Made(thumb) => {
let Some(&tag) = tags.get(&uid.to_string()) else {
continue;
};
match self.cache.store_thumbnail(&uid, ttype, tag, &thumb.bytes) {
Ok(()) => self.record_thumb(&uid, db::THUMB_HAVE, Some(thumb.ratio)),
Err(e) => warn!(%uid, error = %e, "storing generated thumbnail failed"),
}
}
// The photo's own bytes aren't an image we can read: no thumbnail
// will ever exist for it, so stop trying.
ThumbAttempt::Undecodable => self.record_thumb(&uid, db::THUMB_NONE, None),
// The download failed — a dropped connection, an expired link. That
// is not a verdict on the photo: leave its state alone so the next
// scroll past it tries again.
ThumbAttempt::Unavailable => {}
}
}
}
/// Persist what a thumbnail attempt learned about a photo.
fn record_thumb(&self, uid: &NodeUid, state: i64, ratio: Option<f64>) {
if let Err(e) = self.db.photo_set_thumb(&uid.to_string(), state, ratio) {
warn!(%uid, error = %e, "recording thumbnail state failed");
}
}
/// Whether the listing stamped under `key` is older than `ttl` (or was never
/// fetched).
fn listing_stale(&self, key: &str, ttl: Duration) -> bool {
match self.db.state_i64(key).ok().flatten() {
Some(ms) => now_ms().saturating_sub(ms) >= ttl.as_millis() as i64,
None => true,
}
}
/// Re-fetch the whole photos timeline and persist it. Returns whether the
/// account has a photos volume at all.
async fn refresh_timeline(&self) -> Result<bool, String> {
let photos = self.photos();
if photos
.get_photos_root()
.await
.map_err(|e| format!("photos root: {e}"))?
.is_none()
{
let _ = self.db.set_state_i64(PHOTOS_AVAILABLE, 0);
let _ = self.db.set_state_i64(PHOTOS_SYNCED_MS, now_ms());
return Ok(false);
}
let items = photos
.enumerate_timeline()
.await
.map_err(|e| format!("timeline: {e}"))?;
let rows: Vec<(String, i64, Option<String>)> = items
.iter()
.map(|it| (it.uid.to_string(), it.capture_time, None))
.collect();
self.db.photos_replace(&rows).map_err(|e| e.to_string())?;
let _ = self.db.set_state_i64(PHOTOS_AVAILABLE, 1);
let _ = self.db.set_state_i64(PHOTOS_SYNCED_MS, now_ms());
Ok(true)
}
/// Refresh the timeline off the request path, so a stale page is still served
/// at DB speed. At most one refresh runs at a time.
fn spawn_timeline_refresh(&self) {
if self.timeline_refreshing.swap(true, Ordering::SeqCst) {
return;
}
let core = self.clone();
self.rt.spawn(async move {
if let Err(e) = core.refresh_timeline().await {
warn!(error = %e, "background timeline refresh failed");
}
core.timeline_refreshing.store(false, Ordering::SeqCst);
});
}
/// Download a photo's full content into the content cache, returning its
/// on-disk path (served from cache when a fresh blob already exists).
fn open_photo(&self, uid: &NodeUid) -> Result<PathBuf, String> {
let photos = self.photos();
let node = self
.rt
.block_on(photos.get_node(uid))
.map_err(|e| format!("photo node: {e}"))?
.ok_or("photo not found")?;
let (mtime, size) = (node.modification_time, node_size(&node));
if let Some(p) = self.cache.cached_content_path(uid, mtime, size) {
return Ok(p);
}
let bytes = self
.download_photo_tracked(&photos, uid, &node.name, size)
.map_err(|e| format!("download photo: {e}"))?;
self.cache
.store(uid, mtime, size, &bytes)
.map_err(|e| format!("cache store: {e}"))?;
Ok(self.cache.content_path(uid))
}
/// Download the full content of the Drive file at mountpoint-relative `rel`
/// into the content cache, returning its on-disk path (served from cache
/// when a fresh blob already exists). Lets a front-end open the file with
/// the user's default application without pinning it.
fn open_file(&self, rel: &Path) -> Result<PathBuf, String> {
let (ino, uid) = self
.resolve_path(rel)
.map_err(|e| format!("resolve path: {e:?}"))?;
let (name, mtime, size) = {
let st = self.state.lock().unwrap();
let e = st.entries.get(&ino).ok_or("node vanished")?;
if !e.node.is_file() {
return Err("not a regular file".into());
}
(
e.node.name.clone(),
e.node.modification_time,
node_size(&e.node),
)
};
if let Some(p) = self.cache.cached_content_path(&uid, mtime, size) {
return Ok(p);
}
let bytes = self
.download_file_tracked(&uid, &name, size)
.map_err(|e| format!("download: {e}"))?;
self.cache
.store(&uid, mtime, size, &bytes)
.map_err(|e| format!("cache store: {e}"))?;
Ok(self.cache.content_path(&uid))
}
/// Drop the cached child listing of `rel`'s parent directory so the next
/// `ListDir` re-enumerates it from the server. No-op when the parent can't be
/// resolved (e.g. `rel` is the root). Resolves the parent without holding the
/// state lock, then invalidates under it.
fn invalidate_parent_listing(&self, rel: &Path) {
let parent = rel.parent().unwrap_or_else(|| Path::new(""));
if let Ok((pino, _)) = self.resolve_path(parent) {
self.state.lock().unwrap().invalidate_listing(pino);
}
}
/// Rename a file or folder to `new_name`. `rel` is mountpoint-relative.
/// Mirrors the FUSE `rename` write path: rename on the remote, forget the
/// node so it re-interns under its new name, and drop the parent listing so
/// the next `ListDir` re-enumerates.
fn rename(&self, rel: &Path, new_name: &str) -> Result<String, String> {
if new_name.is_empty() || new_name.contains('/') {
return Err(format!("invalid name: {new_name:?}"));
}
let (_ino, uid) = self
.resolve_path(rel)
.map_err(|e| format!("resolve path: {e:?}"))?;
self.rt
.block_on(self.client.rename_node(&uid, new_name, None))
.map_err(|e| format!("rename: {e}"))?;
self.state.lock().unwrap().forget(&uid);
self.invalidate_parent_listing(rel);
Ok(new_name.to_string())
}
/// Move a file or folder into the folder at `new_parent_rel`. Both paths are
/// mountpoint-relative. Forgets the node and invalidates both the source and
/// destination listings so each re-enumerates on next access.
fn move_to(&self, rel: &Path, new_parent_rel: &Path) -> Result<String, String> {
let (_ino, uid) = self
.resolve_path(rel)
.map_err(|e| format!("resolve path: {e:?}"))?;
let (pino, new_parent_uid) = self
.resolve_path(new_parent_rel)
.map_err(|e| format!("resolve new parent: {e:?}"))?;
self.rt
.block_on(self.client.move_node(&uid, &new_parent_uid))
.map_err(|e| format!("move: {e}"))?;
let name = self
.state
.lock()
.unwrap()
.forget(&uid)
.map(|(_, n)| n)
.unwrap_or_default();
self.invalidate_parent_listing(rel);
self.state.lock().unwrap().invalidate_listing(pino);
Ok(name)
}
/// Trash a file or folder. `rel` is mountpoint-relative. Forgets the node,
/// evicts any cached content, and invalidates the parent listing.
fn delete(&self, rel: &Path) -> Result<String, String> {
let (_ino, uid) = self
.resolve_path(rel)
.map_err(|e| format!("resolve path: {e:?}"))?;
self.rt
.block_on(self.client.trash_nodes(std::slice::from_ref(&uid)))
.map_err(|e| format!("trash: {e}"))?;
let name = self
.state
.lock()
.unwrap()
.forget(&uid)
.map(|(_, n)| n)
.unwrap_or_default();
self.cache.evict(&uid);
self.invalidate_parent_listing(rel);
self.invalidate_trash();
Ok(name)
}
/// List the account's trash, from the DB. Trashed nodes are outside the
/// mounted tree — the FUSE side forgot them when they were trashed — so the
/// listing is persisted in its own table rather than derived from `State`, and
/// each entry is identified by its uid (its only remaining handle) with an
/// empty path.
///
/// Stale-while-revalidate, like the photos timeline: a persisted listing comes
/// back at DB speed and is refreshed in the background past [`TRASH_TTL`].
/// Our own trash mutations invalidate it outright (see
/// [`Core::invalidate_trash`]), so the TTL only covers changes made elsewhere.
fn list_trash(&self) -> Result<Vec<DirEntry>, String> {
let stale = self.listing_stale(TRASH_SYNCED_MS, TRASH_TTL);
// Never fetched: this request has to wait for it.
if self.db.state_i64(TRASH_SYNCED_MS).ok().flatten().is_none() {
self.rt.block_on(self.refresh_trash())?;
} else if stale {
self.spawn_trash_refresh();
}
Ok(self
.db
.trash_list()
.map_err(|e| e.to_string())?
.into_iter()
.map(|item| DirEntry {
name: item.name,
is_dir: item.is_dir,
size: item.size.max(0) as u64,
modified: item.mtime,
// A trashed node can't be pinned or served from the mount, so its
// content is never current cache: report neither.
pinned: false,
cached: false,
uid: item.uid,
path: String::new(),
})
.collect())
}
/// Re-fetch the trash listing from the server and persist it.
async fn refresh_trash(&self) -> Result<(), String> {
let uids = self
.client
.enumerate_trash_node_uids()
.await
.map_err(|e| format!("enumerate trash: {e}"))?;
let nodes = if uids.is_empty() {
Vec::new()
} else {
self.client
.enumerate_nodes(&uids)
.await
.map_err(|e| format!("enumerate nodes: {e}"))?
};
let items: Vec<StoredTrash> = nodes
.into_iter()
.map(|node| StoredTrash {
uid: node.uid.to_string(),
name: node.name.clone(),
is_dir: node.is_folder(),
size: node_size(&node) as i64,
mtime: node.modification_time,
})
.collect();
self.db.trash_replace(&items).map_err(|e| e.to_string())?;
let _ = self.db.set_state_i64(TRASH_SYNCED_MS, now_ms());
Ok(())
}
/// Refresh the trash off the request path. At most one refresh at a time.
fn spawn_trash_refresh(&self) {
if self.trash_refreshing.swap(true, Ordering::SeqCst) {
return;
}
let core = self.clone();
self.rt.spawn(async move {
if let Err(e) = core.refresh_trash().await {
warn!(error = %e, "background trash refresh failed");
}
core.trash_refreshing.store(false, Ordering::SeqCst);
});
}
/// Drop the persisted trash listing's freshness stamp after we changed the
/// trash ourselves. The next Trash page then *waits* for a fresh listing
/// rather than painting one from before the mutation — the user just made the
/// change and is about to look straight at it.
fn invalidate_trash(&self) {
let _ = self.db.clear_state(TRASH_SYNCED_MS);
}
/// Parse wire uids (`volume~link`) into [`NodeUid`]s, rejecting the whole
/// batch if any is malformed — a partial trash mutation is worse than none.
fn parse_uids(uids: &[String]) -> Result<Vec<NodeUid>, String> {
if uids.is_empty() {
return Err("no nodes given".to_string());
}
uids.iter()
.map(|u| parse_uid(u).ok_or_else(|| format!("invalid uid: {u}")))
.collect()
}
/// Restore trashed nodes to the folders they were trashed from. The parents
/// are read *before* the restore — a restored node reappears in a listing the
/// daemon may already have cached, so each destination folder is invalidated
/// and re-enumerated on next access.
fn restore(&self, uids: &[String]) -> Result<usize, String> {
let parsed = Self::parse_uids(uids)?;
let parents: Vec<NodeUid> = self
.rt
.block_on(self.client.enumerate_nodes(&parsed))
.map_err(|e| format!("enumerate nodes: {e}"))?
.into_iter()
.filter_map(|n| n.parent_uid)
.collect();
self.rt
.block_on(self.client.restore_nodes(&parsed))
.map_err(|e| format!("restore: {e}"))?;
{
let mut st = self.state.lock().unwrap();
for parent in parents {
if let Some(&ino) = st.by_uid.get(&parent) {
st.invalidate_listing(ino);
}
}
}
self.invalidate_trash();
Ok(parsed.len())
}
/// Permanently delete trashed nodes. Irreversible on the server; locally it
/// drops any metadata and cached content the node still owns.
fn delete_forever(&self, uids: &[String]) -> Result<usize, String> {
let parsed = Self::parse_uids(uids)?;
self.rt
.block_on(self.client.delete_nodes(&parsed))
.map_err(|e| format!("delete: {e}"))?;
self.drop_local(&parsed);
self.invalidate_trash();
Ok(parsed.len())
}
/// Permanently delete everything in the trash. The uids are listed first so
/// the blobs of items trashed by *another* client — which this daemon may
/// still hold in its cache — are reclaimed too, not just the ones it trashed.
fn empty_trash(&self) -> Result<usize, String> {
let uids = self
.rt
.block_on(self.client.enumerate_trash_node_uids())
.map_err(|e| format!("enumerate trash: {e}"))?;
self.rt
.block_on(self.client.empty_trash())
.map_err(|e| format!("empty trash: {e}"))?;
self.drop_local(&uids);
self.invalidate_trash();
Ok(uids.len())
}
/// Forget every trace of nodes that no longer exist anywhere: their inode and
/// DB row, and their cached content.
fn drop_local(&self, uids: &[NodeUid]) {
let mut st = self.state.lock().unwrap();
for uid in uids {
st.forget(uid);
}
drop(st);
for uid in uids {
self.cache.evict(uid);
}
}
/// Create a folder named `name` under the mountpoint-relative `parent_rel`.
/// Interns the new node directly so it shows up without a re-enumeration.
fn create_folder(&self, parent_rel: &Path, name: &str) -> Result<String, String> {
if name.is_empty() || name.contains('/') {
return Err(format!("invalid name: {name:?}"));
}
let (pino, parent_uid) = self
.resolve_path(parent_rel)
.map_err(|e| format!("resolve path: {e:?}"))?;
self.ensure_children(pino)
.map_err(|e| format!("enumerate: {e:?}"))?;
let new_uid = self
.rt
.block_on(
self.client
.create_folder(&parent_uid, name, Some(now_secs())),
)
.map_err(|e| format!("create folder: {e}"))?;
let node = self
.fetch_node(&new_uid)
.map_err(|e| format!("fetch node: {e:?}"))?;
let mut st = self.state.lock().unwrap();
let ino = st.intern(pino, node);
if let Some(kids) = st.children.get_mut(&pino)
&& !kids.contains(&ino)
{
kids.push(ino);
}
Ok(name.to_string())
}
/// Upload a file named `name` with content `bytes` into the
/// mountpoint-relative `parent_rel` folder. Interns the new node directly.
fn upload(&self, parent_rel: &Path, name: &str, bytes: &[u8]) -> Result<String, String> {
if name.is_empty() || name.contains('/') {
return Err(format!("invalid name: {name:?}"));
}
let (pino, parent_uid) = self
.resolve_path(parent_rel)
.map_err(|e| format!("resolve path: {e:?}"))?;
self.ensure_children(pino)
.map_err(|e| format!("enumerate: {e:?}"))?;
let guard = self
.transfers
.begin(name, "", TransferDirection::Upload, bytes.len() as u64);
let reader = CountingReader::new(std::io::Cursor::new(bytes), &guard);
let new_uid = self
.rt
.block_on(self.client.upload_file_from(
&parent_uid,
name,
media_type_for(name),
reader,
bytes.len() as i64,
Vec::new(),
None,
false,
))
.map_err(|e| format!("upload: {e}"))?;
drop(guard);
let node = self
.fetch_node(&new_uid)
.map_err(|e| format!("fetch node: {e:?}"))?;
let mut st = self.state.lock().unwrap();
let ino = st.intern(pino, node);
if let Some(kids) = st.children.get_mut(&pino)
&& !kids.contains(&ino)
{
kids.push(ino);
}
Ok(name.to_string())
}
/// Bulk-upload local files and directory trees under `sources` into the
/// mountpoint-relative `parent_rel` folder. Directories are recreated (or
/// merged into an existing same-named folder) and walked; the resulting flat
/// set of files is uploaded with bounded concurrency, each ticking the
/// transfer registry so a front-end sees live progress. Runs on a background
/// thread — a large tree far outlasts the control socket's read timeout — so
/// it reports only a summary for the log. Individual failures are counted and
/// logged rather than aborting the whole batch.
fn upload_paths(&self, parent_rel: &Path, sources: &[PathBuf]) -> Result<UploadStats, String> {
let (pino, parent_uid) = self
.resolve_path(parent_rel)
.map_err(|e| format!("resolve path: {e:?}"))?;
self.ensure_children(pino)
.map_err(|e| format!("enumerate: {e:?}"))?;
// Phase 1 (sequential): build the remote folder skeleton and collect the
// flat list of files to upload. Folders must exist before their children,
// so this can't be parallelised. On a deep tree this is a folder-creation
// round-trip per directory before a single byte moves — long enough that
// it needs a job of its own, or the daemon looks idle for minutes.
let mut tasks = Vec::new();
let mut folders = 0usize;
{
let job = self.transfers.begin_job("Preparing upload");
for src in sources {
if let Err(e) =
self.collect_uploads(pino, &parent_uid, src, &mut tasks, &mut folders, &job)
{
warn!(source = %src.display(), error = %e, "skipping source");
}
}
}
// Phase 2 (concurrent): upload the files, up to UPLOAD_CONCURRENCY at once.
// Each file reports its own bytes; this job is the batch's "N of M files",
// which is the number a user actually waits on.
let job = Arc::new(self.transfers.begin_job(match tasks.len() {
1 => "Uploading 1 file".to_string(),
n => format!("Uploading {n} files"),
}));
job.set_total(tasks.len() as u64);
let outcomes = self.rt.block_on(run_uploads(
self.clone(),
tasks,
UPLOAD_CONCURRENCY,
job.clone(),
));
drop(job);
// Phase 3 (sequential): intern each uploaded node so it shows up in the
// listing without a re-enumeration. fetch_node uses `block_on`, so it must
// run here rather than inside the async batch — and it is a round-trip per
// file, so it too gets a job rather than a silent tail.
let mut stats = UploadStats {
folders,
..UploadStats::default()
};
let job = self.transfers.begin_job("Finishing upload");
job.set_total(outcomes.len() as u64);
for outcome in outcomes {
job.step();
match outcome {
Ok((parent_ino, uid, size)) => {
stats.uploaded += 1;
stats.bytes += size;
match self.fetch_node(&uid) {
Ok(node) => {
let mut st = self.state.lock().unwrap();
let ino = st.intern(parent_ino, node);
if let Some(kids) = st.children.get_mut(&parent_ino)
&& !kids.contains(&ino)
{
kids.push(ino);
}
}
// Uploaded fine but the metadata refresh failed; it will
// appear on the next directory enumeration regardless.
Err(e) => warn!(%uid, error = ?e, "uploaded node metadata refresh failed"),
}
}
Err((name, msg)) => {
stats.failed += 1;
warn!(name, error = %msg, "file upload failed");
}
}
}
info!(
uploaded = stats.uploaded,
failed = stats.failed,
"bulk upload finished"
);
Ok(stats)
}
/// Resolve a remote child folder named `name` under `pino`, creating it if it
/// doesn't exist, and return its `(inode, uid)`. Reusing an existing same-named
/// folder makes re-uploading a directory merge into it rather than fail on a
/// duplicate name.
fn ensure_remote_folder(
&self,
pino: u64,
parent_uid: &NodeUid,
name: &str,
) -> Result<(u64, NodeUid), String> {
if name.is_empty() || name.contains('/') {
return Err(format!("invalid folder name: {name:?}"));
}
self.ensure_children(pino)
.map_err(|e| format!("enumerate: {e:?}"))?;
{
let st = self.state.lock().unwrap();
if let Some(kids) = st.children.get(&pino) {
for &ino in kids {
if let Some(e) = st.entries.get(&ino)
&& e.node.is_folder()
&& e.node.name == name
{
return Ok((ino, e.uid.clone()));
}
}
}
}
let new_uid = self
.rt
.block_on(
self.client
.create_folder(parent_uid, name, Some(now_secs())),
)
.map_err(|e| format!("create folder {name}: {e}"))?;
let node = self
.fetch_node(&new_uid)
.map_err(|e| format!("fetch node: {e:?}"))?;
let mut st = self.state.lock().unwrap();
let ino = st.intern(pino, node);
if let Some(kids) = st.children.get_mut(&pino)
&& !kids.contains(&ino)
{
kids.push(ino);
}
Ok((ino, new_uid))
}
/// Walk one local source path, appending its files to `tasks`. A file becomes
/// one task; a directory is recreated remotely and recursed into (children
/// sorted for a stable order). Symlinks and other special files are skipped.
///
/// `job` narrates the walk with the folder currently being mirrored. It stays
/// indeterminate: nothing knows the size of the tree until the walk has ended.
fn collect_uploads(
&self,
pino: u64,
parent_uid: &NodeUid,
src: &Path,
tasks: &mut Vec<UploadTask>,
folders: &mut usize,
job: &JobGuard,
) -> Result<(), String> {
let meta =
std::fs::symlink_metadata(src).map_err(|e| format!("stat {}: {e}", src.display()))?;
let name = src
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| format!("unusable name: {}", src.display()))?
.to_string();
if meta.is_dir() {
job.detail(&name);
let (child_ino, child_uid) = self.ensure_remote_folder(pino, parent_uid, &name)?;
*folders += 1;
let mut entries: Vec<PathBuf> = std::fs::read_dir(src)
.map_err(|e| format!("read dir {}: {e}", src.display()))?
.filter_map(|e| e.ok())
.map(|e| e.path())
.collect();
entries.sort();
for entry in entries {
if let Err(e) =
self.collect_uploads(child_ino, &child_uid, &entry, tasks, folders, job)
{
warn!(source = %entry.display(), error = %e, "skipping entry");
}
}
// Deeper folders have retitled the job by now; put this one back so the
// line tracks the walk's position rather than its deepest leaf.
job.detail(&name);
} else if meta.is_file() {
if name.contains('/') {
return Err(format!("invalid file name: {name:?}"));
}
tasks.push(UploadTask {
parent_ino: pino,
parent_uid: parent_uid.clone(),
name,
path: src.to_path_buf(),
size: meta.len(),
});
}
Ok(())
}
// ---- devices ----------------------------------------------------------
/// List the account's registered devices.
fn list_devices(&self) -> Result<Vec<DeviceInfo>, String> {
let devices = self
.rt
.block_on(self.client.enumerate_devices())
.map_err(|e| format!("list devices: {e}"))?;
Ok(devices
.into_iter()
.map(|d| DeviceInfo {
uid: d.uid.to_string(),
name: d.name.unwrap_or_else(|_| "(unnamed device)".to_string()),
device_type: device_type_str(d.device_type).to_string(),
last_sync: d.last_sync_time,
})
.collect())
}
/// Rename a device by its uid.
fn rename_device(&self, uid: &str, name: &str) -> Result<(), String> {
if name.is_empty() {
return Err("device name must not be empty".to_string());
}
let device_uid = DeviceUid::from(uid);
self.rt
.block_on(self.client.rename_device(&device_uid, name))
.map_err(|e| format!("rename device: {e}"))?;
Ok(())
}
/// Delete (deregister) a device by its uid.
fn delete_device(&self, uid: &str) -> Result<(), String> {
let device_uid = DeviceUid::from(uid);
self.rt
.block_on(self.client.delete_device(&device_uid))
.map_err(|e| format!("delete device: {e}"))?;
Ok(())
}
// ---- device folder sync (devices.md, Phase 1) -------------------------
/// Auto-register (or recover) this machine as a Proton Drive Device, caching
/// it so restarts reuse the same device. Recovery matches an existing remote
/// Linux device by name before creating a new one, so a lost local record
/// doesn't orphan the device's root folder.
fn ensure_device(&self) -> Result<StoredDevice, String> {
let name = this_hostname();
// Enumerate the remote devices once: used both to validate any cached
// record and to recover an existing device by name.
let remote = self
.rt
.block_on(self.client.enumerate_devices())
.map_err(|e| format!("enumerate devices: {e}"))?;
// A cached device is only trustworthy if it still exists remotely. A
// device deleted from another client (or the web UI) leaves a stale row
// whose root folder is gone, so creating folders under it fails with
// "parent node is not a folder". Re-register in that case.
if let Some(dev) = self.db.device_get().map_err(|e| format!("db: {e:?}"))? {
if remote.iter().any(|d| d.uid.to_string() == dev.uid) {
return Ok(dev);
}
warn!(uid = %dev.uid, "cached device is gone remotely; re-registering");
}
// Recover: an existing remote Linux device with the same name is ours.
let existing = remote.into_iter().find(|d| {
d.device_type == DeviceType::Linux && d.name.as_deref().ok() == Some(name.as_str())
});
let dev = match existing {
Some(d) => StoredDevice {
uid: d.uid.to_string(),
share_id: d.share_id.to_string(),
root_uid: d.root_folder_uid.to_string(),
name,
created: d.creation_time,
},
None => {
let d = self
.rt
.block_on(self.client.create_device(&name, DeviceType::Linux))
.map_err(|e| format!("create device: {e}"))?;
StoredDevice {
uid: d.uid.to_string(),
share_id: d.share_id.to_string(),
root_uid: d.root_folder_uid.to_string(),
name,
created: d.creation_time,
}
}
};
self.db.device_set(&dev).map_err(|e| format!("db: {e:?}"))?;
Ok(dev)
}
/// An untrashed folder named `name` directly under the device root, if one
/// already exists.
fn find_device_child_folder(
&self,
root_uid: &NodeUid,
name: &str,
) -> Result<Option<NodeUid>, String> {
let uids = self
.rt
.block_on(self.client.enumerate_folder_children_node_uids(root_uid))
.map_err(|e| format!("list device root: {e}"))?;
if uids.is_empty() {
return Ok(None);
}
let nodes = self
.rt
.block_on(self.client.enumerate_nodes(&uids))
.map_err(|e| format!("resolve device root children: {e}"))?;
Ok(nodes
.into_iter()
.find(|n| n.is_folder() && !n.trashed && n.name == name)
.map(|n| n.uid))
}
/// Add a local folder to this device's sync set: register the device if
/// needed, create a matching folder under the device root, upload the local
/// tree into it once, and record the mapping. Phase 1 is a one-shot upload —
/// the two-way engine (Phase 2) reconciles later changes.
fn add_sync_folder(&self, local: &Path) -> Result<StoredSyncFolder, String> {
let meta =
std::fs::metadata(local).map_err(|e| format!("stat {}: {e}", local.display()))?;
if !meta.is_dir() {
return Err(format!("{} is not a directory", local.display()));
}
let local = local
.canonicalize()
.map_err(|e| format!("canonicalize {}: {e}", local.display()))?;
let local_str = local.to_string_lossy().to_string();
// Reject duplicates up front for a clear error (UNIQUE would also catch it).
if self
.db
.sync_folder_list()
.map_err(|e| format!("db: {e:?}"))?
.iter()
.any(|f| f.local_path == local_str)
{
return Err(format!("{} is already synced", local.display()));
}
let name = local
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| format!("unusable folder name: {}", local.display()))?
.to_string();
let device = self.ensure_device()?;
let root_uid = parse_uid(&device.root_uid)
.ok_or_else(|| format!("bad device root uid: {}", device.root_uid))?;
// The synced folder's remote root: the folder under the device root named
// after the local basename. Reuse an existing one rather than creating a
// second folder with the same name — re-adding a folder (after a removal, or
// after a failed add that had already created it) must land back on the
// original, not leave the user with two "Downloads" in their Drive. The
// reconcile treats an existing remote tree correctly: unmatched paths read as
// a conflict, not as data loss.
let remote_root = match self.find_device_child_folder(&root_uid, &name)? {
Some(uid) => {
info!(name, "reusing existing device folder");
uid
}
None => self
.rt
.block_on(
self.client
.create_folder(&root_uid, &name, Some(now_secs())),
)
.map_err(|e| format!("create device folder {name}: {e}"))?,
};
let id = self
.db
.sync_folder_add(&local_str, &remote_root.to_string(), &device.share_id)
.map_err(|e| format!("db: {e:?}"))?;
// Hand the initial upload to the sync engine: an empty baseline against a
// full local tree reconciles as "upload everything", and the folder is
// added to the filesystem watch set in the same pass.
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
let _ = self.sync_tx.send(sync::SyncMsg::Reconcile(id));
info!(local = %local.display(), id, "added sync folder");
self.db
.sync_folder_get(id)
.map_err(|e| format!("db: {e:?}"))?
.ok_or_else(|| "sync folder vanished after insert".to_string())
}
/// List this device's synced folders for the front-ends, each carrying the
/// live progress of its pass when one is running.
fn list_sync_folders(&self) -> Result<Vec<SyncFolderInfo>, String> {
let progress = self.sync_progress.lock().unwrap();
Ok(self
.db
.sync_folder_list()
.map_err(|e| format!("db: {e:?}"))?
.into_iter()
.map(|f| {
let live = progress.get(&f.id).cloned();
sync_folder_info(f, live)
})
.collect())
}
/// Everything the daemon is chewing on that isn't moving bytes, for
/// `GetQueueStatus`: the registered jobs (bulk-upload scans, the local index)
/// plus a synthesized job per running sync pass, so one Activity view answers
/// "is anything still happening?" without also polling `ListSyncFolders`.
///
/// The sync passes are folded in here rather than tracked as registry jobs
/// because the Devices page needs them per folder anyway
/// ([`SyncFolderInfo::progress`]) — this keeps one source of truth and hits
/// the db only while a pass is actually running.
fn jobs_snapshot(&self) -> Vec<JobItem> {
let mut jobs = self.transfers.jobs_snapshot();
let mut passes: Vec<(i64, SyncProgress)> = self
.sync_progress
.lock()
.unwrap()
.iter()
.map(|(id, p)| (*id, p.clone()))
.collect();
if passes.is_empty() {
return jobs;
}
passes.sort_by_key(|(id, _)| *id);
let names: HashMap<i64, String> = self
.db
.sync_folder_list()
.unwrap_or_default()
.into_iter()
.map(|f| (f.id, f.local_path))
.collect();
for (id, p) in passes {
// The row is titled with the folder's own name; the full local path
// is what the Devices page shows, and is far too long for this line.
let folder = names
.get(&id)
.and_then(|path| Path::new(path).file_name())
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "folder".to_string());
jobs.push(match p.phase {
// No counts exist until the walk has classified something, so a
// scan reports indeterminate rather than a fake 0 of 0.
SyncPhase::Scanning => JobItem {
title: format!("Checking {folder}"),
detail: "Looking for changes".to_string(),
done: 0,
total: 0,
},
SyncPhase::Applying => JobItem {
title: format!("Syncing {folder}"),
detail: p.current.clone(),
done: p.done as u64,
total: p.total.max(p.done) as u64,
},
});
}
jobs
}
/// The lock guarding sync-folder `id` against concurrent reconcile/mode-switch.
pub(crate) fn sync_lock(&self, id: i64) -> Arc<Mutex<()>> {
self.sync_locks
.lock()
.unwrap()
.entry(id)
.or_default()
.clone()
}
/// Remove a synced folder from the sync set. `delete_remote` also deletes its
/// folder under the device root; otherwise the cloud copy is left in place.
fn remove_sync_folder(&self, id: i64, delete_remote: bool) -> Result<(), String> {
let folder = self
.db
.sync_folder_get(id)
.map_err(|e| format!("db: {e:?}"))?
.ok_or_else(|| format!("no synced folder with id {id}"))?;
if delete_remote
&& let Some(uid) = parse_uid(&folder.remote_uid)
&& let Err(e) = self.rt.block_on(self.client.trash_nodes(&[uid]))
{
warn!(id, error = %e, "delete remote device folder failed");
}
if !self
.db
.sync_folder_remove(id)
.map_err(|e| format!("db: {e:?}"))?
{
return Err(format!("no synced folder with id {id}"));
}
// Stop watching the folder we just dropped.
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
Ok(())
}
/// Trigger a reconcile: one folder by id, or every folder when `id` is `None`.
fn sync_now(&self, id: Option<i64>) {
let _ = match id {
Some(id) => self.sync_tx.send(sync::SyncMsg::Reconcile(id)),
None => self.sync_tx.send(sync::SyncMsg::ReconcileAll),
};
}
/// A sibling Core that shares this one's client/rt/cache/db (and transfer,
/// activity, mount registries) but gets a **fresh, empty `State`** — its own
/// inode space starting at [`ROOT_INO`]. Used to root a secondary FUSE session
/// at an `ondemand` sync folder without colliding with the main mount's inodes
/// (devices.md Phase 3).
fn fork_state(&self) -> Core {
let mut fork = self.clone();
fork.state = Arc::new(Mutex::new(State {
entries: HashMap::new(),
by_uid: HashMap::new(),
children: HashMap::new(),
next_ino: 2,
handles: HashMap::new(),
next_fh: 1,
db: self.db.clone(),
}));
fork
}
/// Flip a synced folder between `mirror` (full local copy + two-way sync) and
/// `ondemand` (a FUSE mount over the local path; no local storage). Returns a
/// human message for the reply.
///
/// - **mirror→ondemand**: require the folder in-sync (`idle`), stop watching it,
/// evict the local files to reclaim disk, then mount a secondary `ProtonFs`
/// rooted at the folder's remote node over its local path.
/// - **ondemand→mirror**: unmount, clear the stale baseline (the local tree was
/// evicted), then hand the folder back to the engine, which re-downloads it.
fn set_sync_folder_mode(&self, id: i64, mode: &str) -> Result<String, String> {
if mode != "mirror" && mode != "ondemand" {
return Err(format!("unknown mode {mode:?} (want mirror|ondemand)"));
}
// Hold the folder's lock for the whole switch so no reconcile pass can be
// running over the tree we are about to evict (or start while we mount over
// it). A pass in flight holds the lock for its full duration, so `try_lock`
// failing is exactly "still syncing" — and it is the only reliable signal:
// the `state` column is still `idle` in the window between `add_sync_folder`
// inserting the row and the engine picking it up.
let lock = self.sync_lock(id);
let Ok(_guard) = lock.try_lock() else {
return Err("folder is syncing right now — wait for it to finish".to_string());
};
// Re-read under the lock: a pass that finished while we waited may have
// changed `state`.
let folder = self
.db
.sync_folder_get(id)
.map_err(|e| format!("db: {e:?}"))?
.ok_or_else(|| format!("no synced folder with id {id}"))?;
if folder.mode == mode {
return Ok(format!("already {mode}"));
}
let local = PathBuf::from(&folder.local_path);
match mode {
"ondemand" => {
// Only flip a folder that is fully in sync — a failed reconcile means
// local edits could still be un-uploaded, and we are about to delete
// the local copy.
if folder.state != "idle" {
return Err(format!(
"folder is '{}', not idle — sync it before switching to on-demand",
folder.state
));
}
let root_uid = parse_uid(&folder.remote_uid)
.ok_or_else(|| format!("bad remote uid: {}", folder.remote_uid))?;
let root = self
.rt
.block_on(self.client.enumerate_nodes(std::slice::from_ref(&root_uid)))
.map_err(|e| format!("fetch remote root: {e}"))?
.into_iter()
.next()
.ok_or_else(|| "remote folder not found".to_string())?;
// Reclaim the disk: empty the local dir (keep it as the mountpoint).
evict_dir_contents(&local)
.map_err(|e| format!("evict {}: {e}", local.display()))?;
let session = self.spawn_ondemand_mount(&local, root)?;
self.mounts.lock().unwrap().insert(id, session);
// Persist the mode only now that the mount is actually up. Writing it
// first would strand the folder on any failure below: the engine skips
// non-mirror folders, so an `ondemand` row with no mount is a folder
// that is neither mirrored nor browsable, and nothing retries it.
// Failing before this point leaves it `mirror`, and the next pass
// re-downloads whatever eviction removed.
self.db
.sync_folder_set_mode(id, "ondemand")
.map_err(|e| format!("db: {e:?}"))?;
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
self.db.sync_folder_set_state(id, "idle", now_secs()).ok();
info!(id, path = %local.display(), "mounted sync folder on-demand");
Ok(format!("{} is now on-demand", local.display()))
}
_ => {
// ondemand→mirror: tear down the secondary mount first.
if let Some(session) = self.mounts.lock().unwrap().remove(&id)
&& let Err(e) = session.umount_and_join()
{
warn!(id, error = %e, "unmount on-demand folder failed");
}
// The evicted local tree makes the old baseline lie ("everything
// deleted locally"); clear it so the reconcile is a pure download.
self.db
.sync_entries_clear(id)
.map_err(|e| format!("db: {e:?}"))?;
self.db
.sync_folder_set_mode(id, "mirror")
.map_err(|e| format!("db: {e:?}"))?;
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
let _ = self.sync_tx.send(sync::SyncMsg::Reconcile(id));
info!(id, path = %local.display(), "restored sync folder to mirror");
Ok(format!(
"{} is mirroring again; downloading",
local.display()
))
}
}
}
/// Spawn a secondary FUSE session rooted at `root` over `local` on a forked
/// inode space. Clears any stale kernel mount first (a crashed run can leave
/// one, which would fail the fresh mount with EBUSY).
fn spawn_ondemand_mount(&self, local: &Path, root: Node) -> Result<BackgroundSession, String> {
clear_stale_mount(local);
let mut config = Config::default();
config.mount_options = vec![
MountOption::FSName("protondrive".to_string()),
MountOption::Subtype("protondrive".to_string()),
MountOption::DefaultPermissions,
];
let fs = ProtonFs::new(self.fork_state(), root);
Session::new(fs, local, &config)
.and_then(|s| s.spawn())
.map_err(|e| format!("mount {}: {e}", local.display()))
}
/// Re-establish FUSE mounts for folders left in `ondemand` mode across a daemon
/// restart (their local dirs are empty on disk — the files live in the cloud).
/// Best-effort per folder: a missing local path or a failed remote fetch marks
/// the folder `error` and moves on rather than aborting the rest. Runs on its
/// own thread from `mount` so the network fetches never block startup
/// (devices.md Phase 4).
fn restore_ondemand_mounts(&self) {
let folders = match self.db.sync_folder_list() {
Ok(f) => f,
Err(e) => {
warn!(error = ?e, "restore on-demand: cannot list folders");
return;
}
};
for folder in folders {
if folder.mode != "ondemand" {
continue;
}
let local = PathBuf::from(&folder.local_path);
if !local.is_dir() {
warn!(id = folder.id, path = %local.display(), "restore on-demand: local path missing");
let _ = self
.db
.sync_folder_set_state(folder.id, "error", now_secs());
continue;
}
// An `ondemand` folder's local dir is empty by construction — the switch
// evicts it. Finding files there means the row is lying (a switch that
// died between persisting the mode and evicting), and mounting over them
// would hide real local data behind the remote tree. Leave the files
// alone and let the user resolve it.
match dir_is_empty(&local) {
Ok(true) => {}
Ok(false) => {
warn!(
id = folder.id,
path = %local.display(),
"restore on-demand: local dir is not empty; refusing to mount over it"
);
let _ = self
.db
.sync_folder_set_state(folder.id, "error", now_secs());
continue;
}
Err(e) => {
warn!(id = folder.id, path = %local.display(), error = %e, "restore on-demand: cannot read local dir");
let _ = self
.db
.sync_folder_set_state(folder.id, "error", now_secs());
continue;
}
}
let Some(root_uid) = parse_uid(&folder.remote_uid) else {
warn!(id = folder.id, "restore on-demand: bad remote uid");
continue;
};
let root = match self
.rt
.block_on(self.client.enumerate_nodes(std::slice::from_ref(&root_uid)))
{
Ok(v) => match v.into_iter().next() {
Some(n) => n,
None => {
warn!(id = folder.id, "restore on-demand: remote folder gone");
let _ = self
.db
.sync_folder_set_state(folder.id, "error", now_secs());
continue;
}
},
Err(e) => {
warn!(id = folder.id, error = %e, "restore on-demand: fetch remote failed");
let _ = self
.db
.sync_folder_set_state(folder.id, "error", now_secs());
continue;
}
};
match self.spawn_ondemand_mount(&local, root) {
Ok(session) => {
self.mounts.lock().unwrap().insert(folder.id, session);
let _ = self.db.sync_folder_set_state(folder.id, "idle", now_secs());
info!(id = folder.id, path = %local.display(), "remounted on-demand folder");
}
Err(e) => {
warn!(id = folder.id, error = %e, "restore on-demand: mount failed");
let _ = self
.db
.sync_folder_set_state(folder.id, "error", now_secs());
}
}
}
}
// ---- sharing a node ---------------------------------------------------
/// Invite Proton and/or external emails to the node at `rel` at `role`.
/// Returns `(proton_invited, external_invited)`.
fn share_node(
&self,
rel: &Path,
emails: &[String],
role: &str,
message: Option<&str>,
) -> Result<(usize, usize), String> {
let (_ino, uid) = self
.resolve_path(rel)
.map_err(|e| format!("resolve path: {e:?}"))?;
let role = role_from_str(role)?;
let invitees: Vec<(String, MemberRole)> =
emails.iter().map(|e| (e.clone(), role)).collect();
self.rt
.block_on(self.client.invite_users(&uid, &invitees, message))
.map_err(|e| format!("share: {e}"))
}
/// List the members, pending invitations and public link of the node at `rel`.
fn list_share(&self, rel: &Path) -> Result<(Vec<ShareEntry>, Option<PublicLinkInfo>), String> {
let (_ino, uid) = self
.resolve_path(rel)
.map_err(|e| format!("resolve path: {e:?}"))?;
let mut entries = Vec::new();
for m in self
.rt
.block_on(self.client.list_share_members(&uid))
.map_err(|e| format!("list members: {e}"))?
{
entries.push(ShareEntry {
id: m.membership_id.to_string(),
email: m.email,
role: role_to_str(m.role).to_string(),
kind: ShareEntryKind::Member,
});
}
for inv in self
.rt
.block_on(self.client.list_share_invitations(&uid))
.map_err(|e| format!("list invitations: {e}"))?
{
entries.push(ShareEntry {
id: inv.invitation_id,
email: inv.invitee_email,
role: role_to_str(inv.role).to_string(),
kind: ShareEntryKind::ProtonInvite,
});
}
for ext in self
.rt
.block_on(self.client.list_external_invitations(&uid))
.map_err(|e| format!("list external invitations: {e}"))?
{
entries.push(ShareEntry {
id: ext.invitation_id,
email: ext.invitee_email,
role: role_to_str(ext.role).to_string(),
kind: ShareEntryKind::ExternalInvite,
});
}
let link = self
.rt
.block_on(self.client.get_public_link(&uid))
.map_err(|e| format!("get public link: {e}"))?
.map(public_link_info);
Ok((entries, link))
}
/// Change the role of a member or pending Proton invitation on the node at
/// `rel`. External invitations have no role-update endpoint.
fn update_share_role(
&self,
rel: &Path,
id: &str,
kind: ShareEntryKind,
role: &str,
) -> Result<(), String> {
let (_ino, uid) = self
.resolve_path(rel)
.map_err(|e| format!("resolve path: {e:?}"))?;
let role = role_from_str(role)?;
match kind {
ShareEntryKind::Member => {
let member = self
.rt
.block_on(self.client.list_share_members(&uid))
.map_err(|e| format!("list members: {e}"))?
.into_iter()
.find(|m| m.membership_id.to_string() == id)
.ok_or_else(|| "member not found".to_string())?;
self.rt
.block_on(self.client.update_member_role(&member, role))
.map_err(|e| format!("update role: {e}"))
}
ShareEntryKind::ProtonInvite => {
let inv = self
.rt
.block_on(self.client.list_share_invitations(&uid))
.map_err(|e| format!("list invitations: {e}"))?
.into_iter()
.find(|i| i.invitation_id == id)
.ok_or_else(|| "invitation not found".to_string())?;
self.rt
.block_on(self.client.update_invitation_role(&inv, role))
.map_err(|e| format!("update role: {e}"))
}
ShareEntryKind::ExternalInvite => {
Err("an external invitation's role cannot be changed".to_string())
}
}
}
/// Remove a member, pending Proton invite, or external invite from the node
/// at `rel`.
fn remove_share_entry(&self, rel: &Path, id: &str, kind: ShareEntryKind) -> Result<(), String> {
let (_ino, uid) = self
.resolve_path(rel)
.map_err(|e| format!("resolve path: {e:?}"))?;
match kind {
ShareEntryKind::Member => {
let member = self
.rt
.block_on(self.client.list_share_members(&uid))
.map_err(|e| format!("list members: {e}"))?
.into_iter()
.find(|m| m.membership_id.to_string() == id)
.ok_or_else(|| "member not found".to_string())?;
self.rt
.block_on(self.client.remove_member(&member))
.map_err(|e| format!("remove member: {e}"))
}
ShareEntryKind::ProtonInvite => {
let inv = self
.rt
.block_on(self.client.list_share_invitations(&uid))
.map_err(|e| format!("list invitations: {e}"))?
.into_iter()
.find(|i| i.invitation_id == id)
.ok_or_else(|| "invitation not found".to_string())?;
self.rt
.block_on(self.client.delete_invitation(&inv))
.map_err(|e| format!("revoke invitation: {e}"))
}
ShareEntryKind::ExternalInvite => {
let ext = self
.rt
.block_on(self.client.list_external_invitations(&uid))
.map_err(|e| format!("list external invitations: {e}"))?
.into_iter()
.find(|i| i.invitation_id == id)
.ok_or_else(|| "external invitation not found".to_string())?;
self.rt
.block_on(self.client.delete_external_invitation(&ext))
.map_err(|e| format!("revoke external invitation: {e}"))
}
}
}
/// Create a public link on the node at `rel`, returning its info (with URL).
fn create_public_link(
&self,
rel: &Path,
role: &str,
password: Option<&str>,
expires: Option<i64>,
) -> Result<PublicLinkInfo, String> {
let (_ino, uid) = self
.resolve_path(rel)
.map_err(|e| format!("resolve path: {e:?}"))?;
let role = role_from_str(role)?;
let link = self
.rt
.block_on(
self.client
.create_public_link(&uid, role, password, expires),
)
.map_err(|e| format!("create public link: {e}"))?;
Ok(public_link_info(link))
}
/// Remove the public link `id` from the node at `rel`.
fn remove_public_link(&self, rel: &Path, id: &str) -> Result<(), String> {
let (_ino, uid) = self
.resolve_path(rel)
.map_err(|e| format!("resolve path: {e:?}"))?;
let link = self
.rt
.block_on(self.client.get_public_link(&uid))
.map_err(|e| format!("get public link: {e}"))?
.filter(|l| l.public_link_id == id)
.ok_or_else(|| "public link not found".to_string())?;
self.rt
.block_on(self.client.remove_public_link(&link))
.map_err(|e| format!("remove public link: {e}"))
}
// ---- shared with me ---------------------------------------------------
/// List nodes shared with me that I have accepted.
fn list_shared_with_me(&self) -> Result<Vec<DirEntry>, String> {
let uids = self
.rt
.block_on(self.client.enumerate_shared_with_me_node_uids())
.map_err(|e| format!("enumerate shared: {e}"))?;
if uids.is_empty() {
return Ok(Vec::new());
}
let nodes = self
.rt
.block_on(self.client.enumerate_nodes(&uids))
.map_err(|e| format!("enumerate nodes: {e}"))?;
Ok(nodes
.into_iter()
.map(|n| {
let is_dir = n.is_folder();
let size = match &n.kind {
NodeKind::File {
claimed_size,
total_size_on_storage,
..
} => claimed_size.unwrap_or(*total_size_on_storage).max(0) as u64,
NodeKind::Folder => 0,
};
DirEntry {
name: n.name,
is_dir,
size,
modified: 0,
pinned: false,
cached: false,
uid: n.uid.to_string(),
path: String::new(),
}
})
.collect())
}
/// Leave a shared node by its uid.
fn leave_shared(&self, uid: &str) -> Result<(), String> {
let uid = parse_uid(uid).ok_or_else(|| format!("invalid uid: {uid}"))?;
self.rt
.block_on(self.client.leave_shared_node(&uid))
.map_err(|e| format!("leave shared: {e}"))
}
// ---- incoming invitations ---------------------------------------------
/// List invitations addressed to me, pending accept or reject.
fn list_invitations(&self) -> Result<Vec<InvitationInfo>, String> {
let invitations = self
.rt
.block_on(self.client.list_incoming_invitations())
.map_err(|e| format!("list invitations: {e}"))?;
Ok(invitations
.into_iter()
.map(|i| InvitationInfo {
id: i.invitation_id,
inviter_email: i.inviter_email,
name: i.node_name,
is_dir: i.is_folder,
})
.collect())
}
/// Accept an invitation addressed to me by its id.
fn accept_invitation(&self, id: &str) -> Result<(), String> {
self.rt
.block_on(self.client.accept_invitation(id))
.map_err(|e| format!("accept invitation: {e}"))
}
/// Reject an invitation addressed to me by its id.
fn reject_invitation(&self, id: &str) -> Result<(), String> {
self.rt
.block_on(self.client.reject_invitation(id))
.map_err(|e| format!("reject invitation: {e}"))
}
// ---- bookmarks --------------------------------------------------------
/// List public links saved to my account.
fn list_bookmarks(&self) -> Result<Vec<BookmarkInfo>, String> {
let bookmarks = self
.rt
.block_on(self.client.list_bookmarks())
.map_err(|e| format!("list bookmarks: {e}"))?;
Ok(bookmarks
.into_iter()
.map(|b| BookmarkInfo {
token: b.token,
url: b.url,
name: b.node_name,
is_dir: b.is_folder,
})
.collect())
}
/// Save a public link (optionally password-protected) as a bookmark.
fn create_bookmark(&self, url: &str, password: Option<&str>) -> Result<(), String> {
self.rt
.block_on(self.client.create_bookmark(url, password))
.map_err(|e| format!("create bookmark: {e}"))
}
/// Remove a saved bookmark by its token.
fn delete_bookmark(&self, token: &str) -> Result<(), String> {
self.rt
.block_on(self.client.delete_bookmark(token))
.map_err(|e| format!("delete bookmark: {e}"))
}
// ---- shared by me -----------------------------------------------------
/// List the nodes I have shared with others, each with a summary of its share
/// state (members, pending invitations, public link). One list call enumerates
/// the shared uids; the per-node detail is then gathered best-effort — a single
/// node racing with an unshare drops from the list rather than failing the whole
/// request.
fn list_shared_by_me(&self) -> Result<Vec<SharedItem>, String> {
let uids = self
.rt
.block_on(self.client.enumerate_shared_by_me_node_uids())
.map_err(|e| format!("enumerate shared-by-me: {e}"))?;
if uids.is_empty() {
return Ok(Vec::new());
}
let nodes = self
.rt
.block_on(self.client.enumerate_nodes(&uids))
.map_err(|e| format!("enumerate nodes: {e}"))?;
let mut items = Vec::with_capacity(nodes.len());
for n in nodes {
let uid = n.uid.clone();
let members = self
.rt
.block_on(self.client.list_share_members(&uid))
.map(|m| m.len())
.unwrap_or(0);
let proton_invites = self
.rt
.block_on(self.client.list_share_invitations(&uid))
.map(|i| i.len())
.unwrap_or(0);
let external_invites = self
.rt
.block_on(self.client.list_external_invitations(&uid))
.map(|i| i.len())
.unwrap_or(0);
let link = self
.rt
.block_on(self.client.get_public_link(&uid))
.ok()
.flatten()
.map(public_link_info);
items.push(SharedItem {
uid: uid.to_string(),
is_dir: n.is_folder(),
name: n.name,
path: self.rel_path_for_uid(&uid).unwrap_or_default(),
member_count: members,
invite_count: proton_invites + external_invites,
link,
});
}
Ok(items)
}
/// Best-effort mountpoint-relative path for a node already interned in the live
/// tree, by walking parent inodes to the root. `None` when the node has never
/// been seen through the mount (e.g. shared but not browsed to this session) —
/// the caller then leaves the path empty.
fn rel_path_for_uid(&self, uid: &NodeUid) -> Option<String> {
let st = self.state.lock().unwrap();
let mut ino = *st.by_uid.get(uid)?;
let mut parts = Vec::new();
while ino != ROOT_INO {
let entry = st.entries.get(&ino)?;
parts.push(entry.node.name.clone());
ino = entry.parent;
}
parts.reverse();
Some(parts.join("/"))
}
// ---- activity log -----------------------------------------------------
/// Append one entry to the activity log. Callable from any thread (the sync
/// engine and the bulk uploader both log from background tasks). A failed
/// write is logged and dropped: the feed is a record of work, never a reason
/// to fail the work itself.
pub(crate) fn log_activity(
&self,
kind: ActivityKind,
target: impl Into<String>,
detail: impl Into<String>,
ok: bool,
) {
let entry = ActivityEntry {
time: now_secs(),
kind,
target: target.into(),
detail: detail.into(),
ok,
};
if let Err(e) = self.db.activity_add(&entry) {
warn!(error = ?e, "could not record activity");
}
}
/// The recent activity, newest first, capped at `limit` entries.
fn list_activity(&self, limit: usize) -> Vec<ActivityEntry> {
match self.db.activity_list(limit) {
Ok(items) => items,
Err(e) => {
warn!(error = ?e, "could not read activity");
Vec::new()
}
}
}
// ---- live sync progress -----------------------------------------------
/// Start tracking a reconcile pass over `folder_id`, in [`SyncPhase::Scanning`].
pub(crate) fn progress_begin(&self, folder_id: i64) {
self.sync_progress.lock().unwrap().insert(
folder_id,
SyncProgress {
phase: SyncPhase::Scanning,
done: 0,
total: 0,
current: String::new(),
},
);
}
/// Apply `f` to a folder's live progress, if a pass is running for it.
fn progress_update(&self, folder_id: i64, f: impl FnOnce(&mut SyncProgress)) {
if let Some(p) = self.sync_progress.lock().unwrap().get_mut(&folder_id) {
f(p);
}
}
/// Note that `n` more items have been queued for this pass, and that it has
/// moved on from scanning to applying the diff.
pub(crate) fn progress_queued(&self, folder_id: i64, n: usize) {
self.progress_update(folder_id, |p| {
p.phase = SyncPhase::Applying;
p.total += n;
});
}
/// Note that work has started on `name` (shown as the pass's current item).
pub(crate) fn progress_started(&self, folder_id: i64, name: &str) {
self.progress_update(folder_id, |p| p.current = name.to_string());
}
/// Note that one queued item finished, whether it succeeded or not.
pub(crate) fn progress_finished(&self, folder_id: i64) {
self.progress_update(folder_id, |p| {
p.done += 1;
p.current.clear();
});
}
/// Stop tracking a pass — no progress is reported for the folder until the
/// next [`progress_begin`](Self::progress_begin).
pub(crate) fn progress_end(&self, folder_id: i64) {
self.sync_progress.lock().unwrap().remove(&folder_id);
}
}
/// Map a [`MemberRole`] to its wire string.
fn role_to_str(role: MemberRole) -> &'static str {
match role {
MemberRole::Viewer => "viewer",
MemberRole::Editor => "editor",
MemberRole::Admin => "admin",
MemberRole::Inherited => "inherited",
}
}
/// Parse a wire role string into a [`MemberRole`]. "inherited" is read-only and
/// rejected here, since it cannot be sent when inviting or updating.
fn role_from_str(role: &str) -> Result<MemberRole, String> {
match role.to_lowercase().as_str() {
"viewer" => Ok(MemberRole::Viewer),
"editor" => Ok(MemberRole::Editor),
"admin" => Ok(MemberRole::Admin),
other => Err(format!("invalid role: {other}")),
}
}
/// Map a device type to a display string.
fn device_type_str(t: proton_drive_rs::DeviceType) -> &'static str {
match t {
proton_drive_rs::DeviceType::Windows => "Windows",
proton_drive_rs::DeviceType::MacOs => "MacOs",
proton_drive_rs::DeviceType::Linux => "Linux",
}
}
/// Convert an SDK [`PublicLink`](proton_drive_rs::PublicLink) into the wire form.
fn public_link_info(link: proton_drive_rs::PublicLink) -> PublicLinkInfo {
PublicLinkInfo {
id: link.public_link_id,
url: link.url,
role: role_to_str(link.role).to_string(),
expires: link.expiration_time,
has_password: link.has_custom_password,
}
}
/// Parse a `volume~link` uid display string back into a [`NodeUid`]. Front-ends
/// receive uids as strings over the control socket and pass them back verbatim.
fn parse_uid(s: &str) -> Option<NodeUid> {
let (vol, link) = s.split_once('~')?;
Some(NodeUid::new(VolumeId::from(vol), LinkId::from(link)))
}
/// The Proton Drive VFS. FUSE callbacks are synchronous, so the Tokio handle
/// bridges each one to the async SDK via [`Handle::block_on`]; the fuser
/// session thread is not a runtime worker, so blocking on it is sound.
pub struct ProtonFs {
core: Core,
uid: u32,
gid: u32,
}
impl ProtonFs {
/// Build the filesystem rooted at `root` (the user's My Files folder).
fn new(core: Core, root: Node) -> Self {
{
let mut st = core.state.lock().unwrap();
if let Err(e) = st.db.upsert_node(&root) {
warn!(uid = %root.uid, error = %e, "db upsert root failed");
}
st.by_uid.insert(root.uid.clone(), ROOT_INO);
st.entries.insert(
ROOT_INO,
Entry {
uid: root.uid.clone(),
parent: ROOT_INO,
node: root,
},
);
}
// SAFETY: geteuid/getegid are infallible and have no preconditions.
let (uid, gid) = unsafe { (libc::geteuid(), libc::getegid()) };
Self { core, uid, gid }
}
fn attr(&self, ino: u64, node: &Node) -> FileAttr {
let (kind, perm) = match node.kind {
NodeKind::Folder => (FileType::Directory, 0o755),
NodeKind::File { .. } => (FileType::RegularFile, 0o644),
};
let size = node_size(node);
let mtime = unix_secs(node.modification_time);
let crtime = unix_secs(node.creation_time);
FileAttr {
ino: INodeNo(ino),
size,
blocks: size.div_ceil(512),
atime: mtime,
mtime,
ctime: mtime,
crtime,
kind,
perm,
nlink: if kind == FileType::Directory { 2 } else { 1 },
uid: self.uid,
gid: self.gid,
rdev: 0,
blksize: 512,
flags: 0,
}
}
/// Trash the child `name` under `parent` on the remote, then drop it from the
/// local cache. Backs both `unlink` and `rmdir` (Proton trashes whole
/// subtrees, so an `rmdir` of a non-empty dir behaves the same).
fn trash_child(&self, parent: u64, name: &str, reply: ReplyEmpty) {
let (_ino, uid) = match self.core.lookup_child(parent, name) {
Ok(x) => x,
Err(e) => {
reply.error(e);
return;
}
};
if let Err(e) = self
.core
.rt
.block_on(self.core.client.trash_nodes(std::slice::from_ref(&uid)))
{
error!(%uid, error = %e, "trash failed");
reply.error(Errno::EIO);
return;
}
self.core.state.lock().unwrap().forget(&uid);
self.core.cache.evict(&uid);
self.core.invalidate_trash();
reply.ok();
}
}
/// Current wall-clock time as epoch seconds (0 if the clock is before the epoch).
fn now_secs() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
/// This machine's hostname, used to name (and later recover) its Proton Drive
/// Device. Reads the live kernel hostname, falling back to a generic label.
fn this_hostname() -> String {
std::fs::read_to_string("/proc/sys/kernel/hostname")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "Linux device".to_string())
}
/// Whether `dir` has no entries.
fn dir_is_empty(dir: &Path) -> std::io::Result<bool> {
Ok(std::fs::read_dir(dir)?.next().is_none())
}
/// Delete everything inside `dir` but keep `dir` itself (it stays as the FUSE
/// mountpoint). Used when a `mirror` folder flips to `ondemand`: the local files
/// are the disk we're reclaiming (devices.md Phase 3).
fn evict_dir_contents(dir: &Path) -> std::io::Result<()> {
for entry in std::fs::read_dir(dir)? {
let path = entry?.path();
if path.is_dir() && !path.is_symlink() {
std::fs::remove_dir_all(&path)?;
} else {
std::fs::remove_file(&path)?;
}
}
Ok(())
}
/// Convert a stored synced folder into its wire form for the front-ends, with
/// the live progress of its pass when one is running.
fn sync_folder_info(f: StoredSyncFolder, progress: Option<SyncProgress>) -> SyncFolderInfo {
SyncFolderInfo {
id: f.id,
local_path: f.local_path,
remote_uid: f.remote_uid,
mode: f.mode,
state: f.state,
last_sync: f.last_sync,
progress,
}
}
/// A coarse MIME type guessed from a file name's extension; Proton stores this
/// on the node but an exact value is not required for correctness.
fn media_type_for(name: &str) -> &'static str {
let ext = name.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase());
match ext.as_deref() {
Some("txt" | "md" | "log") => "text/plain",
Some("png") => "image/png",
Some("jpg" | "jpeg") => "image/jpeg",
Some("gif") => "image/gif",
Some("pdf") => "application/pdf",
Some("json") => "application/json",
Some("html" | "htm") => "text/html",
_ => "application/octet-stream",
}
}
/// The plaintext size, in bytes, that a node reports.
fn node_size(node: &Node) -> u64 {
match &node.kind {
NodeKind::Folder => 0,
NodeKind::File {
claimed_size,
total_size_on_storage,
..
} => claimed_size.unwrap_or(*total_size_on_storage).max(0) as u64,
}
}
/// `"1 file"` / `"3 files"` — a count with a correctly pluralised noun, for
/// human-readable activity-log lines.
fn count_noun(n: usize, one: &str, many: &str) -> String {
format!("{n} {}", if n == 1 { one } else { many })
}
/// Bytes rendered with a binary unit and one decimal place (e.g. `"1.2 GB"`),
/// for the activity log. Uses 1024-based steps but the shorter SI labels.
fn human_bytes(bytes: u64) -> String {
const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
let mut val = bytes as f64;
let mut unit = 0;
while val >= 1024.0 && unit < UNITS.len() - 1 {
val /= 1024.0;
unit += 1;
}
if unit == 0 {
format!("{bytes} B")
} else {
format!("{val:.1} {}", UNITS[unit])
}
}
/// A compact elapsed-time label for the activity log: `"820ms"`, `"43s"`, or
/// `"2m 5s"`.
fn human_duration(d: Duration) -> String {
let secs = d.as_secs();
if secs == 0 {
format!("{}ms", d.as_millis())
} else if secs < 60 {
format!("{secs}s")
} else {
format!("{}m {}s", secs / 60, secs % 60)
}
}
/// Wall clock in milliseconds since the epoch — the unit the persisted listings
/// stamp their freshness in.
fn now_ms() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
/// The aspect ratio (w/h) of an encoded image, read from its header alone — no
/// pixels are decoded. `None` when the format is unknown or the header is torn.
fn ratio_of(bytes: &[u8]) -> Option<f64> {
let (width, height) = image::ImageReader::new(std::io::Cursor::new(bytes))
.with_guessed_format()
.ok()?
.into_dimensions()
.ok()?;
(height > 0).then(|| f64::from(width) / f64::from(height))
}
/// A thumbnail the daemon made itself, and the aspect ratio of the photo it was
/// made from (free at that point — the full image had to be decoded anyway).
struct GeneratedThumb {
bytes: Vec<u8>,
ratio: f64,
}
/// How one attempt at generating a missing thumbnail ended. The distinction that
/// matters is *permanent* versus *transient*: only bytes we cannot decode prove
/// the photo will never have a thumbnail, and only that verdict is persisted.
enum ThumbAttempt {
Made(GeneratedThumb),
/// Decoded nothing — a format this build has no decoder for. Permanent.
Undecodable,
/// The photo couldn't be downloaded. Transient: try again next time.
Unavailable,
}
/// Scale a full-size photo down to a thumbnail: at most [`THUMB_EDGE`] on its
/// longest side, JPEG, aspect ratio preserved. `None` when the bytes aren't an
/// image this build can decode — the caller then writes the photo off as
/// un-thumbnailable.
///
/// CPU-bound (a 20 MP JPEG is real work), so callers run it on the blocking pool.
fn scale_thumbnail(bytes: &[u8]) -> Option<GeneratedThumb> {
let image = image::ImageReader::new(std::io::Cursor::new(bytes))
.with_guessed_format()
.ok()?
.decode()
.ok()?;
let (width, height) = (image.width(), image.height());
if width == 0 || height == 0 {
return None;
}
let ratio = f64::from(width) / f64::from(height);
// `thumbnail` fits the image *inside* the box, so the longest edge lands on
// THUMB_EDGE and the ratio is untouched.
let thumb = image.thumbnail(THUMB_EDGE, THUMB_EDGE).to_rgb8();
let mut bytes = Vec::new();
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, THUMB_QUALITY)
.encode_image(&thumb)
.ok()?;
Some(GeneratedThumb { bytes, ratio })
}
/// Convert a Unix timestamp (seconds, possibly negative) to a `SystemTime`.
fn unix_secs(secs: i64) -> SystemTime {
if secs >= 0 {
UNIX_EPOCH + Duration::from_secs(secs as u64)
} else {
UNIX_EPOCH - Duration::from_secs(secs.unsigned_abs())
}
}
impl Filesystem for ProtonFs {
fn lookup(&self, _req: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEntry) {
let parent = parent.0;
if let Err(e) = self.core.ensure_children(parent) {
reply.error(e);
return;
}
let name = name.to_string_lossy();
let st = self.core.state.lock().unwrap();
let hit = st.children.get(&parent).and_then(|kids| {
kids.iter().copied().find_map(|ino| {
st.entries
.get(&ino)
.filter(|e| e.node.name == name)
.map(|e| (ino, &e.node))
})
});
match hit {
Some((ino, node)) => {
let attr = self.attr(ino, node);
reply.entry(&TTL, &attr, Generation(0));
}
None => reply.error(Errno::ENOENT),
}
}
fn getattr(&self, _req: &Request, ino: INodeNo, _fh: Option<FileHandle>, reply: ReplyAttr) {
let st = self.core.state.lock().unwrap();
match st.entries.get(&ino.0) {
Some(e) => {
let attr = self.attr(ino.0, &e.node);
reply.attr(&TTL, &attr);
}
None => reply.error(Errno::ENOENT),
}
}
fn readdir(
&self,
_req: &Request,
ino: INodeNo,
_fh: FileHandle,
offset: u64,
mut reply: ReplyDirectory,
) {
let ino = ino.0;
if let Err(e) = self.core.ensure_children(ino) {
reply.error(e);
return;
}
let st = self.core.state.lock().unwrap();
let parent = st.entries.get(&ino).map_or(ROOT_INO, |e| e.parent);
// "." and ".." occupy offsets 0 and 1; real children follow.
let mut listing: Vec<(u64, FileType, String)> = vec![
(ino, FileType::Directory, ".".to_string()),
(parent, FileType::Directory, "..".to_string()),
];
if let Some(kids) = st.children.get(&ino) {
for &kid in kids {
if let Some(e) = st.entries.get(&kid) {
let ft = if e.node.is_folder() {
FileType::Directory
} else {
FileType::RegularFile
};
listing.push((kid, ft, e.node.name.clone()));
}
}
}
drop(st);
for (i, (ino, ft, name)) in listing.into_iter().enumerate().skip(offset as usize) {
// The stored offset is that of the *next* entry to resume at.
if reply.add(INodeNo(ino), (i + 1) as u64, ft, &name) {
break;
}
}
reply.ok();
}
fn open(&self, _req: &Request, ino: INodeNo, flags: OpenFlags, reply: ReplyOpen) {
let (uid, base_mtime, base_size) = {
let st = self.core.state.lock().unwrap();
match st.entries.get(&ino.0) {
Some(e) if e.node.is_file() => {
(e.uid.clone(), e.node.modification_time, node_size(&e.node))
}
Some(_) => {
reply.error(Errno::EISDIR);
return;
}
None => {
reply.error(Errno::ENOENT);
return;
}
}
};
// Read-only opens stay stateless (fh 0). A write open allocates a
// disk-backed handle; bytes are authored into a scratch file and the
// untouched remainder is pulled from the base lazily (on read / commit).
if flags.acc_mode() == OpenAccMode::O_RDONLY {
reply.opened(FileHandle(0), FopenFlags::empty());
return;
}
let (file, path) = match self.core.cache.create_scratch() {
Ok(x) => x,
Err(e) => {
error!(%uid, error = %e, "create scratch file failed");
reply.error(Errno::EIO);
return;
}
};
let mut st = self.core.state.lock().unwrap();
let fh = st.next_fh;
st.next_fh += 1;
st.handles.insert(
fh,
WriteHandle {
ino: ino.0,
uid,
file: Arc::new(file),
path,
written: Intervals::default(),
// Starts at the current size; reads in [0, base_size) come from
// the base until overwritten.
len: base_size,
base_size,
base_mtime,
dirty: false,
},
);
reply.opened(FileHandle(fh), FopenFlags::empty());
}
fn read(
&self,
_req: &Request,
ino: INodeNo,
_fh: FileHandle,
offset: u64,
size: u32,
_flags: OpenFlags,
_lock_owner: Option<LockOwner>,
reply: ReplyData,
) {
// A file open for writing is served from its handle so reads see the
// in-flight (possibly unsaved) content: authored bytes come from the
// scratch file, untouched bytes from the base.
let handle = {
let st = self.core.state.lock().unwrap();
st.handles.values().find(|h| h.ino == ino.0).map(|h| {
(
h.file.clone(),
h.len,
h.uid.clone(),
h.base_mtime,
h.base_size,
h.written.clone(),
)
})
};
if let Some((file, len, uid, base_mtime, base_size, written)) = handle {
match self.core.serve_open_read(
&file,
len,
&uid,
base_mtime,
base_size,
&written,
offset,
size as u64,
) {
Ok(bytes) => reply.data(&bytes),
Err(e) => reply.error(e),
}
return;
}
let (uid, mtime, fsize) = {
let st = self.core.state.lock().unwrap();
match st.entries.get(&ino.0) {
Some(e) if e.node.is_file() => {
(e.uid.clone(), e.node.modification_time, node_size(&e.node))
}
Some(_) => {
reply.error(Errno::EISDIR);
return;
}
None => {
reply.error(Errno::ENOENT);
return;
}
}
};
match self
.core
.read_range(&uid, mtime, fsize, offset, size as u64)
{
Ok(bytes) => reply.data(&bytes),
Err(e) => reply.error(e),
}
}
fn create(
&self,
_req: &Request,
parent: INodeNo,
name: &OsStr,
_mode: u32,
_umask: u32,
_flags: i32,
reply: ReplyCreate,
) {
let parent = parent.0;
if let Err(e) = self.core.ensure_children(parent) {
reply.error(e);
return;
}
let name = name.to_string_lossy().into_owned();
let parent_uid = {
let st = self.core.state.lock().unwrap();
match st.entries.get(&parent) {
Some(e) => e.uid.clone(),
None => {
reply.error(Errno::ENOENT);
return;
}
}
};
// Create an empty file on the remote so it has a real uid immediately;
// written bytes are buffered and sealed as a new revision on close.
let new_uid = match self.core.rt.block_on(self.core.client.upload_file(
&parent_uid,
&name,
media_type_for(&name),
b"",
)) {
Ok(u) => u,
Err(e) => {
error!(%parent_uid, name, error = %e, "create file failed");
reply.error(Errno::EIO);
return;
}
};
let node = match self.core.fetch_node(&new_uid) {
Ok(n) => n,
Err(e) => {
reply.error(e);
return;
}
};
let (file, path) = match self.core.cache.create_scratch() {
Ok(x) => x,
Err(e) => {
error!(%new_uid, error = %e, "create scratch file failed");
reply.error(Errno::EIO);
return;
}
};
let mut st = self.core.state.lock().unwrap();
let ino = st.intern(parent, node);
if let Some(kids) = st.children.get_mut(&parent)
&& !kids.contains(&ino)
{
kids.push(ino);
}
let fh = st.next_fh;
st.next_fh += 1;
st.handles.insert(
fh,
// A brand-new file: empty base, everything written is authored.
WriteHandle {
ino,
uid: new_uid,
file: Arc::new(file),
path,
written: Intervals::default(),
len: 0,
base_size: 0,
base_mtime: now_secs(),
dirty: false,
},
);
let attr = self.attr(ino, &st.entries.get(&ino).unwrap().node);
reply.created(
&TTL,
&attr,
Generation(0),
FileHandle(fh),
FopenFlags::empty(),
);
}
fn write(
&self,
_req: &Request,
ino: INodeNo,
fh: FileHandle,
offset: u64,
data: &[u8],
_write_flags: WriteFlags,
_flags: OpenFlags,
_lock_owner: Option<LockOwner>,
reply: ReplyWrite,
) {
let fh = fh.0;
// Stage the bytes straight into the scratch file (no base download): only
// the untouched remainder is pulled from the remote, and only at commit.
let file = {
let st = self.core.state.lock().unwrap();
match st.handles.get(&fh) {
Some(h) => h.file.clone(),
None => {
reply.error(Errno::EBADF);
return;
}
}
};
if let Err(e) = file.write_all_at(data, offset) {
error!(ino = ino.0, fh, error = %e, "scratch write failed");
reply.error(Errno::EIO);
return;
}
let new_len = {
let mut st = self.core.state.lock().unwrap();
let Some(h) = st.handles.get_mut(&fh) else {
reply.error(Errno::EBADF);
return;
};
let end = offset + data.len() as u64;
h.written.add(offset, end);
h.len = h.len.max(end);
h.dirty = true;
let len = h.len;
st.set_size(ino.0, len);
len
};
debug!(
ino = ino.0,
fh,
offset,
len = data.len(),
new_len,
"staged write"
);
reply.written(data.len() as u32);
}
#[allow(clippy::too_many_arguments)]
fn setattr(
&self,
_req: &Request,
ino: INodeNo,
_mode: Option<u32>,
_uid: Option<u32>,
_gid: Option<u32>,
size: Option<u64>,
_atime: Option<TimeOrNow>,
_mtime: Option<TimeOrNow>,
_ctime: Option<SystemTime>,
fh: Option<FileHandle>,
_crtime: Option<SystemTime>,
_chgtime: Option<SystemTime>,
_bkuptime: Option<SystemTime>,
_flags: Option<BsdFileFlags>,
reply: ReplyAttr,
) {
// Only resizes change remote state; everything else (mode/owner/times) is
// accepted and ignored so tools that chmod/utimes after writing succeed.
if let Some(size) = size {
let fh = fh.map(|f| f.0).filter(|&f| f != 0);
let handled = match fh {
Some(fh) => {
let mut st = self.core.state.lock().unwrap();
match st.handles.get_mut(&fh) {
Some(h) => {
if size < h.len {
// Shrink: drop authored ranges past the new end.
h.written.clip(size);
} else if size > h.len {
// Grow: the new tail is defined as zeros, so claim
// it as authored rather than base content.
h.written.add(h.len, size);
}
let _ = h.file.set_len(size);
h.len = size;
h.dirty = true;
true
}
None => false,
}
}
None => false,
};
if !handled {
// Path-based truncate with no open write handle: resize the
// remote content and seal it now.
let uid = {
let st = self.core.state.lock().unwrap();
match st.entries.get(&ino.0) {
Some(e) if e.node.is_file() => e.uid.clone(),
Some(_) => {
reply.error(Errno::EISDIR);
return;
}
None => {
reply.error(Errno::ENOENT);
return;
}
}
};
let mut content = if size == 0 {
Vec::new()
} else {
match self.core.rt.block_on(self.core.client.download_file(&uid)) {
Ok(b) => b,
Err(e) => {
warn!(%uid, error = %e, "truncate base download failed");
reply.error(Errno::EIO);
return;
}
}
};
content.resize(size as usize, 0);
if let Err(e) = self
.core
.rt
.block_on(self.core.client.upload_new_revision(&uid, &content))
{
error!(%uid, error = %e, "truncate upload failed");
reply.error(Errno::EIO);
return;
}
// Keep any pinned cache consistent with the new content.
if self.core.cache.is_pinned(&uid) {
let _ = self.core.cache.store(&uid, now_secs(), size, &content);
} else {
self.core.cache.evict(&uid);
}
}
self.core.state.lock().unwrap().set_size(ino.0, size);
}
let st = self.core.state.lock().unwrap();
match st.entries.get(&ino.0) {
Some(e) => {
let attr = self.attr(ino.0, &e.node);
reply.attr(&TTL, &attr);
}
None => reply.error(Errno::ENOENT),
}
}
fn flush(
&self,
_req: &Request,
_ino: INodeNo,
fh: FileHandle,
_lock_owner: LockOwner,
reply: ReplyEmpty,
) {
match self.core.commit(fh.0) {
Ok(()) => reply.ok(),
Err(e) => reply.error(e),
}
}
fn fsync(
&self,
_req: &Request,
_ino: INodeNo,
fh: FileHandle,
_datasync: bool,
reply: ReplyEmpty,
) {
match self.core.commit(fh.0) {
Ok(()) => reply.ok(),
Err(e) => reply.error(e),
}
}
fn release(
&self,
_req: &Request,
_ino: INodeNo,
fh: FileHandle,
_flags: OpenFlags,
_lock_owner: Option<LockOwner>,
_flush: bool,
reply: ReplyEmpty,
) {
let res = self.core.commit(fh.0);
let handle = self.core.state.lock().unwrap().handles.remove(&fh.0);
if let Some(h) = handle {
let _ = std::fs::remove_file(&h.path);
}
match res {
Ok(()) => reply.ok(),
Err(e) => reply.error(e),
}
}
fn unlink(&self, _req: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEmpty) {
self.trash_child(parent.0, &name.to_string_lossy(), reply);
}
fn rmdir(&self, _req: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEmpty) {
self.trash_child(parent.0, &name.to_string_lossy(), reply);
}
fn mkdir(
&self,
_req: &Request,
parent: INodeNo,
name: &OsStr,
_mode: u32,
_umask: u32,
reply: ReplyEntry,
) {
let parent = parent.0;
if let Err(e) = self.core.ensure_children(parent) {
reply.error(e);
return;
}
let name = name.to_string_lossy().into_owned();
let parent_uid = {
let st = self.core.state.lock().unwrap();
match st.entries.get(&parent) {
Some(e) => e.uid.clone(),
None => {
reply.error(Errno::ENOENT);
return;
}
}
};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.ok();
let new_uid =
match self
.core
.rt
.block_on(self.core.client.create_folder(&parent_uid, &name, now))
{
Ok(u) => u,
Err(e) => {
error!(%parent_uid, name, error = %e, "create folder failed");
reply.error(Errno::EIO);
return;
}
};
let node = match self.core.fetch_node(&new_uid) {
Ok(n) => n,
Err(e) => {
reply.error(e);
return;
}
};
let mut st = self.core.state.lock().unwrap();
let ino = st.intern(parent, node);
if let Some(kids) = st.children.get_mut(&parent)
&& !kids.contains(&ino)
{
kids.push(ino);
}
let attr = self.attr(ino, &st.entries.get(&ino).unwrap().node);
reply.entry(&TTL, &attr, Generation(0));
}
fn rename(
&self,
_req: &Request,
parent: INodeNo,
name: &OsStr,
newparent: INodeNo,
newname: &OsStr,
_flags: RenameFlags,
reply: ReplyEmpty,
) {
let parent = parent.0;
let newparent = newparent.0;
let name = name.to_string_lossy().into_owned();
let newname = newname.to_string_lossy().into_owned();
let (_ino, uid) = match self.core.lookup_child(parent, &name) {
Ok(x) => x,
Err(e) => {
reply.error(e);
return;
}
};
// Move first if the parent changed, then rename if the name changed.
if newparent != parent {
if let Err(e) = self.core.ensure_children(newparent) {
reply.error(e);
return;
}
let new_parent_uid = {
let st = self.core.state.lock().unwrap();
match st.entries.get(&newparent) {
Some(e) => e.uid.clone(),
None => {
reply.error(Errno::ENOENT);
return;
}
}
};
if let Err(e) = self
.core
.rt
.block_on(self.core.client.move_node(&uid, &new_parent_uid))
{
error!(%uid, error = %e, "move failed");
reply.error(Errno::EIO);
return;
}
}
if newname != name
&& let Err(e) = self
.core
.rt
.block_on(self.core.client.rename_node(&uid, &newname, None))
{
error!(%uid, error = %e, "rename failed");
reply.error(Errno::EIO);
return;
}
// Forget the node so it re-interns under its new parent, and drop the
// destination listing so it re-enumerates on next access.
let mut st = self.core.state.lock().unwrap();
st.forget(&uid);
st.children.remove(&newparent);
reply.ok();
}
/// Expose a file's server-side thumbnail/preview as an extended attribute, so
/// a previewing client can fetch it without downloading the whole file. The
/// bytes are fetched on demand and cached; absence yields `ENODATA`.
fn getxattr(&self, _req: &Request, ino: INodeNo, name: &OsStr, size: u32, reply: ReplyXattr) {
let ttype = match name.to_str() {
Some(XATTR_THUMBNAIL) => ThumbnailType::Thumbnail,
Some(XATTR_PREVIEW) => ThumbnailType::Preview,
// Any other attribute simply does not exist on this filesystem.
_ => {
reply.error(Errno::ENODATA);
return;
}
};
let bytes = match self.core.thumbnail(ino.0, ttype) {
Ok(Some(b)) => b,
Ok(None) => {
reply.error(Errno::ENODATA);
return;
}
Err(e) => {
reply.error(e);
return;
}
};
let len = bytes.len() as u32;
// The xattr protocol: a zero `size` is a probe for the length; otherwise
// the caller's buffer must be large enough or it gets `ERANGE`.
if size == 0 {
reply.size(len);
} else if size < len {
reply.error(Errno::ERANGE);
} else {
reply.data(&bytes);
}
}
/// Advertise the thumbnail/preview attribute names for regular files. The
/// names are listed unconditionally for files; a `getxattr` for one a given
/// file lacks returns `ENODATA`.
fn listxattr(&self, _req: &Request, ino: INodeNo, size: u32, reply: ReplyXattr) {
let is_file = {
let st = self.core.state.lock().unwrap();
match st.entries.get(&ino.0) {
Some(e) => e.node.is_file(),
None => {
reply.error(Errno::ENOENT);
return;
}
}
};
// xattr names are returned as a NUL-terminated, concatenated list.
let mut buf = Vec::new();
if is_file {
for name in [XATTR_THUMBNAIL, XATTR_PREVIEW] {
buf.extend_from_slice(name.as_bytes());
buf.push(0);
}
}
let len = buf.len() as u32;
if size == 0 {
reply.size(len);
} else if size < len {
reply.error(Errno::ERANGE);
} else {
reply.data(&buf);
}
}
}
/// Apply one remote event to the local cache and notify the kernel so it drops
/// any stale cached metadata/data for the affected inodes.
///
/// The cache is authoritative-by-absence: dropping a directory's `children`
/// entry forces the next `lookup`/`readdir` to re-enumerate from the remote, so
/// most events only need to invalidate listings rather than re-fetch eagerly.
fn apply_event(
state: &Mutex<State>,
content: &ContentCache,
notifier: &Notifier,
event: &DriveEvent,
) {
match event {
DriveEvent::NodeUpdated {
node_uid,
parent_node_uid,
is_trashed,
..
} => {
let mut st = state.lock().unwrap();
if *is_trashed {
// Trashing makes a node vanish from its parent listing.
let child = st.by_uid.get(node_uid).copied();
if let Some((parent, name)) = st.forget(node_uid) {
content.evict(node_uid);
match child {
Some(child) => {
let _ =
notifier.delete(INodeNo(parent), INodeNo(child), OsStr::new(&name));
}
None => {
let _ = notifier.inval_entry(INodeNo(parent), OsStr::new(&name));
}
}
}
} else if let Some(&ino) = st.by_uid.get(node_uid) {
// Known node changed: drop its cached attrs/data (and listing if
// it is a directory) so the next access re-fetches. Its content
// blob may now be stale, so evict it too.
st.invalidate_listing(ino);
content.evict(node_uid);
let _ = notifier.inval_inode(INodeNo(ino), 0, 0);
}
// A create (or move-in) shows up as a change to the parent listing;
// drop it so the new child is picked up on the next readdir.
if let Some(parent_uid) = parent_node_uid
&& let Some(&parent) = st.by_uid.get(parent_uid)
{
st.invalidate_listing(parent);
let _ = notifier.inval_inode(INodeNo(parent), 0, 0);
}
}
DriveEvent::NodeDeleted { node_uid, .. } => {
let mut st = state.lock().unwrap();
// Capture the inode before `forget` clears the uid mapping.
let child = st.by_uid.get(node_uid).copied();
content.evict(node_uid);
if let Some((parent, name)) = st.forget(node_uid) {
match child {
Some(child) => {
let _ = notifier.delete(INodeNo(parent), INodeNo(child), OsStr::new(&name));
}
None => {
let _ = notifier.inval_entry(INodeNo(parent), OsStr::new(&name));
}
}
}
}
// Continuity or scope was lost: our cached listings may be arbitrarily
// stale, so drop every listing and tell the kernel to forget all
// metadata. Inodes stay stable; dirs simply re-enumerate on next access.
DriveEvent::ContinuityLost { .. } | DriveEvent::ScopeAccessLost { .. } => {
warn!("event continuity lost; dropping all cached listings, resyncing lazily");
let mut st = state.lock().unwrap();
let dirs: Vec<u64> = st.children.keys().copied().collect();
for &ino in &dirs {
st.invalidate_listing(ino);
let _ = notifier.inval_inode(INodeNo(ino), 0, 0);
}
}
// No substantive local change; the cursor advance is handled by the
// caller persisting the event id.
DriveEvent::CursorAdvanced { .. } | DriveEvent::SharedWithMeUpdated { .. } => {}
}
}
/// Poll the remote event cursor forever, applying each batch to the shared
/// state. Resumes from the cursor persisted in the DB so changes made while
/// unmounted are applied; only a first-ever mount seeds from the server head.
/// The cursor is persisted after every batch. Runs as a Tokio task; returns
/// only on fatal error.
async fn run_event_sync(
client: ProtonDriveClient,
scope: DriveEventScopeId,
state: Arc<Mutex<State>>,
content: Arc<ContentCache>,
db: Arc<Db>,
notifier: Notifier,
) {
let mut cursor: Option<DriveEventId> = match db.get_event_cursor() {
// Resume: pick up exactly where the last run left off.
Ok(Some(saved)) => Some(DriveEventId::from(saved)),
// First mount: a `None` cursor yields a single `CursorAdvanced` at the
// server head; persist it so the next restart resumes instead of
// reseeding (which would skip everything that changed offline).
Ok(None) => match client.enumerate_events(&scope, None).await {
Ok(events) => {
let head = events.last().map(|e| e.id().clone());
if let Some(c) = &head
&& let Err(e) = db.set_event_cursor(c.as_str())
{
warn!(error = %e, "persist seed cursor failed");
}
head
}
Err(e) => {
error!(error = %e, "failed to seed event cursor; live sync disabled");
return;
}
},
Err(e) => {
error!(error = %e, "read persisted cursor failed; live sync disabled");
return;
}
};
info!(?cursor, "event sync started");
loop {
tokio::time::sleep(POLL_INTERVAL).await;
let events = match client.enumerate_events(&scope, cursor.as_ref()).await {
Ok(events) => events,
Err(e) => {
warn!(error = %e, "event poll failed; retrying after interval");
continue;
}
};
if events.is_empty() {
continue;
}
debug!(count = events.len(), "applying remote events");
for event in &events {
apply_event(&state, &content, &notifier, event);
}
cursor = events.last().map(|e| e.id().clone());
if let Some(c) = &cursor
&& let Err(e) = db.set_event_cursor(c.as_str())
{
warn!(error = %e, "persist event cursor failed");
}
}
}
/// Keep the local-file index fresh for the launcher prompt's "This computer"
/// results. Rebuilds the index whenever it is older than [`LOCAL_INDEX_TTL`],
/// then sleeps; runs on its own thread for the life of the daemon.
///
/// The walk is the one part of the daemon that touches the wider filesystem, so
/// it is deliberately kept off every hot path: it never runs on a FUSE or
/// control-socket thread, and it excludes the mountpoint (walking it would fault
/// every remote node in through FUSE, defeating on-demand hydration).
fn run_local_index(
db: Arc<Db>,
indexing: Arc<AtomicBool>,
transfers: Arc<TransferRegistry>,
mountpoint: PathBuf,
) {
loop {
let age = db.local_indexed_at().ok().flatten();
let stale =
age.is_none_or(|at| now_secs().saturating_sub(at) >= LOCAL_INDEX_TTL.as_secs() as i64);
if stale {
scan_local_once(&db, &indexing, &transfers, &mountpoint);
}
std::thread::sleep(LOCAL_INDEX_CHECK);
}
}
/// Walk `$HOME` once and replace the local-file index with what it finds.
/// Batches stream straight into SQLite, so peak memory is one batch — not the
/// whole home directory.
///
/// Reports itself as a job: the first scan after a fresh install walks the whole
/// home directory, and `indexing` alone only tells the launcher prompt to say
/// "still indexing" — nothing else showed that the daemon was busy.
fn scan_local_once(
db: &Db,
indexing: &AtomicBool,
transfers: &Arc<TransferRegistry>,
mountpoint: &Path,
) {
let dirs = match AppDirs::new() {
Ok(d) => d,
Err(e) => {
warn!(error = %e, "local index: cannot resolve app dirs");
return;
}
};
let Some(home) = dirs.home_dir() else {
warn!("local index: cannot resolve home directory");
return;
};
let generation = match db.local_begin_scan() {
Ok(g) => g,
Err(e) => {
warn!(error = %e, "local index: cannot open scan generation");
return;
}
};
let excludes = localindex::default_excludes(mountpoint, &dirs.state_dir(), &dirs.cache_dir());
indexing.store(true, Ordering::Relaxed);
let started = Instant::now();
// The walk has no idea how many files it will find, so the job counts what it
// has seen and stays indeterminate.
let job = transfers.begin_job("Indexing this computer");
job.detail("Scanning your files");
let walked = localindex::scan(&[home], &excludes, |batch| {
if let Err(e) = db.local_upsert_batch(generation, &batch) {
warn!(error = %e, "local index: batch write failed");
}
});
// Prune what this scan did not see and rebuild the FTS index over the rest,
// even if some batches failed — a partial index still beats none.
job.detail("Building the search index");
match db.local_finish_scan(generation, now_secs()) {
Ok(indexed) => info!(
walked,
indexed,
elapsed_ms = started.elapsed().as_millis() as u64,
"local index rebuilt"
),
Err(e) => warn!(error = %e, "local index: finish failed"),
}
indexing.store(false, Ordering::Relaxed);
}
/// Turn a CLI-supplied path into a mountpoint-relative path. An absolute path
/// must live under `mountpoint`; a relative path is taken as already relative to
/// the mount root.
fn rel_to_mount(mountpoint: &Path, path: &str) -> Result<PathBuf, String> {
let p = Path::new(path);
if p.is_absolute() {
p.strip_prefix(mountpoint)
.map(Path::to_path_buf)
.map_err(|_| format!("{path} is not under the mountpoint"))
} else {
Ok(p.to_path_buf())
}
}
/// Handle one control-socket connection: read a single JSON request line,
/// dispatch it against `core`, and write back a JSON response line.
fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: UnixStream) {
let mut reader = BufReader::new(match stream.try_clone() {
Ok(s) => s,
Err(e) => {
warn!(error = %e, "control: clone stream failed");
return;
}
});
let mut line = String::new();
if reader.read_line(&mut line).is_err() || line.trim().is_empty() {
return;
}
let response = match serde_json::from_str::<CtlRequest>(line.trim()) {
Ok(CtlRequest::Status) => {
let pins = core.cache.list_pins();
CtlResponse::Status {
username: username.to_string(),
mountpoint: mountpoint.display().to_string(),
pinned: pins.len(),
used: core.cache.usage(),
budget: core.cache.budget(),
pins,
}
}
Ok(CtlRequest::Pin { path }) => match rel_to_mount(mountpoint, &path) {
Ok(rel) => match core.pin(&rel) {
Ok(name) => CtlResponse::Ok {
message: format!("pinned {name}"),
},
Err(e) => CtlResponse::Error { message: e },
},
Err(e) => CtlResponse::Error { message: e },
},
Ok(CtlRequest::Unpin { path }) => match rel_to_mount(mountpoint, &path) {
Ok(rel) => match core.unpin(&rel) {
Ok(name) => CtlResponse::Ok {
message: format!("unpinned {name}"),
},
Err(e) => CtlResponse::Error { message: e },
},
Err(e) => CtlResponse::Error { message: e },
},
Ok(CtlRequest::ListPins) => CtlResponse::Pins {
pins: core.cache.list_pins(),
},
Ok(CtlRequest::ListDir { path }) => match rel_to_mount(mountpoint, &path) {
Ok(rel) => match core.list_dir(&rel) {
Ok(entries) => CtlResponse::Entries { entries },
Err(e) => CtlResponse::Error { message: e },
},
Err(e) => CtlResponse::Error { message: e },
},
Ok(CtlRequest::PhotosTimeline { offset, limit }) => {
match core.photos_timeline(offset, limit) {
Ok(Some(items)) => CtlResponse::Photos {
available: true,
items,
},
Ok(None) => CtlResponse::Photos {
available: false,
items: Vec::new(),
},
Err(e) => CtlResponse::Error { message: e },
}
}
Ok(CtlRequest::PhotoThumbs { uids }) => {
let parsed: Vec<NodeUid> = uids.iter().filter_map(|u| parse_uid(u)).collect();
CtlResponse::Thumbs {
items: core.photo_thumbs(&parsed),
}
}
Ok(CtlRequest::OpenPhoto { uid }) => match parse_uid(&uid) {
Some(u) => match core.open_photo(&u) {
Ok(p) => CtlResponse::FilePath {
path: p.display().to_string(),
},
Err(e) => CtlResponse::Error { message: e },
},
None => CtlResponse::Error {
message: format!("bad uid: {uid}"),
},
},
Ok(CtlRequest::UploadPhoto {
name,
media_type,
bytes,
capture_time,
}) => {
let photos = core.photos();
let metadata = proton_drive_rs::PhotoUploadMetadata {
capture_time,
..Default::default()
};
let guard = core.transfers.begin(
name.clone(),
"",
TransferDirection::Upload,
bytes.len() as u64,
);
let reader = CountingReader::new(std::io::Cursor::new(&bytes), &guard);
match core.rt.block_on(photos.upload_photo_from(
&name,
&media_type,
reader,
bytes.len() as i64,
Vec::new(),
metadata,
false,
)) {
Ok(uid) => {
// The photo we just uploaded belongs at the head of the
// timeline, and the GUI reloads the gallery the moment this
// reply lands — so refresh now rather than leaving it to a
// background pass that would land just after that reload.
if let Err(e) = core.rt.block_on(core.refresh_timeline()) {
warn!(error = %e, "timeline refresh after upload failed");
}
CtlResponse::Ok {
message: format!("uploaded photo with uid {uid}"),
}
}
Err(e) => CtlResponse::Error {
message: format!("upload photo failed: {e}"),
},
}
}
Ok(CtlRequest::OpenFile { path }) => match rel_to_mount(mountpoint, &path) {
Ok(rel) => match core.open_file(&rel) {
Ok(p) => CtlResponse::FilePath {
path: p.display().to_string(),
},
Err(e) => CtlResponse::Error { message: e },
},
Err(e) => CtlResponse::Error { message: e },
},
Ok(CtlRequest::Search { query, limit }) => match core.search(&query, limit) {
Ok(hits) => CtlResponse::SearchResults { hits },
Err(e) => CtlResponse::Error { message: e },
},
Ok(CtlRequest::SearchLocal { query, limit }) => match core.search_local(&query, limit) {
Ok(hits) => CtlResponse::LocalResults {
hits,
indexing: core.indexing.load(Ordering::Relaxed),
},
Err(e) => CtlResponse::Error { message: e },
},
Ok(CtlRequest::Rename { path, new_name }) => match rel_to_mount(mountpoint, &path) {
Ok(rel) => match core.rename(&rel, &new_name) {
Ok(name) => {
core.log_activity(ActivityKind::Rename, &name, format!("was {path}"), true);
CtlResponse::Ok {
message: format!("renamed to {name}"),
}
}
Err(e) => {
core.log_activity(ActivityKind::Rename, &path, &e, false);
CtlResponse::Error { message: e }
}
},
Err(e) => CtlResponse::Error { message: e },
},
Ok(CtlRequest::Move { path, new_parent }) => {
match (
rel_to_mount(mountpoint, &path),
rel_to_mount(mountpoint, &new_parent),
) {
(Ok(rel), Ok(parent_rel)) => match core.move_to(&rel, &parent_rel) {
Ok(name) => {
let dest = if new_parent.is_empty() {
"My files".to_string()
} else {
new_parent.clone()
};
core.log_activity(ActivityKind::Move, &name, format!("to {dest}"), true);
CtlResponse::Ok {
message: format!("moved {name}"),
}
}
Err(e) => {
core.log_activity(ActivityKind::Move, &path, &e, false);
CtlResponse::Error { message: e }
}
},
(Err(e), _) | (_, Err(e)) => CtlResponse::Error { message: e },
}
}
Ok(CtlRequest::Delete { path }) => match rel_to_mount(mountpoint, &path) {
Ok(rel) => match core.delete(&rel) {
Ok(name) => {
core.log_activity(ActivityKind::Trash, &name, "", true);
CtlResponse::Ok {
message: format!("trashed {name}"),
}
}
Err(e) => {
core.log_activity(ActivityKind::Trash, &path, &e, false);
CtlResponse::Error { message: e }
}
},
Err(e) => CtlResponse::Error { message: e },
},
Ok(CtlRequest::CreateFolder { parent, name }) => match rel_to_mount(mountpoint, &parent) {
Ok(parent_rel) => match core.create_folder(&parent_rel, &name) {
Ok(name) => {
core.log_activity(ActivityKind::CreateFolder, &name, "", true);
CtlResponse::Ok {
message: format!("created folder {name}"),
}
}
Err(e) => {
core.log_activity(ActivityKind::CreateFolder, &name, &e, false);
CtlResponse::Error { message: e }
}
},
Err(e) => CtlResponse::Error { message: e },
},
Ok(CtlRequest::UploadFile {
parent,
name,
bytes,
}) => match rel_to_mount(mountpoint, &parent) {
Ok(parent_rel) => match core.upload(&parent_rel, &name, &bytes) {
Ok(name) => {
core.log_activity(ActivityKind::Upload, &name, "", true);
CtlResponse::Ok {
message: format!("uploaded {name}"),
}
}
Err(e) => {
core.log_activity(ActivityKind::Upload, &name, &e, false);
CtlResponse::Error { message: e }
}
},
Err(e) => CtlResponse::Error { message: e },
},
Ok(CtlRequest::UploadPaths { parent, sources }) => {
match rel_to_mount(mountpoint, &parent) {
// Ack immediately and upload on a background thread: a directory tree
// far outlasts the socket read timeout. Progress and completion are
// observed through GetQueueStatus; the activity log gets the summary
// when the whole batch finishes.
Ok(parent_rel) => {
let core = core.clone();
let paths: Vec<PathBuf> = sources.into_iter().map(PathBuf::from).collect();
let n = paths.len();
std::thread::spawn(move || {
let started = Instant::now();
match core.upload_paths(&parent_rel, &paths) {
Ok(stats) => {
// e.g. "Uploaded 700 files to Photos" — name the destination
// so the log reads like a sentence rather than a bare count.
let dest = parent_rel
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("your Drive");
let target = format!(
"{} to {dest}",
count_noun(stats.uploaded, "file", "files")
);
// Size · folders · elapsed, with a trailing failure count
// when some files didn't make it.
let mut parts = vec![human_bytes(stats.bytes)];
if stats.folders > 0 {
parts.push(count_noun(stats.folders, "folder", "folders"));
}
parts.push(human_duration(started.elapsed()));
if stats.failed > 0 {
parts.push(format!("{} failed", stats.failed));
}
core.log_activity(
ActivityKind::Upload,
target,
parts.join(" · "),
stats.failed == 0,
);
}
Err(e) => {
warn!(error = %e, "bulk upload failed");
core.log_activity(ActivityKind::Upload, "bulk upload", &e, false);
}
}
});
CtlResponse::Ok {
message: format!("uploading {n} item(s)"),
}
}
Err(e) => CtlResponse::Error { message: e },
}
}
Ok(CtlRequest::ListTrash) => match core.list_trash() {
Ok(entries) => CtlResponse::Entries { entries },
Err(e) => CtlResponse::Error { message: e },
},
Ok(CtlRequest::Restore { uids }) => match core.restore(&uids) {
Ok(n) => {
core.log_activity(ActivityKind::Restore, format!("{n} item(s)"), "", true);
CtlResponse::Ok {
message: format!("restored {n} item(s)"),
}
}
Err(e) => CtlResponse::Error { message: e },
},
Ok(CtlRequest::DeleteForever { uids }) => match core.delete_forever(&uids) {
Ok(n) => {
core.log_activity(
ActivityKind::DeleteForever,
format!("{n} item(s)"),
"",
true,
);
CtlResponse::Ok {
message: format!("permanently deleted {n} item(s)"),
}
}
Err(e) => CtlResponse::Error { message: e },
},
Ok(CtlRequest::EmptyTrash) => match core.empty_trash() {
Ok(n) => {
core.log_activity(ActivityKind::EmptyTrash, format!("{n} item(s)"), "", true);
CtlResponse::Ok {
message: format!("emptied trash ({n} item(s))"),
}
}
Err(e) => CtlResponse::Error { message: e },
},
Ok(CtlRequest::PurgeCache) => {
let freed = core.cache.clear_unpinned();
CtlResponse::Ok {
message: format!(
"purged {:.1} MiB of unpinned cache",
freed as f64 / 1_048_576.0
),
}
}
Ok(CtlRequest::GetQueueStatus) => CtlResponse::Transfers {
items: core.transfers.snapshot(),
jobs: core.jobs_snapshot(),
},
Ok(CtlRequest::SetCacheBudget { bytes }) => {
core.cache.set_budget(bytes);
// Persist so the next mount keeps the new cap. Best-effort: a config
// write failure is reported but the live cap is already applied.
match AppDirs::new().map(|dirs| {
let mut cfg = dirs.load_config();
cfg.cache_budget = Some(bytes);
dirs.save_config(&cfg)
}) {
Ok(Ok(())) => CtlResponse::Ok {
message: format!("cache budget set to {bytes} bytes"),
},
Ok(Err(e)) => CtlResponse::Error {
message: format!("budget applied but config write failed: {e}"),
},
Err(e) => CtlResponse::Error {
message: format!("budget applied but config unavailable: {e}"),
},
}
}
Ok(CtlRequest::ListDevices) => match core.list_devices() {
Ok(items) => CtlResponse::Devices { items },
Err(e) => CtlResponse::Error { message: e },
},
Ok(CtlRequest::RenameDevice { uid, name }) => match core.rename_device(&uid, &name) {
Ok(()) => CtlResponse::Ok {
message: format!("renamed device to {name}"),
},
Err(e) => CtlResponse::Error { message: e },
},
Ok(CtlRequest::DeleteDevice { uid }) => match core.delete_device(&uid) {
Ok(()) => CtlResponse::Ok {
message: "device deleted".to_string(),
},
Err(e) => CtlResponse::Error { message: e },
},
Ok(CtlRequest::AddSyncFolder { local_path }) => {
// Registering the device and uploading a folder tree far outlasts the
// socket read timeout, so ack immediately and work on a background
// thread. The folder appears in ListSyncFolders once the row lands;
// completion (and any failures) go to the activity log.
let core = core.clone();
let path = PathBuf::from(&local_path);
std::thread::spawn(move || {
let started = Instant::now();
match core.add_sync_folder(&path) {
Ok(folder) => {
let name = Path::new(&folder.local_path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(&folder.local_path)
.to_string();
core.log_activity(
ActivityKind::Upload,
format!("synced {name}"),
human_duration(started.elapsed()),
folder.state != "error",
);
}
Err(e) => {
warn!(error = %e, "add sync folder failed");
core.log_activity(ActivityKind::Upload, "add sync folder", &e, false);
}
}
});
CtlResponse::Ok {
message: format!("adding {local_path}"),
}
}
Ok(CtlRequest::ListSyncFolders) => match core.list_sync_folders() {
Ok(items) => CtlResponse::SyncFolders { items },
Err(e) => CtlResponse::Error { message: e },
},
Ok(CtlRequest::RemoveSyncFolder { id, delete_remote }) => {
match core.remove_sync_folder(id, delete_remote) {
Ok(()) => CtlResponse::Ok {
message: "removed synced folder".to_string(),
},
Err(e) => CtlResponse::Error { message: e },
}
}
Ok(CtlRequest::SetSyncFolderMode { id, mode }) => {
match core.set_sync_folder_mode(id, &mode) {
Ok(message) => {
core.log_activity(ActivityKind::Upload, &message, "", true);
CtlResponse::Ok { message }
}
Err(e) => CtlResponse::Error { message: e },
}
}
Ok(CtlRequest::SyncNow { id }) => {
core.sync_now(id);
CtlResponse::Ok {
message: match id {
Some(id) => format!("reconciling folder {id}"),
None => "reconciling all folders".to_string(),
},
}
}
Ok(CtlRequest::ShareNode {
path,
emails,
role,
message,
}) => match rel_to_mount(mountpoint, &path) {
Ok(rel) => match core.share_node(&rel, &emails, &role, message.as_deref()) {
Ok((proton, external)) => {
core.log_activity(
ActivityKind::Share,
&path,
format!("{} recipient(s) as {role}", proton + external),
true,
);
CtlResponse::Ok {
message: format!("invited {proton} Proton and {external} external user(s)"),
}
}
Err(e) => {
core.log_activity(ActivityKind::Share, &path, &e, false);
CtlResponse::Error { message: e }
}
},
Err(e) => CtlResponse::Error { message: e },
},
Ok(CtlRequest::ListShare { path }) => match rel_to_mount(mountpoint, &path) {
Ok(rel) => match core.list_share(&rel) {
Ok((entries, link)) => CtlResponse::Share { entries, link },
Err(e) => CtlResponse::Error { message: e },
},
Err(e) => CtlResponse::Error { message: e },
},
Ok(CtlRequest::UpdateShareRole {
path,
id,
kind,
role,
}) => match rel_to_mount(mountpoint, &path) {
Ok(rel) => match core.update_share_role(&rel, &id, kind, &role) {
Ok(()) => CtlResponse::Ok {
message: format!("role updated to {role}"),
},
Err(e) => CtlResponse::Error { message: e },
},
Err(e) => CtlResponse::Error { message: e },
},
Ok(CtlRequest::RemoveShareEntry { path, id, kind }) => {
match rel_to_mount(mountpoint, &path) {
Ok(rel) => match core.remove_share_entry(&rel, &id, kind) {
Ok(()) => {
core.log_activity(ActivityKind::Unshare, &path, "access removed", true);
CtlResponse::Ok {
message: "removed".to_string(),
}
}
Err(e) => CtlResponse::Error { message: e },
},
Err(e) => CtlResponse::Error { message: e },
}
}
Ok(CtlRequest::CreatePublicLink {
path,
role,
password,
expires,
}) => match rel_to_mount(mountpoint, &path) {
Ok(rel) => match core.create_public_link(&rel, &role, password.as_deref(), expires) {
Ok(link) => {
core.log_activity(ActivityKind::PublicLink, &path, "link created", true);
CtlResponse::PublicLink { link }
}
Err(e) => {
core.log_activity(ActivityKind::PublicLink, &path, &e, false);
CtlResponse::Error { message: e }
}
},
Err(e) => CtlResponse::Error { message: e },
},
Ok(CtlRequest::RemovePublicLink { path, id }) => match rel_to_mount(mountpoint, &path) {
Ok(rel) => match core.remove_public_link(&rel, &id) {
Ok(()) => {
core.log_activity(ActivityKind::Unshare, &path, "link removed", true);
CtlResponse::Ok {
message: "public link removed".to_string(),
}
}
Err(e) => CtlResponse::Error { message: e },
},
Err(e) => CtlResponse::Error { message: e },
},
Ok(CtlRequest::ListSharedWithMe) => match core.list_shared_with_me() {
Ok(entries) => CtlResponse::Entries { entries },
Err(e) => CtlResponse::Error { message: e },
},
Ok(CtlRequest::LeaveShared { uid }) => match core.leave_shared(&uid) {
Ok(()) => {
core.log_activity(ActivityKind::Unshare, "shared item", "left", true);
CtlResponse::Ok {
message: "left shared item".to_string(),
}
}
Err(e) => CtlResponse::Error { message: e },
},
Ok(CtlRequest::ListInvitations) => match core.list_invitations() {
Ok(items) => CtlResponse::Invitations { items },
Err(e) => CtlResponse::Error { message: e },
},
Ok(CtlRequest::AcceptInvitation { id }) => match core.accept_invitation(&id) {
Ok(()) => CtlResponse::Ok {
message: "invitation accepted".to_string(),
},
Err(e) => CtlResponse::Error { message: e },
},
Ok(CtlRequest::RejectInvitation { id }) => match core.reject_invitation(&id) {
Ok(()) => CtlResponse::Ok {
message: "invitation rejected".to_string(),
},
Err(e) => CtlResponse::Error { message: e },
},
Ok(CtlRequest::ListBookmarks) => match core.list_bookmarks() {
Ok(items) => CtlResponse::Bookmarks { items },
Err(e) => CtlResponse::Error { message: e },
},
Ok(CtlRequest::CreateBookmark { url, password }) => {
match core.create_bookmark(&url, password.as_deref()) {
Ok(()) => CtlResponse::Ok {
message: "bookmark saved".to_string(),
},
Err(e) => CtlResponse::Error { message: e },
}
}
Ok(CtlRequest::DeleteBookmark { token }) => match core.delete_bookmark(&token) {
Ok(()) => CtlResponse::Ok {
message: "bookmark removed".to_string(),
},
Err(e) => CtlResponse::Error { message: e },
},
Ok(CtlRequest::ListSharedByMe) => match core.list_shared_by_me() {
Ok(items) => CtlResponse::SharedByMe { items },
Err(e) => CtlResponse::Error { message: e },
},
Ok(CtlRequest::ListActivity { limit }) => CtlResponse::Activity {
items: core.list_activity(limit),
},
Err(e) => CtlResponse::Error {
message: format!("bad request: {e}"),
},
};
let mut out = match serde_json::to_vec(&response) {
Ok(v) => v,
Err(e) => {
warn!(error = %e, "control: serialize response failed");
return;
}
};
out.push(b'\n');
let mut stream = stream;
let _ = stream.write_all(&out);
}
/// Listen on the control socket, serving one command per connection, each on its
/// own thread. Runs on its own thread; returns only if the listener itself fails.
///
/// Concurrent rather than serial because requests differ wildly in cost: an
/// `OpenPhoto` downloads a whole photo, and while it ran the accept loop used to
/// stall every other caller behind it — the GUI's 2s status poll, and the
/// thumbnail batches the gallery needs to paint. [`Core`] is a bundle of handles
/// (`Arc`/`Clone`), so each connection gets its own copy of it.
fn run_control_socket(core: Core, username: String, mountpoint: PathBuf, listener: UnixListener) {
info!("control socket listening");
for conn in listener.incoming() {
match conn {
Ok(stream) => {
let core = core.clone();
let username = username.clone();
let mountpoint = mountpoint.clone();
if let Err(e) = std::thread::Builder::new()
.name("pdfs-control".into())
.spawn(move || handle_control_conn(&core, &username, &mountpoint, stream))
{
warn!(error = %e, "control: spawn handler failed");
}
}
Err(e) => {
warn!(error = %e, "control: accept failed");
}
}
}
}
/// Why a [`mount`] call returned. Lets the daemon decide whether to exit (clean
/// shutdown) or remount (the mount went away under it).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MountOutcome {
/// The daemon was asked to stop (SIGTERM/SIGINT) and we lazily unmounted
/// ourselves. The caller should exit cleanly.
Shutdown,
/// The kernel mount ended on its own (e.g. an external `fusermount -u`).
/// The caller may want to remount.
Unmounted,
}
/// Best-effort teardown of a stale mount left behind by a crashed daemon. A
/// previous run that died without unmounting leaves the kernel mount in place,
/// so the fresh `Session::new` below would fail with EBUSY ("Device or resource
/// busy"). `fusermount3 -u -z` is the lazy (detach) unmount, which succeeds even
/// when the old mount is still busy. Swallow all output/errors: if there is no
/// stale mount this is simply a no-op.
fn clear_stale_mount(mountpoint: &Path) {
for bin in ["fusermount3", "fusermount"] {
let ok = std::process::Command::new(bin)
.arg("-u")
.arg("-z")
.arg(mountpoint)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
if ok {
info!(mountpoint = %mountpoint.display(), "cleared stale mount before remount");
return;
}
}
}
/// Mount the filesystem at `mountpoint` and block until it is unmounted or the
/// daemon is asked to stop.
///
/// Fetches the My Files root up front (so an auth/network failure surfaces
/// before the kernel mount), then spawns the Phase 2 event-sync task, the
/// Phase 4 control socket, and runs the FUSE session on its own thread while
/// this thread waits for either a stop signal (SIGTERM/SIGINT) or the kernel
/// mount ending on its own. On a stop signal we lazily unmount ourselves
/// (`umount_and_join`, the MNT_DETACH path that succeeds even while downloads
/// are in flight), so `systemctl --user stop` is always a clean teardown.
/// `rt` must be a handle to a *running* multi-threaded runtime.
pub fn mount(
client: ProtonDriveClient,
rt: tokio::runtime::Handle,
mountpoint: &Path,
cache: ContentCache,
control_socket: &Path,
db: Arc<Db>,
username: String,
) -> std::io::Result<MountOutcome> {
let root = rt
.block_on(client.get_my_files_folder())
.map_err(|e| std::io::Error::other(format!("fetch My Files root: {e}")))?;
let scope = root.tree_event_scope_id();
// The folder-sync engine (devices.md Phase 2) runs on its own thread and is
// nudged over this channel; the sender lives in Core so control-socket
// handlers can trigger reconciles.
let (sync_tx, sync_rx) = std::sync::mpsc::channel::<sync::SyncMsg>();
let core = Core {
client: client.clone(),
rt: rt.clone(),
state: Arc::new(Mutex::new(State {
entries: HashMap::new(),
by_uid: HashMap::new(),
children: HashMap::new(),
next_ino: 2,
handles: HashMap::new(),
next_fh: 1,
db: db.clone(),
})),
cache: Arc::new(cache),
db,
timeline_refreshing: Arc::new(AtomicBool::new(false)),
trash_refreshing: Arc::new(AtomicBool::new(false)),
thumb_gen: Arc::new(Mutex::new(HashSet::new())),
transfers: TransferRegistry::new(),
indexing: Arc::new(AtomicBool::new(false)),
sync_progress: Arc::new(Mutex::new(HashMap::new())),
sync_tx,
mounts: Arc::new(Mutex::new(HashMap::new())),
sync_locks: Arc::new(Mutex::new(HashMap::new())),
};
// Start the folder-sync engine. It watches every mirror folder, polls the
// remotes, and reconciles on its own thread — never in front of a FUSE call.
sync::spawn(core.clone(), sync_rx);
// Re-establish on-demand mounts left over from a previous run (devices.md
// Phase 4). On its own thread: each remount fetches a remote node, and we
// must not block the main mount below on the network.
{
let core = core.clone();
std::thread::Builder::new()
.name("pdfs-restore-ondemand".into())
.spawn(move || core.restore_ondemand_mounts())?;
}
// Keep the launcher prompt's "This computer" index fresh. Its own thread:
// the walk is I/O-heavy and must never sit in front of a FUSE callback.
{
let db = core.db.clone();
let indexing = core.indexing.clone();
let transfers = core.transfers.clone();
let mountpoint = mountpoint.to_path_buf();
std::thread::Builder::new()
.name("pdfs-localindex".into())
.spawn(move || run_local_index(db, indexing, transfers, mountpoint))?;
}
let mut config = Config::default();
config.mount_options = vec![
MountOption::FSName("protondrive".to_string()),
MountOption::Subtype("protondrive".to_string()),
MountOption::DefaultPermissions,
];
// A crashed previous run can leave the kernel mount in place, which makes
// the fresh mount below fail with EBUSY. Lazily detach any leftover first.
clear_stale_mount(mountpoint);
info!(mountpoint = %mountpoint.display(), "mounting Proton Drive");
// Bind the control socket before the FUSE session takes over the thread. A
// stale socket file from a previous run would block the bind, so clear it.
let _ = std::fs::remove_file(control_socket);
let listener = UnixListener::bind(control_socket)?;
{
let core = core.clone();
let username = username.clone();
let mountpoint = mountpoint.to_path_buf();
std::thread::Builder::new()
.name("pdfs-control".into())
.spawn(move || run_control_socket(core, username, mountpoint, listener))?;
}
let fs = ProtonFs::new(core.clone(), root);
// Warm the in-memory maps from the DB so a cold start serves previously
// seen metadata without re-hitting the API (plan.md P1).
core.hydrate();
// Build the session explicitly (not `mount2`) so we can grab a `Notifier`
// for the event task. `spawn` runs the session loop on its own background
// thread; we then wait here for either a stop signal or the mount ending.
let bg = Session::new(fs, mountpoint, &config)?.spawn()?;
let notifier = bg.notifier();
rt.spawn(run_event_sync(
client, scope, core.state, core.cache, core.db, notifier,
));
// Stop signals (SIGTERM from `systemctl --user stop`, SIGINT from Ctrl-C)
// are delivered onto the async runtime; bridge them onto a sync channel so
// the loop below can react without blocking a worker thread. A bounded
// channel of 1 is enough — we only need to know that *a* stop arrived.
let (sig_tx, sig_rx) = std::sync::mpsc::sync_channel::<()>(1);
rt.spawn(async move {
let mut sigterm =
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
Ok(s) => s,
Err(e) => {
warn!(error = %e, "install SIGTERM handler failed");
return;
}
};
let mut sigint =
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt()) {
Ok(s) => s,
Err(e) => {
warn!(error = %e, "install SIGINT handler failed");
return;
}
};
tokio::select! {
_ = sigterm.recv() => info!("received SIGTERM"),
_ = sigint.recv() => info!("received SIGINT"),
}
let _ = sig_tx.try_send(());
});
// Wait for whichever happens first: a stop signal (→ we unmount ourselves
// via the lazy MNT_DETACH path, clean even mid-download), or the kernel
// mount ending on its own (→ the session thread finishes). Poll instead of
// blocking on `join` so we can also notice the signal.
let outcome = loop {
match sig_rx.recv_timeout(Duration::from_millis(500)) {
Ok(()) => {
info!("stop requested; unmounting");
if let Err(e) = bg.umount_and_join() {
warn!(error = %e, "umount_and_join failed");
}
break MountOutcome::Shutdown;
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
if bg.guard.is_finished() {
info!("mount ended externally");
if let Err(e) = bg.join() {
warn!(error = %e, "session join failed");
}
break MountOutcome::Unmounted;
}
}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
// Signal task gone (failed to install); fall back to join.
let _ = bg.join();
break MountOutcome::Unmounted;
}
}
};
// Unmount every on-demand sync folder too, or the kernel mounts linger as
// stale and the next start fails with EBUSY (devices.md Phase 3).
let secondaries: Vec<_> = core.mounts.lock().unwrap().drain().collect();
for (id, session) in secondaries {
if let Err(e) = session.umount_and_join() {
warn!(id, error = %e, "unmount on-demand folder failed");
}
}
let _ = std::fs::remove_file(control_socket);
Ok(outcome)
}
#[cfg(test)]
mod thumb_tests {
use super::{THUMB_EDGE, ratio_of, scale_thumbnail};
/// A `width`×`height` JPEG, standing in for a camera photo the server never
/// generated a thumbnail for.
fn jpeg(width: u32, height: u32) -> Vec<u8> {
let image = image::RgbImage::from_fn(width, height, |x, y| {
image::Rgb([(x % 256) as u8, (y % 256) as u8, 128])
});
let mut bytes = Vec::new();
image::codecs::jpeg::JpegEncoder::new(&mut bytes)
.encode_image(&image)
.unwrap();
bytes
}
#[test]
fn scaling_fits_the_long_edge_and_keeps_the_aspect_ratio() {
let photo = jpeg(4000, 3000);
let thumb = scale_thumbnail(&photo).expect("a JPEG scales");
let (width, height) = image::ImageReader::new(std::io::Cursor::new(&thumb.bytes))
.with_guessed_format()
.unwrap()
.into_dimensions()
.unwrap();
assert_eq!(width, THUMB_EDGE, "the long edge lands on the target");
assert_eq!(height, THUMB_EDGE * 3 / 4, "and nothing is cropped");
assert!((thumb.ratio - 4.0 / 3.0).abs() < 1e-6);
assert!(
thumb.bytes.len() < photo.len(),
"a thumbnail that isn't smaller than its photo is no thumbnail"
);
}
#[test]
fn a_portrait_photo_fits_its_long_edge_too() {
let thumb = scale_thumbnail(&jpeg(1000, 2000)).expect("a JPEG scales");
assert!(thumb.ratio < 1.0, "portrait stays portrait");
assert_eq!(ratio_of(&thumb.bytes).map(|r| r < 1.0), Some(true));
}
#[test]
fn undecodable_bytes_are_not_a_thumbnail() {
// What a photo in a format this build has no decoder for looks like: the
// caller writes it off as un-thumbnailable rather than retrying forever.
assert!(scale_thumbnail(b"not an image at all").is_none());
assert!(ratio_of(b"not an image at all").is_none());
}
#[test]
fn ratio_is_read_from_the_header_alone() {
assert_eq!(ratio_of(&jpeg(300, 200)), Some(1.5));
}
}
#[cfg(test)]
mod tests {
use super::Intervals;
/// Flatten `segments` into a readable form for assertions.
fn segs(iv: &Intervals, start: u64, end: u64) -> Vec<(u64, u64, bool)> {
iv.segments(start, end)
}
#[test]
fn append_merges_into_one_run() {
// Sequential writes (an append) coalesce into a single authored range.
let mut iv = Intervals::default();
iv.add(0, 10);
iv.add(10, 20);
iv.add(20, 25);
assert_eq!(segs(&iv, 0, 25), vec![(0, 25, true)]);
}
#[test]
fn partial_overwrite_leaves_base_gap() {
// Author [0,4) and [8,12); [4,8) stays base. A read of [0,12) must stitch
// authored / base / authored in order.
let mut iv = Intervals::default();
iv.add(0, 4);
iv.add(8, 12);
assert_eq!(
segs(&iv, 0, 12),
vec![(0, 4, true), (4, 8, false), (8, 12, true)]
);
}
#[test]
fn overlapping_writes_coalesce() {
let mut iv = Intervals::default();
iv.add(0, 10);
iv.add(5, 15);
iv.add(14, 20);
assert_eq!(segs(&iv, 0, 20), vec![(0, 20, true)]);
}
#[test]
fn segments_clamp_to_request_window() {
let mut iv = Intervals::default();
iv.add(0, 100);
// A sub-window of one big authored range is a single authored segment.
assert_eq!(segs(&iv, 20, 50), vec![(20, 50, true)]);
// A window entirely outside any authored range is all base.
let empty = Intervals::default();
assert_eq!(segs(&empty, 0, 8), vec![(0, 8, false)]);
}
#[test]
fn truncate_shrink_drops_tail() {
// Grow-then-shrink: clip removes/truncates authored ranges past the end.
let mut iv = Intervals::default();
iv.add(0, 100);
iv.clip(40);
assert_eq!(segs(&iv, 0, 40), vec![(0, 40, true)]);
// Authored ranges wholly past the new end disappear.
let mut iv2 = Intervals::default();
iv2.add(0, 10);
iv2.add(50, 60);
iv2.clip(40);
assert_eq!(segs(&iv2, 0, 40), vec![(0, 10, true), (10, 40, false)]);
}
#[test]
fn truncate_extend_authors_zero_tail() {
// setattr grow claims the new tail as authored (defined zeros), so commit
// never pulls it from the base.
let mut iv = Intervals::default();
iv.add(0, 10); // base content authored over
let old_len = 10u64;
let new_len = 30u64;
iv.add(old_len, new_len);
assert_eq!(segs(&iv, 0, 30), vec![(0, 30, true)]);
}
}