Compare commits

..
9 Commits
Author SHA1 Message Date
nvrl 77198ed261 version bumped to 1.2 2026-07-29 21:36:24 +02:00
nvrl 0d79c46729 version bumped to 1.2 2026-07-29 21:30:10 +02:00
nvrl a8210ce8fd implemented shared with me + verification 2026-07-29 21:29:07 +02:00
nvrl 551e14b644 implemented shared id permissions 2026-07-29 19:26:57 +02:00
nvrl 7de667b386 implemented primary mount shared with me 2026-07-29 17:04:42 +02:00
nvrl 295bbeadac implemented share_access, POSIX read-only mode 2026-07-29 14:57:56 +02:00
nvrlandClaude Opus 5 32817870a5 chore(packaging): bump PKGBUILD pkgver to 1.1.1
Version bump in b93e41e updated Cargo.toml only, so the release
workflow's tag/manifest/PKGBUILD agreement check failed on v1.1.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BEWKS8smdPfLMZacgDXjAR
2026-07-27 03:25:56 +02:00
nvrl b93e41efc5 version bump to 1.1.1 2026-07-26 20:18:45 +02:00
nvrl 082ef8522a fixed refresh pull bug for trash 2026-07-26 20:17:59 +02:00
33 changed files with 8277 additions and 508 deletions

No files matched your search

Generated
+8 -8
View File
@@ -3518,7 +3518,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]] [[package]]
name = "pdfs-cli" name = "pdfs-cli"
version = "1.1.0" version = "1.2.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap 4.6.4", "clap 4.6.4",
@@ -3533,7 +3533,7 @@ dependencies = [
[[package]] [[package]]
name = "pdfs-core" name = "pdfs-core"
version = "1.1.0" version = "1.2.0"
dependencies = [ dependencies = [
"directories", "directories",
"ignore", "ignore",
@@ -3553,7 +3553,7 @@ dependencies = [
[[package]] [[package]]
name = "pdfs-fuse" name = "pdfs-fuse"
version = "1.1.0" version = "1.2.0"
dependencies = [ dependencies = [
"fuser", "fuser",
"image", "image",
@@ -3569,7 +3569,7 @@ dependencies = [
[[package]] [[package]]
name = "pdfs-gui" name = "pdfs-gui"
version = "1.1.0" version = "1.2.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-channel", "async-channel",
@@ -3830,9 +3830,9 @@ dependencies = [
[[package]] [[package]]
name = "proton-drive-rs" name = "proton-drive-rs"
version = "0.2.2" version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "692610fbead83735a0415d9d068c1e9b467b12cce013ea19b3063f05206ce3d6" checksum = "a02afd5a53ecbf0e1ea18f3102e7b99e5ad61977ee0709bd214279b2376e0077"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"base64", "base64",
@@ -3857,9 +3857,9 @@ dependencies = [
[[package]] [[package]]
name = "proton-sdk" name = "proton-sdk"
version = "0.2.2" version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b3f2ad9cd1ba69b67cbafcaf7c159b3a200162a3c518b1bc7e25df837343b41" checksum = "46b5f84f0c33ac7a19b0131e971e7a17f10f12d6dc6df64c3b9dda7f37aa35bb"
dependencies = [ dependencies = [
"aes-gcm 0.11.0", "aes-gcm 0.11.0",
"async-trait", "async-trait",
+3 -3
View File
@@ -3,15 +3,15 @@ resolver = "2"
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"] members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
[workspace.package] [workspace.package]
version = "1.1.0" version = "1.2.0"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
authors = ["Nils Pukropp <contact@narl.io>"] authors = ["Nils Pukropp <contact@narl.io>"]
rust-version = "1.96" rust-version = "1.96"
[workspace.dependencies] [workspace.dependencies]
proton-sdk = { version = "0.2.2" } proton-sdk = { version = "0.3.1" }
proton-drive-rs = { version = "0.2.2" } proton-drive-rs = { version = "0.3.1" }
# Internal crates. # Internal crates.
pdfs-core = { path = "crates/pdfs-core" } pdfs-core = { path = "crates/pdfs-core" }
+44 -2
View File
@@ -13,7 +13,7 @@ use pdfs_core::auth;
use pdfs_core::cache::ContentCache; use pdfs_core::cache::ContentCache;
use pdfs_core::config::AppDirs; use pdfs_core::config::AppDirs;
use pdfs_core::control::{ use pdfs_core::control::{
ErrorKind, RefreshScope, Request as CtlRequest, Response as CtlResponse, ErrorKind, MountKind, RefreshScope, Request as CtlRequest, Response as CtlResponse,
RestoreItem as CtlRestoreItem, ShareEntryKind, SyncPhase, pending_summary, RestoreItem as CtlRestoreItem, ShareEntryKind, SyncPhase, pending_summary,
}; };
use pdfs_core::db::Db; use pdfs_core::db::Db;
@@ -28,7 +28,7 @@ struct Cli {
/// Emit machine-readable JSON instead of formatted text. /// Emit machine-readable JSON instead of formatted text.
/// ///
/// Applies to the query commands (`status`, `ls`, `pins`, `sync list`, /// Applies to the query commands (`status`, `ls`, `pins`, `sync list`,
/// `devices list`, `transfers`, `activity`, `cache inspect`). Commands that /// `devices list`, `locations`, `transfers`, `activity`, `cache inspect`). Commands that
/// perform an action keep their human output — a script that needs to know /// perform an action keep their human output — a script that needs to know
/// whether one succeeded has the exit code. /// whether one succeeded has the exit code.
#[arg(long, global = true)] #[arg(long, global = true)]
@@ -105,6 +105,8 @@ enum Command {
Logout, Logout,
/// Show account login state, and mount status if a daemon is running. /// Show account login state, and mount status if a daemon is running.
Status, Status,
/// List every local Proton Drive location.
Locations,
/// Mount Proton Drive at the given (or default) path. Blocks until unmounted. /// Mount Proton Drive at the given (or default) path. Blocks until unmounted.
Mount { Mount {
/// Mountpoint; defaults to ~/ProtonDrive. /// Mountpoint; defaults to ~/ProtonDrive.
@@ -507,6 +509,7 @@ fn main() -> Result<()> {
Command::Login { username } => cmd_login(username), Command::Login { username } => cmd_login(username),
Command::Logout => cmd_logout(), Command::Logout => cmd_logout(),
Command::Status => cmd_status(), Command::Status => cmd_status(),
Command::Locations => cmd_locations(),
Command::Mount { mountpoint } => cmd_mount(mountpoint), Command::Mount { mountpoint } => cmd_mount(mountpoint),
Command::Daemon { mountpoint } => cmd_daemon(mountpoint), Command::Daemon { mountpoint } => cmd_daemon(mountpoint),
Command::Pin { path } => cmd_pin(path), Command::Pin { path } => cmd_pin(path),
@@ -1201,6 +1204,45 @@ fn cmd_status() -> Result<()> {
Ok(()) Ok(())
} }
fn cmd_locations() -> Result<()> {
let response = control_request(CtlRequest::ListLocations)?;
if emit_json(&response)? {
return Ok(());
}
match response {
CtlResponse::Locations { items } if items.is_empty() => {
println!("No Proton Drive locations.");
}
CtlResponse::Locations { items } => {
for location in items {
let kind = match &location.kind {
MountKind::MyFiles => "My Files".to_string(),
MountKind::Device { sync_folder_id } => {
format!("Device folder {sync_folder_id}")
}
MountKind::Shared { .. } => "Shared folder".to_string(),
};
let mode = match location.pending_mode {
Some(pending) => format!("{} -> {pending}", location.mode),
None => location.mode.to_string(),
};
let mounted = if location.mounted {
"mounted"
} else {
"not mounted"
};
println!(
"{kind}: {} ({mode}, {}, {}, {mounted})",
location.local_path, location.access, location.state
);
}
}
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
}
Ok(())
}
fn cmd_mount(mountpoint: Option<PathBuf>) -> Result<()> { fn cmd_mount(mountpoint: Option<PathBuf>) -> Result<()> {
mount_once(mountpoint)?; mount_once(mountpoint)?;
Ok(()) Ok(())
+108
View File
@@ -0,0 +1,108 @@
//! Effective access carried by mounted Drive nodes.
use proton_drive_rs::MemberRole;
/// Effective write access for a node in a mounted tree.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Access {
Owner,
Editor,
Viewer,
Unknown,
}
impl Access {
pub fn writable(self) -> bool {
matches!(self, Self::Owner | Self::Editor)
}
pub(crate) fn as_db_str(self) -> &'static str {
match self {
Self::Owner => "owner",
Self::Editor => "editor",
Self::Viewer => "viewer",
Self::Unknown => "unknown",
}
}
pub(crate) fn from_db_str(value: &str) -> Option<Self> {
match value {
"owner" => Some(Self::Owner),
"editor" => Some(Self::Editor),
"viewer" => Some(Self::Viewer),
"unknown" => Some(Self::Unknown),
_ => None,
}
}
}
/// Resolve a share root's effective access from its exact membership role.
///
/// Owned trees fail open. A share whose role is absent or unrecognised fails
/// closed. Descendants normally inherit directly at intern time; `Inherited`
/// exists for the explicit wire role and therefore returns `parent`.
pub fn access_for(role: Option<MemberRole>, parent: Access, under_share: bool) -> Access {
match role {
Some(MemberRole::Viewer) => Access::Viewer,
Some(MemberRole::Editor | MemberRole::Admin) => Access::Editor,
Some(MemberRole::Inherited) => parent,
None if under_share => Access::Viewer,
None => Access::Owner,
}
}
/// POSIX permission bits advertised for a mounted node.
pub fn perm_bits(is_dir: bool, access: Access) -> u16 {
match (is_dir, access.writable()) {
(true, true) => 0o755,
(false, true) => 0o644,
(true, false) => 0o555,
(false, false) => 0o444,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn access_for_covers_the_fail_open_and_fail_closed_table() {
for parent in [
Access::Owner,
Access::Editor,
Access::Viewer,
Access::Unknown,
] {
assert_eq!(access_for(None, parent, false), Access::Owner);
assert_eq!(access_for(None, parent, true), Access::Viewer);
assert_eq!(
access_for(Some(MemberRole::Viewer), parent, true),
Access::Viewer
);
assert_eq!(
access_for(Some(MemberRole::Editor), parent, true),
Access::Editor
);
assert_eq!(
access_for(Some(MemberRole::Admin), parent, true),
Access::Editor
);
assert_eq!(
access_for(Some(MemberRole::Inherited), parent, true),
parent
);
}
}
#[test]
fn permission_bits_follow_effective_access() {
for writable in [Access::Owner, Access::Editor] {
assert_eq!(perm_bits(true, writable), 0o755);
assert_eq!(perm_bits(false, writable), 0o644);
}
for read_only in [Access::Viewer, Access::Unknown] {
assert_eq!(perm_bits(true, read_only), 0o555);
assert_eq!(perm_bits(false, read_only), 0o444);
}
}
}
+279 -11
View File
@@ -17,6 +17,7 @@
//! [`Node`]: proton_drive_rs::Node //! [`Node`]: proton_drive_rs::Node
use std::collections::{BTreeMap, HashSet}; use std::collections::{BTreeMap, HashSet};
use std::fmt;
use std::io::{Read, Seek, SeekFrom}; use std::io::{Read, Seek, SeekFrom};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
@@ -28,10 +29,11 @@ use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use crate::db::{CacheEntryInput, Db}; use crate::db::{CacheEntryInput, Db};
use crate::error::Result; use crate::error::{Error, Result};
/// `cache_entries.kind` tag for whole-file content blobs. /// `cache_entries.kind` tag for whole-file content blobs.
const KIND_BLOB: &str = "blob"; const KIND_BLOB: &str = "blob";
static STAGING_N: AtomicU64 = AtomicU64::new(0);
/// `cache_entries.kind` tag for on-demand block-cache chunks. /// `cache_entries.kind` tag for on-demand block-cache chunks.
const KIND_BLOCK: &str = "block"; const KIND_BLOCK: &str = "block";
/// `cache_entries.kind` tag for cached thumbnails. /// `cache_entries.kind` tag for cached thumbnails.
@@ -169,6 +171,35 @@ pub struct Baseline {
pub revision_id: Option<String>, pub revision_id: Option<String>,
} }
/// A denied-write rescue that retained the bytes but could not fully publish
/// their recovery metadata.
#[derive(Debug)]
pub struct PreserveWriteError {
/// The actual path that still contains the accepted bytes.
pub surviving_path: PathBuf,
/// The recovery metadata that still describes those bytes, when one could
/// be made durable. Callers must never clear this after an error.
pub marker_path: Option<PathBuf>,
error: Error,
}
impl fmt::Display for PreserveWriteError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
formatter,
"{} (bytes retained at {})",
self.error,
self.surviving_path.display()
)
}
}
impl std::error::Error for PreserveWriteError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(&self.error)
}
}
/// Content cache rooted at a directory, with a sibling pin-registry file. /// Content cache rooted at a directory, with a sibling pin-registry file.
pub struct ContentCache { pub struct ContentCache {
/// Directory holding `<key>` blobs and `<key>.meta` tags. /// Directory holding `<key>` blobs and `<key>.meta` tags.
@@ -225,6 +256,18 @@ pub struct ContentCache {
} }
impl ContentCache { impl ContentCache {
fn staging_path(&self, meta: &StagedWrite) -> PathBuf {
// The uid goes in the name so a staged file can be tied back to its node
// without a database; `/` in a uid would otherwise open a path.
let safe_uid = meta.uid.replace(['/', '\\'], "_");
self.staging_dir.join(format!(
"{}-{}-{}",
safe_uid,
now_millis(),
STAGING_N.fetch_add(1, Ordering::Relaxed)
))
}
/// Open (and create) a cache under `content_dir`, with the pin registry at /// Open (and create) a cache under `content_dir`, with the pin registry at
/// `pins_path` and a `max_bytes` size cap (`0` = unlimited). Both parent /// `pins_path` and a `max_bytes` size cap (`0` = unlimited). Both parent
/// directories are created if missing. `db` is the shared metadata database /// directories are created if missing. `db` is the shared metadata database
@@ -910,16 +953,7 @@ impl ContentCache {
pub fn stage_write(&self, meta: &StagedWrite, scratch: &Path) -> Result<PathBuf> { pub fn stage_write(&self, meta: &StagedWrite, scratch: &Path) -> Result<PathBuf> {
use std::io::Write as _; use std::io::Write as _;
static N: AtomicU64 = AtomicU64::new(0); let path = self.staging_path(meta);
// The uid goes in the name so a staged file can be tied back to its node
// without a database; `/` in a uid would otherwise open a path.
let safe_uid = meta.uid.replace(['/', '\\'], "_");
let path = self.staging_dir.join(format!(
"{}-{}-{}",
safe_uid,
now_millis(),
N.fetch_add(1, Ordering::Relaxed)
));
let copied = if let Err(e) = std::fs::rename(scratch, &path) { let copied = if let Err(e) = std::fs::rename(scratch, &path) {
if e.kind() != std::io::ErrorKind::CrossesDevices { if e.kind() != std::io::ErrorKind::CrossesDevices {
return Err(e.into()); return Err(e.into());
@@ -949,6 +983,154 @@ impl ContentCache {
Ok(path) Ok(path)
} }
/// Preserve accepted bytes which cannot be queued.
///
/// Scratch and staging normally share a filesystem, so the normal path is
/// an atomic rename. If that is impossible, the source first receives a
/// durable recovery marker and remains in place until a copied destination,
/// its sidecar, and the staging directory are all synced.
pub fn preserve_write(
&self,
meta: &StagedWrite,
scratch: &Path,
) -> std::result::Result<PathBuf, PreserveWriteError> {
let path = self.staging_path(meta);
self.preserve_write_at(meta, scratch, &path)
}
fn preserve_write_at(
&self,
meta: &StagedWrite,
scratch: &Path,
path: &Path,
) -> std::result::Result<PathBuf, PreserveWriteError> {
use std::io::Write as _;
let retained = |surviving_path: &Path, error: Error| {
let adjacent = Self::scratch_sidecar(surviving_path);
let source = Self::scratch_sidecar(scratch);
let marker_path = [adjacent, source].into_iter().find(|path| path.is_file());
PreserveWriteError {
surviving_path: surviving_path.to_path_buf(),
marker_path,
error,
}
};
// If rename cannot happen, this marker is what lets the next open move
// the still-authoritative source into recovery instead of deleting it.
let marker_result = self.mark_scratch_durable(scratch, meta);
let side = path.with_extension("json");
let side_tmp = path.with_extension("json.tmp");
let publish_sidecar = || -> Result<()> {
let mut side_file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&side_tmp)?;
side_file.write_all(&serde_json::to_vec_pretty(meta)?)?;
side_file.sync_all()?;
drop(side_file);
std::fs::rename(&side_tmp, &side)?;
std::fs::File::open(&self.staging_dir)?.sync_all()?;
Ok(())
};
match std::fs::rename(scratch, path) {
Ok(()) => {
if let Err(error) = std::fs::File::open(path).and_then(|file| file.sync_all()) {
if std::fs::rename(path, scratch).is_ok() {
if !Self::scratch_sidecar(scratch).is_file() {
let _ = self.mark_scratch_durable(scratch, meta);
}
let _ =
std::fs::File::open(&self.scratch_dir).and_then(|dir| dir.sync_all());
return Err(retained(scratch, error.into()));
}
return Err(retained(path, error.into()));
}
let scratch_side = Self::scratch_sidecar(scratch);
let side_result = if marker_result.is_ok() {
std::fs::rename(&scratch_side, &side)
.map_err(Error::from)
.and_then(|()| {
std::fs::File::open(&self.staging_dir)?
.sync_all()
.map_err(Error::from)
})
} else {
publish_sidecar()
};
if let Err(error) = side_result {
let _ = std::fs::remove_file(&side_tmp);
// Publication did not complete. Put the blob back beside
// the source marker so startup recovery still has a
// reconstructable pair, rather than an unexplained staging
// blob and a marker whose source vanished.
if std::fs::rename(path, scratch).is_ok() {
if !Self::scratch_sidecar(scratch).is_file() {
let _ = self.mark_scratch_durable(scratch, meta);
}
let _ =
std::fs::File::open(&self.scratch_dir).and_then(|dir| dir.sync_all());
return Err(retained(scratch, error));
}
return Err(retained(path, error));
}
Ok(path.to_path_buf())
}
Err(rename_error) => {
let source_marked =
marker_result.is_ok() || Self::scratch_sidecar(scratch).exists();
if let Err(marker_error) = marker_result {
tracing::warn!(
error = %marker_error,
path = %scratch.display(),
"could not mark rescue source durable before copy fallback"
);
}
if let Err(error) = std::fs::copy(scratch, path) {
if !source_marked {
let recovery = self.recovery_dir.join(
path.file_name()
.unwrap_or_else(|| std::ffi::OsStr::new("denied-write")),
);
if std::fs::rename(scratch, &recovery).is_ok() {
let _ = std::fs::File::open(&self.recovery_dir)
.and_then(|dir| dir.sync_all());
return Err(retained(
&recovery,
Error::Other(format!(
"rename failed ({rename_error}); copy fallback failed ({error})"
)),
));
}
}
return Err(retained(
scratch,
Error::Other(format!(
"rename failed ({rename_error}); copy fallback failed ({error})"
)),
));
}
if let Err(error) = std::fs::File::open(path).and_then(|file| file.sync_all()) {
let surviving = if source_marked { scratch } else { path };
return Err(retained(surviving, error.into()));
}
if let Err(error) = publish_sidecar() {
let _ = std::fs::remove_file(&side_tmp);
let surviving = if source_marked { scratch } else { path };
return Err(retained(surviving, error));
}
// Deliberately last: fallback never removes its marked source
// until destination publication is complete.
if let Err(error) = std::fs::remove_file(scratch) {
return Err(retained(path, error.into()));
}
self.clear_scratch_durable(scratch);
Ok(path.to_path_buf())
}
}
}
/// Drop a staged blob and its sidecar, once its bytes are safely on the /// Drop a staged blob and its sidecar, once its bytes are safely on the
/// remote (or the op that owned them has been superseded). Best effort: /// remote (or the op that owned them has been superseded). Best effort:
/// leftovers cost disk, while failing here would strand a drained op. /// leftovers cost disk, while failing here would strand a drained op.
@@ -1906,6 +2088,92 @@ mod tests {
); );
} }
fn preservation_meta() -> StagedWrite {
StagedWrite {
uid: uid("preserved").to_string(),
len: 14,
base_size: 0,
base_mtime: 100,
authored: vec![(0, 14)],
complete: true,
based_on: None,
}
}
#[test]
fn orphan_preservation_publishes_exact_bytes_then_removes_source() {
use std::io::Write as _;
let (c, _dir) = cache();
let (mut file, scratch) = c.create_scratch().unwrap();
file.write_all(b"accepted bytes").unwrap();
file.sync_all().unwrap();
drop(file);
let preserved = c.preserve_write(&preservation_meta(), &scratch).unwrap();
assert!(!scratch.exists());
assert_eq!(std::fs::read(&preserved).unwrap(), b"accepted bytes");
let sidecar: StagedWrite =
serde_json::from_slice(&std::fs::read(preserved.with_extension("json")).unwrap())
.unwrap();
let expected = preservation_meta();
assert_eq!(sidecar.uid, expected.uid);
assert_eq!(sidecar.len, expected.len);
assert_eq!(sidecar.authored, expected.authored);
assert_eq!(sidecar.complete, expected.complete);
}
#[test]
fn orphan_preservation_sidecar_failure_reports_surviving_blob_across_reopen() {
use std::io::Write as _;
let (c, dir) = cache();
let (mut file, scratch) = c.create_scratch().unwrap();
file.write_all(b"only surviving copy").unwrap();
file.sync_all().unwrap();
drop(file);
let destination = c.staging_dir.join("forced-preservation-failure");
let side = destination.with_extension("json");
std::fs::create_dir(&side).unwrap();
let error = c
.preserve_write_at(&preservation_meta(), &scratch, &destination)
.unwrap_err();
assert_eq!(error.surviving_path, scratch);
assert!(
scratch.exists(),
"failed sidecar publication must roll the blob back beside its marker"
);
assert_eq!(
std::fs::read(&error.surviving_path).unwrap(),
b"only surviving copy"
);
assert_eq!(
error.marker_path,
Some(ContentCache::scratch_sidecar(&scratch))
);
assert!(
side.is_dir(),
"the induced sidecar failure remains observable"
);
let content = dir.path().join("content");
let pins = dir.path().join("pins-reopen.json");
drop(c);
let reopened =
ContentCache::open(content, pins, 0, Arc::new(Db::open_in_memory().unwrap())).unwrap();
let recovered = reopened.recovered_writes();
assert_eq!(recovered.len(), 1);
assert_eq!(
std::fs::read(&recovered[0].0).unwrap(),
b"only surviving copy"
);
assert_eq!(recovered[0].1.uid, preservation_meta().uid);
drop(reopened);
}
/// A2: `fsync(2)` promises the bytes survive a crash, but queueing happens /// A2: `fsync(2)` promises the bytes survive a crash, but queueing happens
/// at `release`. A scratch file marked durable must therefore outlive the /// at `release`. A scratch file marked durable must therefore outlive the
/// open that clears the scratch directory — and an unmarked one must not, /// open that clears the scratch directory — and an unmarked one must not,
+389
View File
@@ -14,6 +14,7 @@ use serde::{Deserialize, Serialize};
use crate::cache::Pin; use crate::cache::Pin;
use crate::error::Result; use crate::error::Result;
pub use crate::mounts::{MountAccess, MountKind, MountMode, MountSpec};
/// Cap on the *write* half of a round-trip. A crashed daemon can leave its /// Cap on the *write* half of a round-trip. A crashed daemon can leave its
/// listening socket in the kernel (e.g. as a `<defunct>` zombie): `connect` /// listening socket in the kernel (e.g. as a `<defunct>` zombie): `connect`
@@ -194,6 +195,11 @@ pub enum Request {
/// it invalidates, it does not fetch. /// it invalidates, it does not fetch.
Refresh { scope: RefreshScope }, Refresh { scope: RefreshScope },
// ---- locations --------------------------------------------------------
/// List every local Proton Drive location. Replies with
/// [`Response::Locations`].
ListLocations,
// ---- devices ---------------------------------------------------------- // ---- devices ----------------------------------------------------------
/// List the account's registered devices. Replies with [`Response::Devices`]. /// List the account's registered devices. Replies with [`Response::Devices`].
ListDevices, ListDevices,
@@ -240,9 +246,20 @@ pub enum Request {
role: String, role: String,
message: Option<String>, message: Option<String>,
}, },
/// The by-uid twin of [`Request::ShareNode`], for nodes proven to belong to
/// an on-demand or mirror device location. This is a distinct variant so an
/// older daemon rejects it instead of ignoring `uid` and acting on `path`.
ShareNodeByUid {
uid: String,
emails: Vec<String>,
role: String,
message: Option<String>,
},
/// List the members, pending invitations and public link of the node at /// List the members, pending invitations and public link of the node at
/// mountpoint-relative `path`. Replies with [`Response::Share`]. /// mountpoint-relative `path`. Replies with [`Response::Share`].
ListShare { path: String }, ListShare { path: String },
/// The by-uid twin of [`Request::ListShare`].
ListShareByUid { uid: String },
/// Change the role of a share entry (member or pending invitation) on the node /// Change the role of a share entry (member or pending invitation) on the node
/// at `path`. `id` and `kind` identify the entry (from [`Response::Share`]). /// at `path`. `id` and `kind` identify the entry (from [`Response::Share`]).
/// Replies with [`Response::Ok`]. /// Replies with [`Response::Ok`].
@@ -252,6 +269,13 @@ pub enum Request {
kind: ShareEntryKind, kind: ShareEntryKind,
role: String, role: String,
}, },
/// The by-uid twin of [`Request::UpdateShareRole`].
UpdateShareRoleByUid {
uid: String,
id: String,
kind: ShareEntryKind,
role: String,
},
/// Remove a share entry (member, pending Proton invite, or external invite) /// Remove a share entry (member, pending Proton invite, or external invite)
/// from the node at `path`. Replies with [`Response::Ok`]. /// from the node at `path`. Replies with [`Response::Ok`].
RemoveShareEntry { RemoveShareEntry {
@@ -259,6 +283,12 @@ pub enum Request {
id: String, id: String,
kind: ShareEntryKind, kind: ShareEntryKind,
}, },
/// The by-uid twin of [`Request::RemoveShareEntry`].
RemoveShareEntryByUid {
uid: String,
id: String,
kind: ShareEntryKind,
},
/// Create a public link on the node at `path`. `role` is "viewer" or "editor"; /// Create a public link on the node at `path`. `role` is "viewer" or "editor";
/// `password` optionally adds a custom password; `expires` is an optional Unix /// `password` optionally adds a custom password; `expires` is an optional Unix
/// expiry. Replies with [`Response::PublicLink`] (carrying the shareable URL). /// expiry. Replies with [`Response::PublicLink`] (carrying the shareable URL).
@@ -268,9 +298,18 @@ pub enum Request {
password: Option<String>, password: Option<String>,
expires: Option<i64>, expires: Option<i64>,
}, },
/// The by-uid twin of [`Request::CreatePublicLink`].
CreatePublicLinkByUid {
uid: String,
role: String,
password: Option<String>,
expires: Option<i64>,
},
/// Remove the public link `id` from the node at `path`. Replies with /// Remove the public link `id` from the node at `path`. Replies with
/// [`Response::Ok`]. /// [`Response::Ok`].
RemovePublicLink { path: String, id: String }, RemovePublicLink { path: String, id: String },
/// The by-uid twin of [`Request::RemovePublicLink`].
RemovePublicLinkByUid { uid: String, id: String },
// ---- shared by me ----------------------------------------------------- // ---- shared by me -----------------------------------------------------
/// List the nodes I have shared with others — collaborative shares that still /// List the nodes I have shared with others — collaborative shares that still
@@ -1094,6 +1133,8 @@ pub enum Response {
}, },
/// The account's devices (reply to [`Request::ListDevices`]). /// The account's devices (reply to [`Request::ListDevices`]).
Devices { items: Vec<DeviceInfo> }, Devices { items: Vec<DeviceInfo> },
/// Every local Proton Drive location (reply to [`Request::ListLocations`]).
Locations { items: Vec<MountSpec> },
/// This device's synced folders (reply to [`Request::ListSyncFolders`]). /// This device's synced folders (reply to [`Request::ListSyncFolders`]).
SyncFolders { items: Vec<SyncFolderInfo> }, SyncFolders { items: Vec<SyncFolderInfo> },
/// Folders offered for restore (reply to [`Request::ListRestorableFolders`]). /// Folders offered for restore (reply to [`Request::ListRestorableFolders`]).
@@ -1541,6 +1582,7 @@ mod tests {
#[test] #[test]
fn sharing_requests_roundtrip() { fn sharing_requests_roundtrip() {
let reqs = [ let reqs = [
Request::ListLocations,
Request::ListDevices, Request::ListDevices,
Request::RenameDevice { Request::RenameDevice {
uid: "dev-1".into(), uid: "dev-1".into(),
@@ -1580,28 +1622,58 @@ mod tests {
role: "editor".into(), role: "editor".into(),
message: Some("hi".into()), message: Some("hi".into()),
}, },
Request::ShareNodeByUid {
uid: "vol~link".into(),
emails: vec!["x@proton.me".into(), "y@example.com".into()],
role: "editor".into(),
message: Some("hi".into()),
},
Request::ListShare { path: "a/b".into() }, Request::ListShare { path: "a/b".into() },
Request::ListShareByUid {
uid: "vol~link".into(),
},
Request::UpdateShareRole { Request::UpdateShareRole {
path: "a/b".into(), path: "a/b".into(),
id: "mid-1".into(), id: "mid-1".into(),
kind: ShareEntryKind::Member, kind: ShareEntryKind::Member,
role: "admin".into(), role: "admin".into(),
}, },
Request::UpdateShareRoleByUid {
uid: "vol~link".into(),
id: "mid-1".into(),
kind: ShareEntryKind::Member,
role: "admin".into(),
},
Request::RemoveShareEntry { Request::RemoveShareEntry {
path: "a/b".into(), path: "a/b".into(),
id: "iid-1".into(), id: "iid-1".into(),
kind: ShareEntryKind::ExternalInvite, kind: ShareEntryKind::ExternalInvite,
}, },
Request::RemoveShareEntryByUid {
uid: "vol~link".into(),
id: "iid-1".into(),
kind: ShareEntryKind::ExternalInvite,
},
Request::CreatePublicLink { Request::CreatePublicLink {
path: "a/b".into(), path: "a/b".into(),
role: "viewer".into(), role: "viewer".into(),
password: Some("pw".into()), password: Some("pw".into()),
expires: Some(1_700_000_000), expires: Some(1_700_000_000),
}, },
Request::CreatePublicLinkByUid {
uid: "vol~link".into(),
role: "viewer".into(),
password: Some("pw".into()),
expires: Some(1_700_000_000),
},
Request::RemovePublicLink { Request::RemovePublicLink {
path: "a/b".into(), path: "a/b".into(),
id: "url-1".into(), id: "url-1".into(),
}, },
Request::RemovePublicLinkByUid {
uid: "vol~link".into(),
id: "url-1".into(),
},
Request::ListSharedByMe, Request::ListSharedByMe,
Request::ListActivity { limit: 100 }, Request::ListActivity { limit: 100 },
Request::ListSharedWithMe, Request::ListSharedWithMe,
@@ -1635,6 +1707,323 @@ mod tests {
} }
} }
#[test]
fn locations_response_roundtrips_typed_mounts() {
let response = Response::Locations {
items: vec![
MountSpec {
id: 1,
kind: MountKind::MyFiles,
local_path: "/home/me/ProtonDrive".into(),
root_uid: "vol~root".into(),
root_share_id: "share-main".into(),
mode: MountMode::OnDemand,
access: MountAccess::Rw,
state: "idle".into(),
last_sync: 0,
pending_mode: None,
mounted: true,
progress: None,
},
MountSpec {
id: 2,
kind: MountKind::Device { sync_folder_id: 7 },
local_path: "/home/me/Work".into(),
root_uid: "device-vol~work".into(),
root_share_id: "device-share".into(),
mode: MountMode::Mirror,
access: MountAccess::Rw,
state: "syncing".into(),
last_sync: 42,
pending_mode: Some(MountMode::OnDemand),
mounted: false,
progress: None,
},
MountSpec {
id: 3,
kind: MountKind::Shared {
share_root_uid: "shared-vol~root".into(),
},
local_path: "/home/me/Shared".into(),
root_uid: "shared-vol~root".into(),
root_share_id: "shared-share".into(),
mode: MountMode::Unknown,
access: MountAccess::Ro,
state: "error".into(),
last_sync: 0,
pending_mode: Some(MountMode::Unknown),
mounted: false,
progress: Some(SyncProgress {
phase: SyncPhase::Applying,
done: 2,
total: 5,
current: "report.pdf".into(),
}),
},
],
};
let wire = serde_json::to_string(&response).unwrap();
assert!(!wire.contains('\n'), "wire form must be a single line");
assert!(wire.contains(r#""mode":"ondemand""#));
assert!(wire.contains(r#""kind":"shared""#));
assert!(wire.contains(r#""access":"ro""#));
assert!(wire.contains(r#""mode":"unknown""#));
let back: Response = serde_json::from_str(&wire).unwrap();
match back {
Response::Locations { items } => {
assert_eq!(items.len(), 3);
assert!(matches!(
items[1].kind,
MountKind::Device { sync_folder_id: 7 }
));
assert_eq!(items[1].pending_mode, Some(MountMode::OnDemand));
assert!(matches!(
&items[2].kind,
MountKind::Shared { share_root_uid }
if share_root_uid == "shared-vol~root"
));
assert_eq!(items[2].access, MountAccess::Ro);
assert_eq!(items[2].mode, MountMode::Unknown);
assert_eq!(items[2].pending_mode, Some(MountMode::Unknown));
let progress = items[2].progress.as_ref().unwrap();
assert_eq!(progress.phase, SyncPhase::Applying);
assert_eq!((progress.done, progress.total), (2, 5));
}
other => panic!("unexpected response: {other:?}"),
}
}
#[test]
fn legacy_daemons_reject_list_locations_cleanly() {
#[allow(dead_code)]
#[derive(serde::Deserialize)]
enum LegacyLocationRequest {
ListSyncFolders,
}
let wire = serde_json::to_string(&Request::ListLocations).unwrap();
assert!(
serde_json::from_str::<LegacyLocationRequest>(&wire).is_err(),
"an old daemon must reject ListLocations instead of interpreting another request"
);
}
#[test]
fn path_share_requests_keep_their_existing_wire_shape() {
let cases = [
(
Request::ShareNode {
path: "a/b".into(),
emails: vec!["x@example.com".into()],
role: "viewer".into(),
message: None,
},
r#"{"ShareNode":{"path":"a/b","emails":["x@example.com"],"role":"viewer","message":null}}"#,
),
(
Request::ListShare { path: "a/b".into() },
r#"{"ListShare":{"path":"a/b"}}"#,
),
(
Request::UpdateShareRole {
path: "a/b".into(),
id: "member".into(),
kind: ShareEntryKind::Member,
role: "editor".into(),
},
r#"{"UpdateShareRole":{"path":"a/b","id":"member","kind":"Member","role":"editor"}}"#,
),
(
Request::RemoveShareEntry {
path: "a/b".into(),
id: "invite".into(),
kind: ShareEntryKind::ProtonInvite,
},
r#"{"RemoveShareEntry":{"path":"a/b","id":"invite","kind":"ProtonInvite"}}"#,
),
(
Request::CreatePublicLink {
path: "a/b".into(),
role: "viewer".into(),
password: None,
expires: None,
},
r#"{"CreatePublicLink":{"path":"a/b","role":"viewer","password":null,"expires":null}}"#,
),
(
Request::RemovePublicLink {
path: "a/b".into(),
id: "link".into(),
},
r#"{"RemovePublicLink":{"path":"a/b","id":"link"}}"#,
),
];
for (request, expected) in cases {
assert_eq!(serde_json::to_string(&request).unwrap(), expected);
assert!(
!expected.contains("\"uid\""),
"path variants must never acquire an optional uid"
);
}
}
#[test]
fn by_uid_share_requests_are_distinct_wire_variants() {
let cases = [
(
Request::ShareNodeByUid {
uid: "vol~link".into(),
emails: vec!["x@example.com".into()],
role: "viewer".into(),
message: None,
},
"ShareNodeByUid",
),
(
Request::ListShareByUid {
uid: "vol~link".into(),
},
"ListShareByUid",
),
(
Request::UpdateShareRoleByUid {
uid: "vol~link".into(),
id: "member".into(),
kind: ShareEntryKind::Member,
role: "editor".into(),
},
"UpdateShareRoleByUid",
),
(
Request::RemoveShareEntryByUid {
uid: "vol~link".into(),
id: "invite".into(),
kind: ShareEntryKind::ProtonInvite,
},
"RemoveShareEntryByUid",
),
(
Request::CreatePublicLinkByUid {
uid: "vol~link".into(),
role: "viewer".into(),
password: None,
expires: None,
},
"CreatePublicLinkByUid",
),
(
Request::RemovePublicLinkByUid {
uid: "vol~link".into(),
id: "link".into(),
},
"RemovePublicLinkByUid",
),
];
for (request, variant) in cases {
let wire = serde_json::to_string(&request).unwrap();
let object = serde_json::from_str::<serde_json::Value>(&wire)
.unwrap()
.as_object()
.cloned()
.unwrap();
assert_eq!(object.len(), 1);
assert!(object.contains_key(variant));
let decoded = serde_json::from_str::<Request>(&wire).unwrap();
let decoded_uid = match &decoded {
Request::ShareNodeByUid { uid, .. }
| Request::ListShareByUid { uid }
| Request::UpdateShareRoleByUid { uid, .. }
| Request::RemoveShareEntryByUid { uid, .. }
| Request::CreatePublicLinkByUid { uid, .. }
| Request::RemovePublicLinkByUid { uid, .. } => uid,
other => panic!("decoded into the wrong dispatch variant: {other:?}"),
};
assert_eq!(decoded_uid, "vol~link");
assert_eq!(serde_json::to_string(&decoded).unwrap(), wire);
}
}
#[test]
fn legacy_daemons_reject_new_by_uid_variants() {
#[allow(dead_code)]
#[derive(serde::Deserialize)]
enum LegacyShareRequest {
ShareNode {
path: String,
emails: Vec<String>,
role: String,
message: Option<String>,
},
ListShare {
path: String,
},
UpdateShareRole {
path: String,
id: String,
kind: ShareEntryKind,
role: String,
},
RemoveShareEntry {
path: String,
id: String,
kind: ShareEntryKind,
},
CreatePublicLink {
path: String,
role: String,
password: Option<String>,
expires: Option<i64>,
},
RemovePublicLink {
path: String,
id: String,
},
}
let requests = [
Request::ShareNodeByUid {
uid: "vol~link".into(),
emails: Vec::new(),
role: "viewer".into(),
message: None,
},
Request::ListShareByUid {
uid: "vol~link".into(),
},
Request::UpdateShareRoleByUid {
uid: "vol~link".into(),
id: "member".into(),
kind: ShareEntryKind::Member,
role: "viewer".into(),
},
Request::RemoveShareEntryByUid {
uid: "vol~link".into(),
id: "member".into(),
kind: ShareEntryKind::Member,
},
Request::CreatePublicLinkByUid {
uid: "vol~link".into(),
role: "viewer".into(),
password: None,
expires: None,
},
Request::RemovePublicLinkByUid {
uid: "vol~link".into(),
id: "link".into(),
},
];
for request in requests {
let wire = serde_json::to_string(&request).unwrap();
assert!(
serde_json::from_str::<LegacyShareRequest>(&wire).is_err(),
"an old daemon must reject {wire}, never reinterpret it as a path request"
);
}
}
/// A daemon built before `kind` existed sends `Error` without the field. /// A daemon built before `kind` existed sends `Error` without the field.
/// It must still parse, and land on the class that promises the least. /// It must still parse, and land on the class that promises the least.
#[test] #[test]
+2
View File
@@ -77,10 +77,12 @@ const REPORTED_TABLES: &[&str] = &[
"trash", "trash",
"device", "device",
"sync_folder", "sync_folder",
"mount",
"sync_entry", "sync_entry",
"pending_op", "pending_op",
"activity", "activity",
"local_files", "local_files",
"share_access",
]; ];
impl Db { impl Db {
+62 -1
View File
@@ -9,7 +9,7 @@ use super::Db;
use crate::Result; use crate::Result;
/// Current schema version. Bump on every forward migration added below. /// Current schema version. Bump on every forward migration added below.
pub(super) const SCHEMA_VERSION: i64 = 16; pub(super) const SCHEMA_VERSION: i64 = 18;
impl Db { impl Db {
pub(super) fn migrate(&self) -> Result<()> { pub(super) fn migrate(&self) -> Result<()> {
@@ -89,6 +89,12 @@ impl Db {
if current < 16 { if current < 16 {
tx.execute_batch(MIGRATION_V16)?; tx.execute_batch(MIGRATION_V16)?;
} }
if current < 17 {
tx.execute_batch(MIGRATION_V17)?;
}
if current < 18 {
tx.execute_batch(MIGRATION_V18)?;
}
tx.execute( tx.execute(
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1) "INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
ON CONFLICT(key) DO UPDATE SET value = excluded.value", ON CONFLICT(key) DO UPDATE SET value = excluded.value",
@@ -411,3 +417,58 @@ CREATE INDEX IF NOT EXISTS idx_local_files_name_nocase
ON local_files(name COLLATE NOCASE); ON local_files(name COLLATE NOCASE);
INSERT INTO local_fts(local_fts) VALUES('rebuild'); INSERT INTO local_fts(local_fts) VALUES('rebuild');
"; ";
/// Schema v17: effective access for roots shared with this account. The role is
/// persisted separately from `nodes.node_json` so a cold/offline mount can
/// enforce the last known permission even when the SDK cannot refresh metadata.
const MIGRATION_V17: &str = "
CREATE TABLE share_access (
root_uid TEXT PRIMARY KEY,
access TEXT NOT NULL
CHECK (access IN ('owner', 'editor', 'viewer', 'unknown'))
);
";
/// Schema v18: unified location presentation. Device rows carry only their
/// `sync_folder` identity; reads join all device state from that authoritative
/// table. The trigger projects future folders, while the foreign key removes a
/// presentation row with its sync folder.
const MIGRATION_V18: &str = "
CREATE TABLE mount (
id INTEGER PRIMARY KEY,
kind TEXT NOT NULL
CHECK (kind IN ('myfiles', 'device', 'shared')),
sync_folder_id INTEGER REFERENCES sync_folder(id) ON DELETE CASCADE,
share_root_uid TEXT,
local_path TEXT NOT NULL DEFAULT '',
root_uid TEXT NOT NULL DEFAULT '',
root_share_id TEXT NOT NULL DEFAULT '',
mode TEXT NOT NULL DEFAULT 'unknown',
access TEXT NOT NULL DEFAULT 'rw'
CHECK (access IN ('rw', 'ro')),
CHECK (
(kind = 'myfiles' AND sync_folder_id IS NULL AND share_root_uid IS NULL)
OR
(kind = 'device' AND sync_folder_id IS NOT NULL AND share_root_uid IS NULL)
OR
(kind = 'shared' AND sync_folder_id IS NULL AND share_root_uid IS NOT NULL)
)
);
CREATE UNIQUE INDEX mount_myfiles
ON mount(kind) WHERE kind = 'myfiles';
CREATE UNIQUE INDEX mount_device
ON mount(sync_folder_id) WHERE kind = 'device';
CREATE UNIQUE INDEX mount_shared
ON mount(share_root_uid) WHERE kind = 'shared';
INSERT INTO mount (kind, sync_folder_id)
SELECT 'device', id FROM sync_folder;
CREATE TRIGGER mount_sync_folder_insert
AFTER INSERT ON sync_folder
BEGIN
INSERT OR IGNORE INTO mount (kind, sync_folder_id)
VALUES ('device', NEW.id);
END;
";
+8 -2
View File
@@ -27,10 +27,12 @@ mod devices;
mod local; mod local;
mod maintenance; mod maintenance;
mod migrations; mod migrations;
mod mounts;
mod nodes; mod nodes;
mod ops; mod ops;
mod photos; mod photos;
mod pins; mod pins;
mod share_access;
mod state; mod state;
mod sync; mod sync;
mod trash; mod trash;
@@ -40,10 +42,10 @@ pub use cache::CacheEntryInput;
pub use devices::StoredDevice; pub use devices::StoredDevice;
pub use local::LocalFileHit; pub use local::LocalFileHit;
pub use maintenance::{DbStats, VacuumOutcome}; pub use maintenance::{DbStats, VacuumOutcome};
pub use nodes::{SearchHit, StoredNode}; pub use nodes::{PublishedSharedRoot, SearchHit, StoredNode};
pub use ops::{ pub use ops::{
AttachedBlob, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PARK_UNTIL, AttachedBlob, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PARK_UNTIL,
PendingCounts, PendingOp, op_supersedes, PendingCounts, PendingOp, RenameMeta, op_supersedes,
}; };
pub use photos::{StoredPhoto, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN}; pub use photos::{StoredPhoto, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN};
pub use sync::{StoredSyncEntry, StoredSyncFolder}; pub use sync::{StoredSyncEntry, StoredSyncFolder};
@@ -109,6 +111,10 @@ impl Db {
#[cfg(test)] #[cfg(test)]
pub fn open_in_memory() -> Result<Self> { pub fn open_in_memory() -> Result<Self> {
let conn = Connection::open_in_memory()?; let conn = Connection::open_in_memory()?;
// Keep relational behavior identical to `open`: V18 relies on the
// `mount.sync_folder_id` cascade, and SQLite disables foreign keys per
// connection unless explicitly enabled.
conn.pragma_update(None, "foreign_keys", "ON")?;
let db = Self { let db = Self {
conn: Mutex::new(conn), conn: Mutex::new(conn),
}; };
+178
View File
@@ -0,0 +1,178 @@
//! Unified mount/location presentation rows.
//!
//! A device row stores only its `sync_folder` identity. Its path, remote root,
//! mode, state, pending mode, and last-sync timestamp are selected from
//! `sync_folder`, which remains the authoritative sync-engine table.
use rusqlite::params;
use super::Db;
use crate::mounts::{MountAccess, MountKind, MountMode, MountSpec};
use crate::{Error, Result};
impl Db {
/// Project the configured primary mount into the presentation table.
///
/// `root_share_id` is optional because an offline startup may only have the
/// cached root node. A previously learned share id is retained only when it
/// belongs to the same root uid.
pub fn mount_upsert_my_files(
&self,
local_path: &str,
root_uid: &str,
root_share_id: Option<&str>,
) -> Result<i64> {
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
let updated = tx.execute(
"UPDATE mount
SET local_path = ?1,
root_share_id = CASE
WHEN root_uid = ?2
THEN COALESCE(NULLIF(?3, ''), root_share_id)
ELSE COALESCE(?3, '')
END,
root_uid = ?2,
mode = 'ondemand',
access = 'rw'
WHERE kind = 'myfiles'",
params![local_path, root_uid, root_share_id],
)?;
let id = if updated == 0 {
tx.execute(
"INSERT INTO mount
(kind, local_path, root_uid, root_share_id, mode, access)
VALUES ('myfiles', ?1, ?2, COALESCE(?3, ''), 'ondemand', 'rw')",
params![local_path, root_uid, root_share_id],
)?;
tx.last_insert_rowid()
} else {
tx.query_row("SELECT id FROM mount WHERE kind = 'myfiles'", [], |row| {
row.get(0)
})?
};
tx.commit()?;
Ok(id)
}
/// Store a share id learned asynchronously, but only if the projection still
/// names the root it was resolved for.
pub fn mount_repair_my_files_share_id(
&self,
root_uid: &str,
root_share_id: &str,
) -> Result<bool> {
let conn = self.conn.lock();
Ok(conn.execute(
"UPDATE mount
SET root_share_id = ?2
WHERE kind = 'myfiles'
AND root_uid = ?1",
params![root_uid, root_share_id],
)? > 0)
}
/// Every local location, with device fields joined from `sync_folder`.
pub fn mount_list(&self) -> Result<Vec<MountSpec>> {
let conn = self.conn.lock();
let mut stmt = conn.prepare(
"SELECT
m.id,
m.kind,
m.sync_folder_id,
m.share_root_uid,
CASE WHEN m.kind = 'device' THEN sf.local_path ELSE m.local_path END,
CASE WHEN m.kind = 'device' THEN sf.remote_uid ELSE m.root_uid END,
CASE WHEN m.kind = 'device' THEN sf.remote_share_id ELSE m.root_share_id END,
CASE WHEN m.kind = 'device' THEN sf.mode ELSE m.mode END,
m.access,
CASE WHEN m.kind = 'device' THEN sf.state ELSE 'idle' END,
CASE WHEN m.kind = 'device' THEN sf.last_sync ELSE 0 END,
CASE WHEN m.kind = 'device' THEN sf.pending_mode ELSE NULL END
FROM mount AS m
LEFT JOIN sync_folder AS sf ON sf.id = m.sync_folder_id
ORDER BY CASE m.kind
WHEN 'myfiles' THEN 0
WHEN 'device' THEN 1
ELSE 2
END,
m.id",
)?;
let rows = stmt.query_map([], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, Option<i64>>(2)?,
row.get::<_, Option<String>>(3)?,
row.get::<_, String>(4)?,
row.get::<_, String>(5)?,
row.get::<_, String>(6)?,
row.get::<_, String>(7)?,
row.get::<_, String>(8)?,
row.get::<_, String>(9)?,
row.get::<_, i64>(10)?,
row.get::<_, Option<String>>(11)?,
))
})?;
let mut mounts = Vec::new();
for row in rows {
let (
id,
kind,
sync_folder_id,
share_root_uid,
local_path,
root_uid,
root_share_id,
mode,
access,
state,
last_sync,
pending_mode,
) = row?;
let kind = match kind.as_str() {
"myfiles" => MountKind::MyFiles,
"device" => MountKind::Device {
sync_folder_id: sync_folder_id.ok_or_else(|| {
Error::Other(format!("mount row {id} has no sync_folder_id"))
})?,
},
"shared" => MountKind::Shared {
share_root_uid: share_root_uid.ok_or_else(|| {
Error::Other(format!("mount row {id} has no share_root_uid"))
})?,
},
other => {
return Err(Error::Other(format!(
"mount row {id} has unknown kind {other:?}"
)));
}
};
let access = match access.as_str() {
"rw" => MountAccess::Rw,
"ro" => MountAccess::Ro,
other => {
return Err(Error::Other(format!(
"mount row {id} has unknown access {other:?}"
)));
}
};
mounts.push(MountSpec {
id,
kind,
local_path,
root_uid,
root_share_id,
mode: MountMode::from(mode.as_str()),
access,
state,
last_sync,
pending_mode: pending_mode.as_deref().map(MountMode::from),
mounted: false,
progress: None,
});
}
Ok(mounts)
}
}
+375 -79
View File
@@ -1,11 +1,13 @@
//! Node rows: the persisted mirror of the remote tree, plus the trigram //! Node rows: the persisted mirror of the remote tree, plus the trigram
//! full-text index over names and paths that backs `Request::Search`. //! full-text index over names and paths that backs `Request::Search`.
use rusqlite::{OptionalExtension, params}; use std::collections::HashSet;
use rusqlite::{OptionalExtension, Transaction, params};
use super::Db; use super::Db;
use crate::Result; use crate::{Access, Result};
use proton_drive_rs::proton_sdk::ids::NodeUid; use proton_drive_rs::proton_sdk::ids::{LinkId, NodeUid, VolumeId};
use proton_drive_rs::{Node, NodeKind}; use proton_drive_rs::{Node, NodeKind};
use super::utils::{TRIGRAM_MIN, collect_pairs, like_escape, pair, path_of}; use super::utils::{TRIGRAM_MIN, collect_pairs, like_escape, pair, path_of};
@@ -22,6 +24,11 @@ pub struct SearchHit {
pub path: String, pub path: String,
} }
pub struct PublishedSharedRoot {
pub node: Node,
pub access: Access,
}
impl Db { impl Db {
pub fn upsert_node(&self, node: &Node) -> Result<()> { pub fn upsert_node(&self, node: &Node) -> Result<()> {
self.upsert_nodes(std::slice::from_ref(node)) self.upsert_nodes(std::slice::from_ref(node))
@@ -42,87 +49,188 @@ impl Db {
let mut conn = self.conn.lock(); let mut conn = self.conn.lock();
let tx = conn.transaction()?; let tx = conn.transaction()?;
for node in nodes { for node in nodes {
let json = serde_json::to_string(node)?; upsert_node_tx(&tx, node)?;
let uid = node.uid.to_string();
tx.execute(
"INSERT INTO nodes
(uid, parent_uid, name, is_dir, size, mtime, trashed, node_json)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
ON CONFLICT(uid) DO UPDATE SET
parent_uid = excluded.parent_uid,
name = excluded.name,
is_dir = excluded.is_dir,
size = excluded.size,
mtime = excluded.mtime,
trashed = excluded.trashed,
node_json = excluded.node_json",
params![
uid,
node.parent_uid.as_ref().map(|u| u.to_string()),
node.name,
node.is_folder() as i64,
node_size(node),
node.modification_time,
node.trashed as i64,
json,
],
)?;
// FTS5 has no UPSERT, so refresh the row by delete-then-insert.
// Trashed nodes are kept out of the index entirely so they never
// surface in search results.
//
// Keyed by the node's rowid, never by its uid: the uid is not an
// indexed FTS column, so deleting by it scans the entire index once
// per node written (B12). The rowid is stable across the upsert
// above — `ON CONFLICT DO UPDATE` keeps the existing row — so it is
// the same key before and after.
let rowid: i64 = tx.query_row(
"SELECT rowid FROM nodes WHERE uid = ?1",
params![uid],
|row| row.get(0),
)?;
// A folder rename/move changes every descendant's searchable path.
// Refresh the affected subtree, while retaining efficient rowid
// deletes for the overwhelmingly common one-file case.
let mut affected = vec![rowid];
if node.is_folder() {
let mut stmt = tx.prepare(
"WITH RECURSIVE descendants(rowid, uid) AS (
SELECT rowid, uid FROM nodes WHERE parent_uid = ?1
UNION ALL
SELECT n.rowid, n.uid FROM nodes n
JOIN descendants d ON n.parent_uid = d.uid
) SELECT rowid FROM descendants",
)?;
for descendant in stmt.query_map(params![uid], |row| row.get(0))? {
affected.push(descendant?);
}
}
for affected_rowid in affected {
tx.execute(
"DELETE FROM nodes_fts WHERE rowid = ?1",
params![affected_rowid],
)?;
let indexed: Option<(String, String)> = tx
.query_row(
"SELECT name, uid FROM nodes WHERE rowid = ?1 AND trashed = 0",
params![affected_rowid],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.optional()?;
if let Some((name, indexed_uid)) = indexed {
let path = path_of(&tx, &indexed_uid)?;
tx.execute(
"INSERT INTO nodes_fts (rowid, name, path) VALUES (?1, ?2, ?3)",
params![affected_rowid, name, path],
)?;
}
}
} }
tx.commit()?; tx.commit()?;
Ok(()) Ok(())
} }
/// Return visible direct children without consulting the parent's `listed`
/// flag. Synthetic shared listings use this to serve the last completed
/// snapshot while offline, including after a live event expired its TTL.
pub fn visible_children(&self, parent: &NodeUid) -> Result<Vec<Node>> {
let conn = self.conn.lock();
let mut stmt = conn.prepare(
"SELECT node_json FROM nodes
WHERE parent_uid = ?1 AND trashed = 0 AND node_json IS NOT NULL
ORDER BY name, uid",
)?;
let rows = stmt.query_map([parent.to_string()], |row| row.get::<_, String>(0))?;
let mut nodes = Vec::new();
for row in rows {
nodes.push(serde_json::from_str(&row?)?);
}
Ok(nodes)
}
/// Atomically publish one completed `Shared with me` root listing.
///
/// Roots absent from `accepted` and every persisted descendant are
/// tombstoned, not deleted. Accepted roots omitted from materialization keep
/// their visible snapshot but are downgraded to Viewer until membership is
/// verified again. Both cases preserve queued operations and staged writes.
pub fn publish_shared_roots(
&self,
parent: &NodeUid,
accepted: &[NodeUid],
roots: &[PublishedSharedRoot],
) -> Result<Vec<NodeUid>> {
let parent = parent.to_string();
let present: HashSet<String> = accepted.iter().map(NodeUid::to_string).collect();
let materialized: HashSet<String> = roots
.iter()
.map(|published| published.node.uid.to_string())
.collect();
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
let existing = direct_child_uids_tx(&tx, &parent)?;
let removed: Vec<String> = existing
.into_iter()
.filter(|uid| !present.contains(uid))
.collect();
for published in roots {
if !present.contains(&published.node.uid.to_string()) {
continue;
}
upsert_node_tx(&tx, &published.node)?;
upsert_share_access_tx(&tx, &published.node.uid.to_string(), published.access)?;
}
for uid in present.difference(&materialized) {
upsert_share_access_tx(&tx, uid, Access::Viewer)?;
}
for root in &removed {
tombstone_subtree_tx(&tx, root)?;
upsert_share_access_tx(&tx, root, Access::Viewer)?;
}
tx.execute(
"UPDATE nodes SET listed = 1 WHERE uid = ?1",
params![parent],
)?;
tx.commit()?;
Ok(removed
.into_iter()
.filter_map(|uid| parse_node_uid(&uid))
.collect())
}
/// Atomically withdraw a deleted foreign subtree and deny stale handles.
///
/// Node rows and queued operations remain so retries and staged writes keep
/// their references. FTS and completed-listing state are withdrawn for the
/// whole subtree, and the deleted UID becomes a fail-closed authority.
pub fn tombstone_foreign_subtree(&self, uid: &NodeUid) -> Result<()> {
let uid = uid.to_string();
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
tombstone_subtree_tx(&tx, &uid)?;
upsert_share_access_tx(&tx, &uid, Access::Viewer)?;
tx.commit()?;
Ok(())
}
/// Publish a completed foreign-folder listing from its authoritative UID
/// list and the subset that materialized successfully. Accepted-but-omitted
/// children retain their previous snapshot; only absent UIDs are tombstoned.
pub fn publish_foreign_children(
&self,
parent: &NodeUid,
accepted: &[NodeUid],
materialized: &[Node],
) -> Result<Vec<NodeUid>> {
let parent = parent.to_string();
let present: HashSet<String> = accepted.iter().map(NodeUid::to_string).collect();
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
let removed: Vec<String> = direct_child_uids_tx(&tx, &parent)?
.into_iter()
.filter(|uid| !present.contains(uid))
.collect();
for node in materialized {
if present.contains(&node.uid.to_string()) {
upsert_node_tx(&tx, node)?;
}
}
for uid in &removed {
tombstone_subtree_tx(&tx, uid)?;
}
tx.execute(
"UPDATE nodes SET listed = 1 WHERE uid = ?1",
params![parent],
)?;
tx.commit()?;
Ok(removed
.into_iter()
.filter_map(|uid| parse_node_uid(&uid))
.collect())
}
/// Atomically publish the synthetic root's pinned name, node/FTS state, and
/// Viewer authority.
///
/// The pin is inserted last so any failure there rolls back the node and
/// its descendant FTS updates as well as the access row. Returning `false`
/// guarantees no write occurred, which keeps cached root lookups O(1).
pub fn publish_virtual_root(&self, pinned_name_key: &str, node: &Node) -> Result<bool> {
let uid = node.uid.to_string();
let desired_parent = node.parent_uid.as_ref().map(NodeUid::to_string);
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
let pinned_name: Option<String> = tx
.query_row(
"SELECT value FROM sync_state WHERE key = ?1",
params![pinned_name_key],
|row| row.get(0),
)
.optional()?;
let stored: Option<(Option<String>, String, i64, i64)> = tx
.query_row(
"SELECT parent_uid, name, is_dir, trashed FROM nodes WHERE uid = ?1",
params![uid],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
)
.optional()?;
let access: Option<String> = tx
.query_row(
"SELECT access FROM share_access WHERE root_uid = ?1",
params![uid],
|row| row.get(0),
)
.optional()?;
let unchanged = stored.is_some_and(|(parent, name, is_dir, trashed)| {
parent == desired_parent
&& name == node.name
&& is_dir == 1
&& trashed == node.trashed as i64
}) && access.as_deref() == Some(Access::Viewer.as_db_str())
&& pinned_name.is_some();
if unchanged {
tx.commit()?;
return Ok(false);
}
upsert_share_access_tx(&tx, &uid, Access::Viewer)?;
upsert_node_tx(&tx, node)?;
tx.execute(
"INSERT INTO sync_state (key, value) VALUES (?1, ?2)
ON CONFLICT(key) DO NOTHING",
params![pinned_name_key, node.name],
)?;
tx.commit()?;
Ok(true)
}
/// Drop a node row (delete or trash from the hot cache). Children rows are /// Drop a node row (delete or trash from the hot cache). Children rows are
/// not cascaded here; the daemon forgets a whole subtree node-by-node. /// not cascaded here; the daemon forgets a whole subtree node-by-node.
pub fn delete_node(&self, uid: &NodeUid) -> Result<()> { pub fn delete_node(&self, uid: &NodeUid) -> Result<()> {
@@ -148,6 +256,32 @@ impl Db {
Ok(()) Ok(())
} }
/// Atomically retire a landed trash operation and its retained authority.
///
/// Queued trash keeps the node row so drain-time permission checks can
/// resolve its shared-tree access. Removing the op first could resurrect
/// that row after a crash; removing the row first could strand a now
/// unauthorizable op. One transaction closes both windows.
pub fn complete_trash_op(&self, op_id: i64, uid: &NodeUid) -> Result<()> {
let uid = uid.to_string();
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
let rowid: Option<i64> = tx
.query_row(
"SELECT rowid FROM nodes WHERE uid = ?1",
params![uid],
|row| row.get(0),
)
.optional()?;
tx.execute("DELETE FROM nodes WHERE uid = ?1", params![uid])?;
if let Some(rowid) = rowid {
tx.execute("DELETE FROM nodes_fts WHERE rowid = ?1", params![rowid])?;
}
tx.execute("DELETE FROM pending_op WHERE id = ?1", params![op_id])?;
tx.commit()?;
Ok(())
}
/// Check if a folder node has any non-trashed children in the database. /// Check if a folder node has any non-trashed children in the database.
pub fn has_children(&self, parent_uid: &NodeUid) -> Result<bool> { pub fn has_children(&self, parent_uid: &NodeUid) -> Result<bool> {
let uid_str = parent_uid.to_string(); let uid_str = parent_uid.to_string();
@@ -387,6 +521,168 @@ impl Db {
// in sync on every store/read/evict, so it is authoritative for eviction. // in sync on every store/read/evict, so it is authoritative for eviction.
} }
fn upsert_node_tx(tx: &Transaction<'_>, node: &Node) -> Result<()> {
let json = serde_json::to_string(node)?;
let uid = node.uid.to_string();
tx.execute(
"INSERT INTO nodes
(uid, parent_uid, name, is_dir, size, mtime, trashed, node_json)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
ON CONFLICT(uid) DO UPDATE SET
parent_uid = excluded.parent_uid,
name = excluded.name,
is_dir = excluded.is_dir,
size = excluded.size,
mtime = excluded.mtime,
trashed = excluded.trashed,
node_json = excluded.node_json",
params![
uid,
node.parent_uid.as_ref().map(|u| u.to_string()),
node.name,
node.is_folder() as i64,
node_size(node),
node.modification_time,
node.trashed as i64,
json,
],
)?;
// FTS5 has no UPSERT. Folder moves also change every descendant path, so
// refresh the whole affected subtree while keeping rowid deletes indexed.
let rowid: i64 = tx.query_row(
"SELECT rowid FROM nodes WHERE uid = ?1",
params![uid],
|row| row.get(0),
)?;
let mut affected = vec![rowid];
if node.is_folder() {
let mut stmt = tx.prepare(
"WITH RECURSIVE descendants(rowid, uid) AS (
SELECT rowid, uid FROM nodes WHERE parent_uid = ?1
UNION ALL
SELECT n.rowid, n.uid FROM nodes n
JOIN descendants d ON n.parent_uid = d.uid
) SELECT rowid FROM descendants",
)?;
for descendant in stmt.query_map(params![uid], |row| row.get(0))? {
affected.push(descendant?);
}
}
for affected_rowid in affected {
tx.execute(
"DELETE FROM nodes_fts WHERE rowid = ?1",
params![affected_rowid],
)?;
let indexed: Option<(String, String)> = tx
.query_row(
"SELECT name, uid FROM nodes WHERE rowid = ?1",
params![affected_rowid],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.optional()?;
if let Some((name, indexed_uid)) = indexed
&& node_is_indexable_tx(tx, &indexed_uid)?
{
let path = path_of(tx, &indexed_uid)?;
tx.execute(
"INSERT INTO nodes_fts (rowid, name, path) VALUES (?1, ?2, ?3)",
params![affected_rowid, name, path],
)?;
}
}
Ok(())
}
fn direct_child_uids_tx(tx: &Transaction<'_>, parent: &str) -> Result<Vec<String>> {
let mut stmt = tx.prepare("SELECT uid FROM nodes WHERE parent_uid = ?1")?;
let rows = stmt.query_map([parent], |row| row.get::<_, String>(0))?;
let mut uids = Vec::new();
for row in rows {
uids.push(row?);
}
Ok(uids)
}
fn node_is_indexable_tx(tx: &Transaction<'_>, uid: &str) -> Result<bool> {
let indexable: i64 = tx.query_row(
"WITH RECURSIVE ancestors(uid, parent_uid, trashed, path) AS (
SELECT uid, parent_uid, trashed, char(31) || uid || char(31)
FROM nodes WHERE uid = ?1
UNION ALL
SELECT n.uid, n.parent_uid, n.trashed,
a.path || n.uid || char(31)
FROM ancestors a JOIN nodes n ON n.uid = a.parent_uid
WHERE instr(a.path, char(31) || n.uid || char(31)) = 0
)
SELECT CASE
WHEN COUNT(*) = 0 THEN 0
WHEN MAX(trashed) != 0 THEN 0
WHEN MAX(CASE WHEN parent_uid IS NULL THEN 1 ELSE 0 END) = 0 THEN 0
ELSE 1
END
FROM ancestors",
[uid],
|row| row.get(0),
)?;
Ok(indexable == 1)
}
fn tombstone_subtree_tx(tx: &Transaction<'_>, root_uid: &str) -> Result<()> {
let rows = {
let mut stmt = tx.prepare(
"WITH RECURSIVE subtree(rowid, uid, node_json) AS (
SELECT rowid, uid, node_json FROM nodes WHERE uid = ?1
UNION
SELECT n.rowid, n.uid, n.node_json FROM nodes n
JOIN subtree s ON n.parent_uid = s.uid
)
SELECT rowid, uid, node_json FROM subtree",
)?;
let mapped = stmt.query_map([root_uid], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, Option<String>>(2)?,
))
})?;
let mut rows = Vec::new();
for row in mapped {
rows.push(row?);
}
rows
};
for (rowid, uid, json) in rows {
let json = match json {
Some(json) => {
let mut node: Node = serde_json::from_str(&json)?;
node.trashed = true;
Some(serde_json::to_string(&node)?)
}
None => None,
};
tx.execute(
"UPDATE nodes SET trashed = 1, listed = 0, node_json = ?2 WHERE uid = ?1",
params![uid, json],
)?;
tx.execute("DELETE FROM nodes_fts WHERE rowid = ?1", [rowid])?;
}
Ok(())
}
fn upsert_share_access_tx(tx: &Transaction<'_>, uid: &str, access: Access) -> Result<()> {
tx.execute(
"INSERT INTO share_access (root_uid, access) VALUES (?1, ?2)
ON CONFLICT(root_uid) DO UPDATE SET access = excluded.access",
params![uid, access.as_db_str()],
)?;
Ok(())
}
fn parse_node_uid(value: &str) -> Option<NodeUid> {
let (volume, link) = value.split_once('~')?;
Some(NodeUid::new(VolumeId::from(volume), LinkId::from(link)))
}
/// Unique quoted character trigrams suitable for an FTS5 OR expression. /// Unique quoted character trigrams suitable for an FTS5 OR expression.
pub(super) fn candidate_trigrams(query: &str) -> Vec<String> { pub(super) fn candidate_trigrams(query: &str) -> Vec<String> {
let chars: Vec<char> = query.to_lowercase().chars().collect(); let chars: Vec<char> = query.to_lowercase().chars().collect();
+100
View File
@@ -3,6 +3,7 @@
//! order, so a child never drains before the parent that gives it a real uid. //! order, so a child never drains before the parent that gives it a real uid.
use rusqlite::{OptionalExtension, params}; use rusqlite::{OptionalExtension, params};
use serde::{Deserialize, Serialize};
use super::Db; use super::Db;
use crate::Result; use crate::Result;
@@ -35,6 +36,16 @@ pub const OP_MKDIR: &str = "mkdir";
/// that folder's own drain, exactly as for [`OP_CREATE`]. /// that folder's own drain, exactly as for [`OP_CREATE`].
pub const OP_RENAME: &str = "rename"; pub const OP_RENAME: &str = "rename";
/// Drain-time authority retained by a queued rename.
///
/// The optimistic local move rewrites the node's persisted ancestry to the
/// destination immediately. Keeping the original parent here prevents that
/// rewrite from erasing the shared tree that admitted the move.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RenameMeta {
pub original_parent_uid: String,
}
/// The `kind` of a [`PendingOp`] that trashes a node the server knows about /// The `kind` of a [`PendingOp`] that trashes a node the server knows about
/// (offline.md Phase 3b). /// (offline.md Phase 3b).
/// ///
@@ -127,6 +138,32 @@ pub struct AttachedBlob {
} }
impl Db { impl Db {
/// Whether a specific desired-state operation is still queued for a node.
pub fn has_pending_op(&self, uid: &str, kind: &str) -> Result<bool> {
let conn = self.conn.lock();
conn.query_row(
"SELECT EXISTS(
SELECT 1 FROM pending_op WHERE uid = ?1 AND kind = ?2
)",
params![uid, kind],
|row| row.get(0),
)
.map_err(Into::into)
}
/// Read an existing operation's metadata before a superseding enqueue.
pub fn pending_op_meta(&self, uid: &str, kind: &str) -> Result<Option<String>> {
let conn = self.conn.lock();
conn.query_row(
"SELECT meta_json FROM pending_op WHERE uid = ?1 AND kind = ?2",
params![uid, kind],
|row| row.get(0),
)
.optional()
.map(|value| value.flatten())
.map_err(Into::into)
}
pub fn enqueue_op(&self, op: &PendingOp) -> Result<(i64, Option<String>)> { pub fn enqueue_op(&self, op: &PendingOp) -> Result<(i64, Option<String>)> {
let mut conn = self.conn.lock(); let mut conn = self.conn.lock();
let tx = conn.transaction()?; let tx = conn.transaction()?;
@@ -167,6 +204,50 @@ impl Db {
Ok((id, superseded)) Ok((id, superseded))
} }
/// Atomically replace every queued operation for `uid` and its queued
/// descendants with one remote trash intent.
///
/// Staged blob paths are returned only after the transaction commits. A
/// failed trash insert therefore rolls the deletions back and leaves every
/// prior operation owning its bytes.
pub fn replace_ops_with_trash(
&self,
uid: &str,
name: &str,
created_at: i64,
) -> Result<(i64, Vec<String>)> {
const SUBTREE: &str = "
WITH RECURSIVE doomed(uid) AS (
SELECT ?1
UNION
SELECT p.uid FROM pending_op p JOIN doomed d ON p.parent_uid = d.uid
)";
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
let blobs: Vec<String> = {
let mut stmt = tx.prepare(&format!(
"{SUBTREE}
SELECT blob_path FROM pending_op
WHERE uid IN (SELECT uid FROM doomed) AND blob_path IS NOT NULL"
))?;
let rows = stmt.query_map(params![uid], |row| row.get::<_, String>(0))?;
rows.collect::<std::result::Result<Vec<_>, _>>()?
};
tx.execute(
&format!("{SUBTREE} DELETE FROM pending_op WHERE uid IN (SELECT uid FROM doomed)"),
params![uid],
)?;
tx.execute(
"INSERT INTO pending_op
(kind, uid, parent_uid, name, blob_path, meta_json, created_at, next_attempt_at)
VALUES (?1, ?2, NULL, ?3, NULL, NULL, ?4, 0)",
params![OP_TRASH, uid, name, created_at],
)?;
let id = tx.last_insert_rowid();
tx.commit()?;
Ok((id, blobs))
}
/// Point a queued create at the bytes that were just written to it, returning /// Point a queued create at the bytes that were just written to it, returning
/// any blob it previously held so the caller can discard it. /// any blob it previously held so the caller can discard it.
/// ///
@@ -509,4 +590,23 @@ impl Db {
)?; )?;
Ok(()) Ok(())
} }
/// Defer a due operation without recording a failed remote attempt.
///
/// Access checks happen before the drain calls Proton, so a locally denied
/// operation has not failed remotely and must not consume a retry. Preserve
/// the long-lived transient-create sentinel if this is ever called for one.
pub fn defer_op_without_attempt(&self, id: i64, next_attempt_at: i64) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"UPDATE pending_op
SET next_attempt_at = CASE
WHEN next_attempt_at >= ?3 THEN next_attempt_at
ELSE ?2
END
WHERE id = ?1",
params![id, next_attempt_at, PARK_UNTIL],
)?;
Ok(())
}
} }
+143
View File
@@ -0,0 +1,143 @@
//! Persisted effective access for roots shared with this account.
use std::collections::HashMap;
use proton_drive_rs::proton_sdk::ids::{LinkId, NodeUid, VolumeId};
use rusqlite::{OptionalExtension, params};
use super::Db;
use crate::{Access, Error, Result};
impl Db {
/// Load the complete, deliberately small shared-root authority map.
///
/// Mount state keeps this resident so ordinary node interning and subtree
/// propagation never turn into one SQLite query per inode. The database
/// remains the restart/offline authority and every explicit role change is
/// written through before this cache is updated.
pub fn all_share_access(&self) -> Result<HashMap<NodeUid, Access>> {
let conn = self.conn.lock();
let mut stmt = conn.prepare("SELECT root_uid, access FROM share_access")?;
let rows = stmt.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})?;
let mut access = HashMap::new();
for row in rows {
let (uid, value) = row?;
let (volume, link) = uid.split_once('~').ok_or_else(|| {
Error::Other(format!(
"invalid shared-root uid {uid:?} stored in database"
))
})?;
let access_value = Access::from_db_str(&value).ok_or_else(|| {
Error::Other(format!("invalid share access {value:?} stored for {uid}"))
})?;
access.insert(
NodeUid::new(VolumeId::from(volume), LinkId::from(link)),
access_value,
);
}
Ok(access)
}
/// Read the effective access recorded for a shared-tree root.
pub fn share_access(&self, uid: &NodeUid) -> Result<Option<Access>> {
let conn = self.conn.lock();
let value = conn
.query_row(
"SELECT access FROM share_access WHERE root_uid = ?1",
[uid.to_string()],
|row| row.get::<_, String>(0),
)
.optional()?;
value
.map(|value| {
Access::from_db_str(&value).ok_or_else(|| {
Error::Other(format!("invalid share access {value:?} stored for {uid}"))
})
})
.transpose()
}
/// Insert or replace the effective access for a shared-tree root.
pub fn set_share_access(&self, uid: &NodeUid, access: Access) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"INSERT INTO share_access (root_uid, access) VALUES (?1, ?2)
ON CONFLICT(root_uid) DO UPDATE SET access = excluded.access",
params![uid.to_string(), access.as_db_str()],
)?;
Ok(())
}
/// Forget a shared-tree root's effective access.
pub fn delete_share_access(&self, uid: &NodeUid) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"DELETE FROM share_access WHERE root_uid = ?1",
[uid.to_string()],
)?;
Ok(())
}
/// Fail closed every persisted shared-tree authority.
///
/// Owned and device trees have no row in `share_access`, so this only
/// affects roots already identified as shared with this account.
pub fn downgrade_all_share_access(&self) -> Result<usize> {
let conn = self.conn.lock();
Ok(conn.execute(
"UPDATE share_access SET access = 'viewer' WHERE access != 'viewer'",
[],
)?)
}
/// Resolve a persisted node's effective access through its nearest recorded
/// share root. `None` means the node itself is not persisted, which callers
/// must not treat as owned: stale handles need to fail closed.
pub fn effective_node_access(&self, uid: &NodeUid) -> Result<Option<Access>> {
let conn = self.conn.lock();
let exists: bool = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM nodes WHERE uid = ?1)",
[uid.to_string()],
|row| row.get(0),
)?;
if !exists {
return Ok(None);
}
let value = conn
.query_row(
"WITH RECURSIVE ancestors(uid, parent_uid, depth, path) AS (
SELECT uid, parent_uid, 0, char(31) || uid || char(31)
FROM nodes WHERE uid = ?1
UNION ALL
SELECT a.parent_uid, n.parent_uid, a.depth + 1,
a.path || a.parent_uid || char(31)
FROM ancestors a
LEFT JOIN nodes n ON n.uid = a.parent_uid
WHERE a.parent_uid IS NOT NULL
AND instr(
a.path,
char(31) || a.parent_uid || char(31)
) = 0
)
SELECT sa.access
FROM ancestors a JOIN share_access sa ON sa.root_uid = a.uid
ORDER BY a.depth ASC
LIMIT 1",
[uid.to_string()],
|row| row.get::<_, String>(0),
)
.optional()?;
value
.map(|value| {
Access::from_db_str(&value).ok_or_else(|| {
Error::Other(format!(
"invalid effective share access {value:?} for {uid}"
))
})
})
.transpose()
.map(|access| Some(access.unwrap_or(Access::Owner)))
}
}
+42 -5
View File
@@ -115,14 +115,17 @@ impl Db {
Ok(n > 0) Ok(n > 0)
} }
/// Update a synced folder's mode (`mirror`/`ondemand`). The folder has /// Update a synced folder's mode (`mirror`/`ondemand`). A queued request for
/// reached the mode it was asked for, so any queued request is satisfied and /// that mode is satisfied and cleared in the same write. An opposite pending
/// cleared in the same write — a `pending_mode` outliving the switch it asked /// mode arrived after this transition started, so preserve it as the newer
/// for would have the engine try to apply it again on the next pass. /// last-request intent.
pub fn sync_folder_set_mode(&self, id: i64, mode: &str) -> Result<()> { pub fn sync_folder_set_mode(&self, id: i64, mode: &str) -> Result<()> {
let conn = self.conn.lock(); let conn = self.conn.lock();
conn.execute( conn.execute(
"UPDATE sync_folder SET mode = ?2, pending_mode = NULL WHERE id = ?1", "UPDATE sync_folder
SET mode = ?2,
pending_mode = CASE WHEN pending_mode = ?2 THEN NULL ELSE pending_mode END
WHERE id = ?1",
params![id, mode], params![id, mode],
)?; )?;
Ok(()) Ok(())
@@ -139,6 +142,18 @@ impl Db {
Ok(()) Ok(())
} }
/// Withdraw a queued mode only if it is still the intent the caller
/// inspected. A newer request must survive an older settlement attempt.
pub fn sync_folder_clear_pending_mode_if(&self, id: i64, expected: &str) -> Result<bool> {
let conn = self.conn.lock();
let changed = conn.execute(
"UPDATE sync_folder SET pending_mode = NULL
WHERE id = ?1 AND pending_mode = ?2",
params![id, expected],
)?;
Ok(changed > 0)
}
/// Update a synced folder's state and stamp `last_sync` to now. /// Update a synced folder's state and stamp `last_sync` to now.
pub fn sync_folder_set_state(&self, id: i64, state: &str, last_sync: i64) -> Result<()> { pub fn sync_folder_set_state(&self, id: i64, state: &str, last_sync: i64) -> Result<()> {
let conn = self.conn.lock(); let conn = self.conn.lock();
@@ -174,6 +189,28 @@ impl Db {
Ok(map) Ok(map)
} }
/// Whether `uid` is represented by a mirror location.
///
/// An on-demand location proves residency through its live inode state.
/// Mirror locations have no FUSE state, so their folder root comes from
/// `sync_folder` and descendants come from the last successful
/// `sync_entry` baseline.
pub fn mirror_contains_uid(&self, uid: &str) -> Result<bool> {
let conn = self.conn.lock();
conn.query_row(
"SELECT EXISTS(
SELECT 1
FROM sync_folder AS folder
LEFT JOIN sync_entry AS entry ON entry.folder_id = folder.id
WHERE folder.mode = 'mirror'
AND (folder.remote_uid = ?1 OR entry.remote_uid = ?1)
)",
params![uid],
|row| row.get(0),
)
.map_err(Into::into)
}
/// Insert or replace one baseline row. /// Insert or replace one baseline row.
pub fn sync_entry_upsert(&self, folder_id: i64, e: &StoredSyncEntry) -> Result<()> { pub fn sync_entry_upsert(&self, folder_id: i64, e: &StoredSyncEntry) -> Result<()> {
let conn = self.conn.lock(); let conn = self.conn.lock();
File diff suppressed because it is too large. Load diff
+3
View File
@@ -2,6 +2,7 @@
//! identity, XDG paths, and the login/session/keyring layer that every //! identity, XDG paths, and the login/session/keyring layer that every
//! front-end (CLI today, daemon + GUI later) shares. //! front-end (CLI today, daemon + GUI later) shares.
pub mod access;
pub mod auth; pub mod auth;
pub mod cache; pub mod cache;
pub mod config; pub mod config;
@@ -9,12 +10,14 @@ pub mod control;
pub mod db; pub mod db;
pub mod error; pub mod error;
pub mod localindex; pub mod localindex;
pub mod mounts;
pub mod profile; pub mod profile;
pub mod search; pub mod search;
pub mod service; pub mod service;
pub mod shell; pub mod shell;
pub mod syncignore; pub mod syncignore;
pub use access::{Access, access_for, perm_bits};
pub use error::{CoreError, CoreResult, Error, Result}; pub use error::{CoreError, CoreResult, Error, Result};
// Re-export the SDK surface downstream crates need, so they depend on // Re-export the SDK surface downstream crates need, so they depend on
+146
View File
@@ -0,0 +1,146 @@
//! Unified presentation model for every local Proton Drive location.
//!
//! Device sync state remains owned by `sync_folder`; these types are the stable
//! shape exposed to front-ends after that state is joined with the `mount`
//! presentation table.
use std::fmt;
use serde::{Deserialize, Serialize};
use crate::control::SyncProgress;
/// How a location makes remote content available locally.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum MountMode {
/// A full local copy reconciled in both directions.
Mirror,
/// A FUSE session that fetches content as it is accessed.
OnDemand,
/// A mode written by a newer client.
#[default]
#[serde(other)]
Unknown,
}
impl MountMode {
pub const fn as_str(self) -> &'static str {
match self {
Self::Mirror => "mirror",
Self::OnDemand => "ondemand",
Self::Unknown => "unknown",
}
}
}
impl From<&str> for MountMode {
fn from(value: &str) -> Self {
match value {
"mirror" => Self::Mirror,
"ondemand" => Self::OnDemand,
_ => Self::Unknown,
}
}
}
impl fmt::Display for MountMode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
/// Whether the location itself was declared writable.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum MountAccess {
Rw,
Ro,
}
impl MountAccess {
pub const fn as_str(self) -> &'static str {
match self {
Self::Rw => "rw",
Self::Ro => "ro",
}
}
}
impl fmt::Display for MountAccess {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
/// What remote root a local location presents.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum MountKind {
/// The account's primary My Files mount.
MyFiles,
/// A folder owned by this machine's Proton Drive device.
Device { sync_folder_id: i64 },
/// A future standalone mount rooted at a node shared with this account.
Shared { share_root_uid: String },
}
/// One local Proton Drive location as exposed over the control protocol.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct MountSpec {
/// Presentation-row id. Device actions use the `sync_folder_id` carried by
/// [`MountKind::Device`], not this id.
pub id: i64,
pub kind: MountKind,
pub local_path: String,
pub root_uid: String,
pub root_share_id: String,
pub mode: MountMode,
pub access: MountAccess,
/// `idle` | `syncing` | `error` | `conflict` for device locations.
pub state: String,
/// Last successful device sync, epoch seconds; `0` when not applicable.
pub last_sync: i64,
/// A device mode switch accepted but not yet applied.
#[serde(default)]
pub pending_mode: Option<MountMode>,
/// Whether a FUSE session currently owns `local_path`.
#[serde(default)]
pub mounted: bool,
/// Live device sync progress, absent when no pass is running.
#[serde(default)]
pub progress: Option<SyncProgress>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mount_mode_keeps_legacy_wire_strings() {
assert_eq!(
serde_json::to_string(&MountMode::Mirror).unwrap(),
r#""mirror""#
);
assert_eq!(
serde_json::to_string(&MountMode::OnDemand).unwrap(),
r#""ondemand""#
);
assert_eq!(
serde_json::from_str::<MountMode>(r#""mirror""#).unwrap(),
MountMode::Mirror
);
assert_eq!(
serde_json::from_str::<MountMode>(r#""ondemand""#).unwrap(),
MountMode::OnDemand
);
}
#[test]
fn mount_mode_tolerates_unknown_values() {
assert_eq!(
serde_json::from_str::<MountMode>(r#""from-the-future""#).unwrap(),
MountMode::Unknown
);
}
}
+52 -3
View File
@@ -17,6 +17,8 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::mounts::MountMode;
/// File name of the profile inside the device root folder. /// File name of the profile inside the device root folder.
pub const PROFILE_FILE_NAME: &str = "profile.json"; pub const PROFILE_FILE_NAME: &str = "profile.json";
@@ -39,8 +41,9 @@ pub struct ProfileFolder {
/// Absolute local path it was synced to. A *suggestion* on restore: another /// Absolute local path it was synced to. A *suggestion* on restore: another
/// machine may have a different username, home, or mount layout. /// machine may have a different username, home, or mount layout.
pub local_path: String, pub local_path: String,
/// `mirror` or `ondemand`. /// `mirror` or `ondemand`. Unknown values written by a newer client do not
pub mode: String, /// invalidate the rest of the profile.
pub mode: MountMode,
} }
/// One pinned node (P5 pins), as `pins` stores it. /// One pinned node (P5 pins), as `pins` stores it.
@@ -114,7 +117,7 @@ mod tests {
folders: vec![ProfileFolder { folders: vec![ProfileFolder {
remote_uid: "vol~link".to_string(), remote_uid: "vol~link".to_string(),
local_path: "/home/nils/Documents".to_string(), local_path: "/home/nils/Documents".to_string(),
mode: "mirror".to_string(), mode: MountMode::Mirror,
}], }],
pins: vec![ProfilePin { pins: vec![ProfilePin {
uid: "vol~pin".to_string(), uid: "vol~pin".to_string(),
@@ -133,6 +136,52 @@ mod tests {
assert_eq!(Profile::parse(&p.to_bytes().unwrap()).unwrap(), p); assert_eq!(Profile::parse(&p.to_bytes().unwrap()).unwrap(), p);
} }
#[test]
fn profile_keeps_legacy_mode_bytes_and_parses_old_documents() {
let bytes = sample().to_bytes().unwrap();
let json = String::from_utf8(bytes).unwrap();
assert!(json.contains(r#""mode": "mirror""#));
let old = br#"{
"version": 1,
"device_uid": "dev-1",
"hostname": "old-client",
"saved_at": 1,
"folders": [
{
"remote_uid": "vol~mirror",
"local_path": "/home/me/Mirror",
"mode": "mirror"
},
{
"remote_uid": "vol~ondemand",
"local_path": "/home/me/Cloud",
"mode": "ondemand"
}
]
}"#;
let profile = Profile::parse(old).unwrap();
assert_eq!(profile.folders[0].mode, MountMode::Mirror);
assert_eq!(profile.folders[1].mode, MountMode::OnDemand);
}
#[test]
fn unknown_folder_mode_does_not_reject_profile() {
let json = br#"{
"version": 1,
"device_uid": "dev-1",
"hostname": "future-client",
"saved_at": 1,
"folders": [{
"remote_uid": "vol~future",
"local_path": "/home/me/Future",
"mode": "streaming"
}]
}"#;
let profile = Profile::parse(json).unwrap();
assert_eq!(profile.folders[0].mode, MountMode::Unknown);
}
/// A field added by a later client must not break this one's restore: the /// A field added by a later client must not break this one's restore: the
/// common subset is still usable, which is the whole reason `version` is /// common subset is still usable, which is the whole reason `version` is
/// reserved for breaking changes only. /// reserved for breaking changes only.
+413 -16
View File
@@ -27,6 +27,83 @@ fn forget_and_notify(st: &mut State, notifier: Option<&Notifier>, uid: &NodeUid)
true true
} }
fn invalidate_access_changes(notifier: Option<&Notifier>, changed: &[u64]) {
let Some(notifier) = notifier else {
return;
};
for &ino in changed {
let _ = notifier.inval_inode(INodeNo(ino), 0, 0);
}
}
fn hide_foreign_deleted_and_notify(st: &mut State, notifier: Option<&Notifier>, uid: &NodeUid) {
let child = st.by_uid.get(uid).copied();
let dentry = child.and_then(|ino| {
st.entries
.get(&ino)
.map(|entry| (entry.parent, entry.node.name.clone()))
});
let changed = st.downgrade_shared_subtree(uid);
st.hide_foreign_subtree(uid);
invalidate_access_changes(notifier, &changed);
if let (Some(notifier), Some((parent, name)), Some(child)) = (notifier, dentry, child) {
let _ = notifier.delete(INodeNo(parent), INodeNo(child), OsStr::new(&name));
}
}
/// Persist the fail-closed part of an access event and install the same denial
/// in resident state even when SQLite rejects the write. The caller must not
/// acknowledge the event when this returns an error.
fn apply_access_downgrade(
db: &Db,
event: &DriveEvent,
mut deny: impl FnMut(Option<&NodeUid>),
) -> pdfs_core::Result<()> {
match event {
DriveEvent::NodeDeleted { node_uid, .. } => {
let result = db.set_share_access(node_uid, Access::Viewer);
deny(Some(node_uid));
result
}
DriveEvent::ContinuityLost { .. }
| DriveEvent::ScopeAccessLost { .. }
| DriveEvent::SharedWithMeUpdated { .. } => {
let result = db.downgrade_all_share_access().map(|_| ());
deny(None);
result
}
_ => Ok(()),
}
}
fn apply_foreign_delete(
db: &Db,
uid: &NodeUid,
mut deny_and_hide: impl FnMut(&NodeUid),
) -> pdfs_core::Result<()> {
let result = db.tombstone_foreign_subtree(uid);
deny_and_hide(uid);
result
}
fn is_foreign_node_delete(event: &DriveEvent, own_volume: &VolumeId) -> bool {
match event {
DriveEvent::NodeDeleted { node_uid, .. } => {
!is_own_or_virtual_uid(node_uid, own_volume) && !is_local_uid(node_uid)
}
_ => false,
}
}
fn event_serializes_shared_publication(event: &DriveEvent, own_volume: &VolumeId) -> bool {
matches!(
event,
DriveEvent::ContinuityLost { .. }
| DriveEvent::ScopeAccessLost { .. }
| DriveEvent::SharedWithMeUpdated { .. }
) || is_foreign_node_delete(event, own_volume)
}
/// Apply one remote event to the local cache and notify the kernel so it drops /// Apply one remote event to the local cache and notify the kernel so it drops
/// any stale cached metadata/data for the affected inodes. /// any stale cached metadata/data for the affected inodes.
/// ///
@@ -43,7 +120,33 @@ fn forget_and_notify(st: &mut State, notifier: Option<&Notifier>, uid: &NodeUid)
/// read-side twin of `docs/BUGS.md` B74. Inode numbers are per-mount, so each /// read-side twin of `docs/BUGS.md` B74. Inode numbers are per-mount, so each
/// mount must be notified through its **own** channel; that pairing is what /// mount must be notified through its **own** channel; that pairing is what
/// [`Core::for_each_mount`] exists to preserve. /// [`Core::for_each_mount`] exists to preserve.
fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) { fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdfs_core::Result<()> {
let foreign_delete = is_foreign_node_delete(event, &core.primary_root_uid.volume_id);
let serializes_shared_publication =
event_serializes_shared_publication(event, &core.primary_root_uid.volume_id);
let _publication = serializes_shared_publication.then(|| core.shared_publication.lock());
if serializes_shared_publication {
core.invalidate_shared_refreshes();
}
let mut apply_result = if let DriveEvent::NodeDeleted { node_uid, .. } = event
&& foreign_delete
{
apply_foreign_delete(&core.db, node_uid, |uid| {
core.for_each_mount(|st, notifier| {
hide_foreign_deleted_and_notify(st, notifier, uid);
});
})
} else {
apply_access_downgrade(&core.db, event, |root| {
core.for_each_mount(|st, notifier| {
let changed = match root {
Some(uid) => st.downgrade_shared_subtree(uid),
None => st.downgrade_known_shared_access(),
};
invalidate_access_changes(notifier, &changed);
});
})
};
match event { match event {
DriveEvent::NodeUpdated { DriveEvent::NodeUpdated {
node_uid, node_uid,
@@ -105,15 +208,21 @@ fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) {
} }
DriveEvent::NodeDeleted { node_uid, .. } => { DriveEvent::NodeDeleted { node_uid, .. } => {
core.cache.evict(node_uid); core.cache.evict(node_uid);
core.for_each_mount(|st, notifier| { if !foreign_delete {
forget_and_notify(st, notifier, node_uid); core.for_each_mount(|st, notifier| {
}); forget_and_notify(st, notifier, node_uid);
});
}
} }
// Continuity or scope was lost: our cached listings may be arbitrarily // Losing event continuity or any shared-access signal makes persisted
// stale, so drop every listing and tell the kernel to forget all // shared roots untrustworthy. Keep owned/device trees unchanged, but
// metadata. Inodes stay stable; dirs simply re-enumerate on next access. // fail every known shared subtree closed until a root refresh carries a
DriveEvent::ContinuityLost { .. } | DriveEvent::ScopeAccessLost { .. } => { // current membership role.
warn!("event continuity lost; dropping all cached listings, resyncing lazily"); DriveEvent::ContinuityLost { .. }
| DriveEvent::ScopeAccessLost { .. }
| DriveEvent::SharedWithMeUpdated { .. } => {
warn!("event access continuity lost; downgrading shared trees and resyncing lazily");
apply_result = apply_result.and(core.db.clear_state(SHARED_WITH_ME_SYNCED_MS));
core.for_each_mount(|st, notifier| { core.for_each_mount(|st, notifier| {
let dirs: Vec<u64> = st.children.keys().copied().collect(); let dirs: Vec<u64> = st.children.keys().copied().collect();
for &ino in &dirs { for &ino in &dirs {
@@ -126,8 +235,20 @@ fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) {
} }
// No substantive local change; the cursor advance is handled by the // No substantive local change; the cursor advance is handled by the
// caller persisting the event id. // caller persisting the event id.
DriveEvent::CursorAdvanced { .. } | DriveEvent::SharedWithMeUpdated { .. } => {} DriveEvent::CursorAdvanced { .. } => {}
} }
apply_result
}
fn acknowledge_applied_event(
db: &Db,
event: &DriveEvent,
applied: pdfs_core::Result<()>,
) -> pdfs_core::Result<DriveEventId> {
applied?;
let id = event.id().clone();
db.set_event_cursor(id.as_str())?;
Ok(id)
} }
/// The folders one batch of events changed, collected so the listings are /// The folders one batch of events changed, collected so the listings are
@@ -296,12 +417,17 @@ pub(super) async fn run_event_sync(
warn!(error = %e, "sdk cache invalidation for event failed; retaining cursor for retry"); warn!(error = %e, "sdk cache invalidation for event failed; retaining cursor for retry");
break; break;
} }
apply_event(&core, event, &mut dirty); let applied = match acknowledge_applied_event(
let applied = event.id().clone(); &db,
if let Err(e) = db.set_event_cursor(applied.as_str()) { event,
warn!(error = %e, "persist event cursor failed; retaining prior cursor for retry"); apply_event(&core, event, &mut dirty),
break; ) {
} Ok(applied) => applied,
Err(e) => {
warn!(error = %e, "event application or acknowledgment failed; retaining prior cursor for retry");
break;
}
};
// Advance only after this exact event is durably acknowledged. If a // Advance only after this exact event is durably acknowledged. If a
// later event fails, polling resumes from here and replays nothing // later event fails, polling resumes from here and replays nothing
// that was not already made visible locally. // that was not already made visible locally.
@@ -396,3 +522,274 @@ fn scan_local_once(
} }
indexing.store(false, Ordering::Relaxed); indexing.store(false, Ordering::Relaxed);
} }
#[cfg(test)]
mod tests {
use super::*;
use std::cell::Cell;
fn uid(link: &str) -> NodeUid {
NodeUid::new(VolumeId::from("vol"), LinkId::from(link))
}
fn folder(volume: &str, link: &str, parent: Option<NodeUid>, name: &str) -> Node {
Node {
uid: NodeUid::new(VolumeId::from(volume), LinkId::from(link)),
parent_uid: parent,
kind: NodeKind::Folder,
name: name.to_string(),
creation_time: 1,
modification_time: 1,
trashed: false,
is_shared: false,
is_shared_publicly: false,
signature_email: None,
membership: None,
verification: Default::default(),
}
}
#[test]
fn foreign_delete_invalidates_an_earlier_refresh_generation() {
let own_volume = VolumeId::from("own");
let event = DriveEvent::NodeDeleted {
id: DriveEventId::from("foreign-delete"),
node_uid: NodeUid::new(VolumeId::from("foreign"), LinkId::from("child")),
parent_node_uid: None,
};
let generation = AtomicU64::new(7);
let captured = generation.load(Ordering::SeqCst);
assert!(event_serializes_shared_publication(&event, &own_volume));
generation.fetch_add(1, Ordering::SeqCst);
assert!(!refresh_generation_is_current(
captured,
generation.load(Ordering::SeqCst)
));
let own_delete = DriveEvent::NodeDeleted {
id: DriveEventId::from("own-delete"),
node_uid: NodeUid::new(own_volume.clone(), LinkId::from("child")),
parent_node_uid: None,
};
assert!(!event_serializes_shared_publication(
&own_delete,
&own_volume
));
}
#[test]
fn foreign_delete_tombstones_subtree_and_advances_cursor_after_success() {
let db = Db::open(std::path::Path::new(":memory:")).unwrap();
let root = folder("own", "root", None, "My Files");
let deleted = folder(
"foreign",
"deleted",
Some(root.uid.clone()),
"Deleted foreign",
);
let deep = folder(
"foreign",
"deep",
Some(deleted.uid.clone()),
"Searchable descendant",
);
db.upsert_nodes(&[root, deleted.clone(), deep.clone()])
.unwrap();
db.set_share_access(&deleted.uid, Access::Editor).unwrap();
db.enqueue_op(&PendingOp {
id: 0,
kind: OP_RENAME.to_string(),
uid: deep.uid.to_string(),
parent_uid: Some(deleted.uid.to_string()),
name: Some("pending".to_string()),
blob_path: None,
meta_json: Some("{}".to_string()),
created_at: 1,
attempts: 0,
last_error: None,
next_attempt_at: 0,
})
.unwrap();
let event = DriveEvent::NodeDeleted {
id: DriveEventId::from("foreign-delete-success"),
node_uid: deleted.uid.clone(),
parent_node_uid: deleted.parent_uid.clone(),
};
let denied = Cell::new(false);
let applied = apply_foreign_delete(&db, &deleted.uid, |_| denied.set(true));
let acknowledged = acknowledge_applied_event(&db, &event, applied).unwrap();
assert!(denied.get());
assert_eq!(acknowledged.as_str(), "foreign-delete-success");
assert_eq!(
db.get_event_cursor().unwrap().as_deref(),
Some("foreign-delete-success")
);
for uid in [&deleted.uid, &deep.uid] {
assert!(db.node_by_uid(&uid.to_string()).unwrap().unwrap().trashed);
}
assert!(db.search("Searchable", 10).unwrap().is_empty());
assert_eq!(db.pending_ops().unwrap().len(), 1);
assert_eq!(db.share_access(&deleted.uid).unwrap(), Some(Access::Viewer));
}
#[test]
fn foreign_delete_db_failure_denies_memory_and_retains_prior_cursor() {
let db = Db::open(std::path::Path::new(":memory:")).unwrap();
let root = folder("own", "root", None, "My Files");
let deleted = folder(
"foreign",
"deleted",
Some(root.uid.clone()),
"Deleted foreign",
);
let deep = folder(
"foreign",
"deep",
Some(deleted.uid.clone()),
"Still searchable",
);
db.upsert_nodes(&[root, deleted.clone(), deep.clone()])
.unwrap();
db.set_share_access(&deleted.uid, Access::Editor).unwrap();
db.set_event_cursor("prior-event").unwrap();
db.with_conn(|conn| {
conn.execute_batch(
"CREATE TRIGGER reject_foreign_tombstone
BEFORE UPDATE OF trashed ON nodes
WHEN OLD.uid = 'foreign~deleted'
BEGIN SELECT RAISE(ABORT, 'forced tombstone failure'); END;",
)?;
Ok(())
})
.unwrap();
let event = DriveEvent::NodeDeleted {
id: DriveEventId::from("foreign-delete-failed"),
node_uid: deleted.uid.clone(),
parent_node_uid: deleted.parent_uid.clone(),
};
let denied = Cell::new(false);
let applied = apply_foreign_delete(&db, &deleted.uid, |_| denied.set(true));
assert!(applied.is_err());
assert!(
denied.get(),
"resident state must fail closed on DB failure"
);
assert!(acknowledge_applied_event(&db, &event, applied).is_err());
assert_eq!(
db.get_event_cursor().unwrap().as_deref(),
Some("prior-event")
);
assert!(
!db.node_by_uid(&deleted.uid.to_string())
.unwrap()
.unwrap()
.trashed
);
assert_eq!(db.search("searchable", 10).unwrap().len(), 1);
assert_eq!(
db.share_access(&deleted.uid).unwrap(),
Some(Access::Editor),
"the failed atomic transaction must not partially publish"
);
}
#[test]
fn deleted_share_downgrade_failure_denies_memory_and_retains_cursor() {
let db = Db::open(std::path::Path::new(":memory:")).unwrap();
let root = uid("shared");
db.set_share_access(&root, Access::Editor).unwrap();
db.with_conn(|conn| {
conn.execute_batch(
"CREATE TRIGGER reject_share_update
BEFORE UPDATE ON share_access
BEGIN SELECT RAISE(FAIL, 'forced downgrade failure'); END;",
)?;
Ok(())
})
.unwrap();
let event = DriveEvent::NodeDeleted {
id: DriveEventId::from("event-1"),
node_uid: root,
parent_node_uid: None,
};
let denied = Cell::new(false);
let applied = apply_access_downgrade(&db, &event, |root| {
denied.set(root.is_some());
});
assert!(applied.is_err());
assert!(denied.get(), "resident state must fail closed immediately");
assert!(acknowledge_applied_event(&db, &event, applied).is_err());
assert!(
db.get_event_cursor().unwrap().is_none(),
"failed downgrade must leave the event unacknowledged"
);
}
#[test]
fn deleted_resident_root_without_share_row_is_denied_and_tombstoned() {
let db = Db::open(std::path::Path::new(":memory:")).unwrap();
let root = uid("resident-without-row");
assert!(db.share_access(&root).unwrap().is_none());
let resident = std::cell::RefCell::new(std::collections::HashMap::from([(
root.clone(),
Access::Editor,
)]));
let event = DriveEvent::NodeDeleted {
id: DriveEventId::from("event-missing-row"),
node_uid: root.clone(),
parent_node_uid: None,
};
let applied = apply_access_downgrade(&db, &event, |target| {
assert_eq!(target, Some(&root));
resident.borrow_mut().insert(root.clone(), Access::Viewer);
});
assert!(applied.is_ok());
assert_eq!(resident.borrow().get(&root), Some(&Access::Viewer));
assert_eq!(db.share_access(&root).unwrap(), Some(Access::Viewer));
let acknowledged = acknowledge_applied_event(&db, &event, applied).unwrap();
assert_eq!(acknowledged.as_str(), "event-missing-row");
assert_eq!(
db.get_event_cursor().unwrap().as_deref(),
Some("event-missing-row")
);
}
#[test]
fn continuity_downgrade_failure_denies_memory_and_retains_cursor() {
let db = Db::open(std::path::Path::new(":memory:")).unwrap();
let root = uid("shared");
db.set_share_access(&root, Access::Editor).unwrap();
db.with_conn(|conn| {
conn.execute_batch(
"CREATE TRIGGER reject_all_share_updates
BEFORE UPDATE ON share_access
BEGIN SELECT RAISE(FAIL, 'forced global downgrade failure'); END;",
)?;
Ok(())
})
.unwrap();
let event = DriveEvent::ContinuityLost {
id: DriveEventId::from("event-2"),
};
let denied_all = Cell::new(false);
let applied = apply_access_downgrade(&db, &event, |root| {
denied_all.set(root.is_none());
});
assert!(applied.is_err());
assert!(
denied_all.get(),
"all resident shared roots must fail closed immediately"
);
assert!(acknowledge_applied_event(&db, &event, applied).is_err());
assert!(db.get_event_cursor().unwrap().is_none());
}
}
+78
View File
@@ -561,6 +561,10 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
Ok(items) => CtlResponse::Devices { items }, Ok(items) => CtlResponse::Devices { items },
Err(e) => CtlResponse::error(e), Err(e) => CtlResponse::error(e),
}, },
Ok(CtlRequest::ListLocations) => match core.list_locations() {
Ok(items) => CtlResponse::Locations { items },
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::RenameDevice { uid, name }) => match core.rename_device(&uid, &name) { Ok(CtlRequest::RenameDevice { uid, name }) => match core.rename_device(&uid, &name) {
Ok(()) => CtlResponse::Ok { Ok(()) => CtlResponse::Ok {
message: format!("renamed device to {name}"), message: format!("renamed device to {name}"),
@@ -692,6 +696,28 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
}, },
Err(e) => CtlResponse::error(e), Err(e) => CtlResponse::error(e),
}, },
Ok(CtlRequest::ShareNodeByUid {
uid,
emails,
role,
message,
}) => match core.share_node_by_uid(&uid, &emails, &role, message.as_deref()) {
Ok((proton, external)) => {
core.log_activity(
ActivityKind::Share,
&uid,
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, &uid, &e, false);
CtlResponse::error(e)
}
},
Ok(CtlRequest::ListShare { path }) => match rel_to_mount(mountpoint, &path) { Ok(CtlRequest::ListShare { path }) => match rel_to_mount(mountpoint, &path) {
Ok(rel) => match core.list_share(&rel) { Ok(rel) => match core.list_share(&rel) {
Ok((entries, link)) => CtlResponse::Share { entries, link }, Ok((entries, link)) => CtlResponse::Share { entries, link },
@@ -699,6 +725,10 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
}, },
Err(e) => CtlResponse::error(e), Err(e) => CtlResponse::error(e),
}, },
Ok(CtlRequest::ListShareByUid { uid }) => match core.list_share_by_uid(&uid) {
Ok((entries, link)) => CtlResponse::Share { entries, link },
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::UpdateShareRole { Ok(CtlRequest::UpdateShareRole {
path, path,
id, id,
@@ -713,6 +743,17 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
}, },
Err(e) => CtlResponse::error(e), Err(e) => CtlResponse::error(e),
}, },
Ok(CtlRequest::UpdateShareRoleByUid {
uid,
id,
kind,
role,
}) => match core.update_share_role_by_uid(&uid, &id, kind, &role) {
Ok(()) => CtlResponse::Ok {
message: format!("role updated to {role}"),
},
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::RemoveShareEntry { path, id, kind }) => { Ok(CtlRequest::RemoveShareEntry { path, id, kind }) => {
match rel_to_mount(mountpoint, &path) { match rel_to_mount(mountpoint, &path) {
Ok(rel) => match core.remove_share_entry(&rel, &id, kind) { Ok(rel) => match core.remove_share_entry(&rel, &id, kind) {
@@ -727,6 +768,17 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
Err(e) => CtlResponse::error(e), Err(e) => CtlResponse::error(e),
} }
} }
Ok(CtlRequest::RemoveShareEntryByUid { uid, id, kind }) => {
match core.remove_share_entry_by_uid(&uid, &id, kind) {
Ok(()) => {
core.log_activity(ActivityKind::Unshare, &uid, "access removed", true);
CtlResponse::Ok {
message: "removed".to_string(),
}
}
Err(e) => CtlResponse::error(e),
}
}
Ok(CtlRequest::CreatePublicLink { Ok(CtlRequest::CreatePublicLink {
path, path,
role, role,
@@ -745,6 +797,21 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
}, },
Err(e) => CtlResponse::error(e), Err(e) => CtlResponse::error(e),
}, },
Ok(CtlRequest::CreatePublicLinkByUid {
uid,
role,
password,
expires,
}) => match core.create_public_link_by_uid(&uid, &role, password.as_deref(), expires) {
Ok(link) => {
core.log_activity(ActivityKind::PublicLink, &uid, "link created", true);
CtlResponse::PublicLink { link }
}
Err(e) => {
core.log_activity(ActivityKind::PublicLink, &uid, &e, false);
CtlResponse::error(e)
}
},
Ok(CtlRequest::RemovePublicLink { path, id }) => match rel_to_mount(mountpoint, &path) { Ok(CtlRequest::RemovePublicLink { path, id }) => match rel_to_mount(mountpoint, &path) {
Ok(rel) => match core.remove_public_link(&rel, &id) { Ok(rel) => match core.remove_public_link(&rel, &id) {
Ok(()) => { Ok(()) => {
@@ -757,6 +824,17 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
}, },
Err(e) => CtlResponse::error(e), Err(e) => CtlResponse::error(e),
}, },
Ok(CtlRequest::RemovePublicLinkByUid { uid, id }) => {
match core.remove_public_link_by_uid(&uid, &id) {
Ok(()) => {
core.log_activity(ActivityKind::Unshare, &uid, "link removed", true);
CtlResponse::Ok {
message: "public link removed".to_string(),
}
}
Err(e) => CtlResponse::error(e),
}
}
Ok(CtlRequest::ListSharedWithMe) => match core.list_shared_with_me() { Ok(CtlRequest::ListSharedWithMe) => match core.list_shared_with_me() {
Ok(entries) => CtlResponse::Entries { entries }, Ok(entries) => CtlResponse::Entries { entries },
Err(e) => CtlResponse::error(e), Err(e) => CtlResponse::error(e),
+424 -122
View File
@@ -11,12 +11,13 @@ use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use fuser::{Config, MountOption, Session};
use parking_lot::Mutex; use parking_lot::Mutex;
use pdfs_core::control::{ use pdfs_core::control::{
ActivityKind, DeviceInfo, JobItem, SyncFolderInfo, SyncPhase, SyncProgress, ActivityKind, DeviceInfo, JobItem, MountKind, MountSpec, SyncFolderInfo, SyncPhase,
SyncProgress,
}; };
use pdfs_core::db::{StoredDevice, StoredSyncFolder}; use pdfs_core::db::{StoredDevice, StoredSyncFolder};
use pdfs_core::mounts::MountMode;
use pdfs_core::{CoreError, CoreResult}; use pdfs_core::{CoreError, CoreResult};
use proton_drive_rs::proton_sdk::ids::{DeviceUid, NodeUid}; use proton_drive_rs::proton_sdk::ids::{DeviceUid, NodeUid};
use proton_drive_rs::{DeviceType, Node}; use proton_drive_rs::{DeviceType, Node};
@@ -25,11 +26,69 @@ use tracing::{info, warn};
use super::sync::{self, base_name}; use super::sync::{self, base_name};
use super::{ use super::{
BackgroundSession, Core, ProtonFs, State, SwitchBlocked, clear_stale_mount, device_type_str, Core, SecondaryInsertRejection, SecondaryMount, State, SwitchBlocked, clear_stale_mount,
dir_is_empty, evict_dir_contents, fuse_connection_id, is_stale_mount, now_secs, parse_uid, device_type_str, dir_is_empty, evict_dir_contents, fuse_connection_id, is_stale_mount,
sync_folder_info, this_hostname, umount_session_unblocked, now_secs, parse_uid, spawn_session, sync_folder_info, this_hostname,
}; };
/// A mode that this daemon is allowed to apply.
///
/// Persisted and control-protocol values first pass through [`MountMode`] so
/// forward-compatible unknown values never reach either destructive transition.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ApplicableMountMode {
Mirror,
OnDemand,
}
impl ApplicableMountMode {
fn parse(value: &str) -> Result<Self, String> {
match MountMode::from(value) {
MountMode::Mirror => Ok(Self::Mirror),
MountMode::OnDemand => Ok(Self::OnDemand),
MountMode::Unknown => Err(format!("unknown mode {value:?} (want mirror|ondemand)")),
}
}
const fn as_str(self) -> &'static str {
match self {
Self::Mirror => "mirror",
Self::OnDemand => "ondemand",
}
}
}
fn select_mode_transition(
current: &str,
target: &str,
) -> Result<Option<ApplicableMountMode>, String> {
let current = ApplicableMountMode::parse(current)
.map_err(|error| format!("unsupported current mode: {error}"))?;
let target = ApplicableMountMode::parse(target)
.map_err(|error| format!("unsupported target mode: {error}"))?;
Ok((current != target).then_some(target))
}
fn restore_snapshot_matches(snapshot: &StoredSyncFolder, current: &StoredSyncFolder) -> bool {
current.id == snapshot.id
&& current.local_path == snapshot.local_path
&& current.remote_uid == snapshot.remote_uid
&& current.remote_share_id == snapshot.remote_share_id
&& current.mode == "ondemand"
}
fn pending_intent_matches(pending: Option<&str>, expected: Option<&str>) -> bool {
expected.is_none_or(|expected| pending == Some(expected))
}
fn commit_after_teardown<T, E>(
teardown: Result<(), E>,
commit: impl FnOnce() -> T,
) -> Result<T, E> {
teardown?;
Ok(commit())
}
/// The device uid this machine has been adopted into, from `config.json`. /// The device uid this machine has been adopted into, from `config.json`.
/// ///
/// Read fresh each time rather than cached on [`Core`]: adoption rewrites the /// Read fresh each time rather than cached on [`Core`]: adoption rewrites the
@@ -57,6 +116,22 @@ pub(crate) fn set_adopted_device_uid(uid: Option<&str>) -> CoreResult<()> {
impl Core { impl Core {
// ---- devices ---------------------------------------------------------- // ---- devices ----------------------------------------------------------
/// Unified local-location listing for the control protocol.
pub(crate) fn list_locations(&self) -> CoreResult<Vec<MountSpec>> {
let progress = self.sync_progress.lock();
let mut locations = self
.db
.mount_list()
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
for location in &mut locations {
location.mounted = self.states.is_mounted_at(Path::new(&location.local_path));
if let MountKind::Device { sync_folder_id } = &location.kind {
location.progress = progress.get(sync_folder_id).cloned();
}
}
Ok(locations)
}
/// List the account's registered devices, flagging the one *this* machine /// List the account's registered devices, flagging the one *this* machine
/// syncs to so a front-end can treat it as more than another computer in the /// syncs to so a front-end can treat it as more than another computer in the
/// list — deleting it takes this machine's synced folders down with it. /// list — deleting it takes this machine's synced folders down with it.
@@ -439,6 +514,8 @@ impl Core {
/// Remove a synced folder from the sync set. `delete_remote` also deletes its /// 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. /// folder under the device root; otherwise the cloud copy is left in place.
pub(crate) fn remove_sync_folder(&self, id: i64, delete_remote: bool) -> CoreResult<()> { pub(crate) fn remove_sync_folder(&self, id: i64, delete_remote: bool) -> CoreResult<()> {
let lock = self.sync_lock(id);
let _guard = lock.lock();
let folder = self let folder = self
.db .db
.sync_folder_get(id) .sync_folder_get(id)
@@ -449,19 +526,16 @@ impl Core {
// folder the daemon no longer tracks, and nothing would ever unmount it. // folder the daemon no longer tracks, and nothing would ever unmount it.
// Tear it down first — including before trashing the remote tree it // Tear it down first — including before trashing the remote tree it
// serves, which would otherwise leave it answering for deleted nodes. // serves, which would otherwise leave it answering for deleted nodes.
if let Some((session, conn)) = self.mounts.lock().remove(&id) { if let Some(mount) = self.mounts.lock().remove(&id)
if let Err(e) = umount_session_unblocked(session, conn) { && let Err(error) = commit_after_teardown(mount.teardown(), || ())
warn!(id, error = %e, "unmount on-demand folder failed");
} else {
info!(id, path = %folder.local_path, "unmounted on-demand folder");
}
}
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"); let _ = self.db.sync_folder_set_state(id, "error", now_secs());
return Err(CoreError::internal(format!(
"cannot remove synced folder: unmount {} failed: {error}",
folder.local_path
)));
} }
info!(id, path = %folder.local_path, "unmounted on-demand folder");
if !self if !self
.db .db
.sync_folder_remove(id) .sync_folder_remove(id)
@@ -469,8 +543,15 @@ impl Core {
{ {
return Err(CoreError::invalid(format!("no synced folder with id {id}"))); return Err(CoreError::invalid(format!("no synced folder with id {id}")));
} }
drop(_guard);
// Stop watching the folder we just dropped. // Stop watching the folder we just dropped.
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch); let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
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");
}
Ok(()) Ok(())
} }
@@ -489,6 +570,8 @@ impl Core {
/// (devices.md Phase 3). /// (devices.md Phase 3).
pub(crate) fn fork_state(&self) -> Core { pub(crate) fn fork_state(&self) -> Core {
let mut fork = self.clone(); let mut fork = self.clone();
fork.primary = false;
let share_access = self.state.lock().share_access.clone();
fork.state = Arc::new(Mutex::new(State { fork.state = Arc::new(Mutex::new(State {
entries: HashMap::new(), entries: HashMap::new(),
by_uid: HashMap::new(), by_uid: HashMap::new(),
@@ -497,19 +580,17 @@ impl Core {
active_writes: HashMap::new(), active_writes: HashMap::new(),
handles: HashMap::new(), handles: HashMap::new(),
next_fh: 1, next_fh: 1,
access_changes: std::collections::HashSet::new(),
share_access,
db: self.db.clone(), db: self.db.clone(),
})); }));
// A fresh inode space needs a fresh notification channel: this fork's // A fresh inode space needs a fresh notification channel: this fork's
// session is the only one that knows these inodes, so it must be the one // session is the only one that knows these inodes, so it must be the one
// notified about them. Filled in by `spawn_ondemand_mount`. // notified about them. Filled in by `spawn_ondemand_mount`.
fork.notifier = Arc::new(OnceLock::new()); fork.notifier = Arc::new(OnceLock::new());
fork.session_live = Arc::new(std::sync::atomic::AtomicBool::new(false));
// Size upgrades are keyed by inode, which is per-fork too. // Size upgrades are keyed by inode, which is per-fork too.
fork.size_upgrades = Arc::new(Mutex::new(HashMap::new())); fork.size_upgrades = Arc::new(Mutex::new(HashMap::new()));
// The daemon's single drain thread belongs to the primary mount, but it
// drains ops for nodes that live *here*. Publishing this inode space is
// what lets it find them; without it a queued create's node keeps its
// `local~` uid after the upload lands and reads it as empty (B74).
fork.register_state();
fork fork
} }
@@ -525,49 +606,84 @@ impl Core {
/// running — which, seeing a queued `ondemand`, also stops pulling down files /// running — which, seeing a queued `ondemand`, also stops pulling down files
/// it is about to evict ([`Core::push_pass`]). /// it is about to evict ([`Core::push_pass`]).
pub(crate) fn request_sync_folder_mode(&self, id: i64, mode: &str) -> CoreResult<String> { pub(crate) fn request_sync_folder_mode(&self, id: i64, mode: &str) -> CoreResult<String> {
if mode != "mirror" && mode != "ondemand" { // Preserve the existing control behavior: reject an unsupported request
return Err(CoreError::invalid(format!( // before looking up the folder, then validate the persisted source too.
"unknown mode {mode:?} (want mirror|ondemand)" let target_mode = ApplicableMountMode::parse(mode).map_err(CoreError::invalid)?;
)));
}
let folder = self let folder = self
.db .db
.sync_folder_get(id) .sync_folder_get(id)
.map_err(|e| CoreError::internal(format!("db: {e:?}")))? .map_err(|e| CoreError::internal(format!("db: {e:?}")))?
.ok_or_else(|| CoreError::not_found(format!("no synced folder with id {id}")))?; .ok_or_else(|| CoreError::not_found(format!("no synced folder with id {id}")))?;
if folder.mode == mode { let current_mode = ApplicableMountMode::parse(&folder.mode)
// Already there. A queued switch the other way is the user changing .map_err(|error| CoreError::invalid(format!("unsupported current mode: {error}")))?;
// their mind back before it landed, so withdraw it. if current_mode == target_mode {
if folder.pending_mode.is_some() { let mode_str = target_mode.as_str();
self.db let lock = self.sync_lock(id);
.sync_folder_set_pending_mode(id, None) match lock.try_lock() {
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?; Some(_guard) => {
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch); let current = self
return Ok(format!("staying {mode}")); .db
.sync_folder_get(id)
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
.ok_or_else(|| {
CoreError::not_found(format!("no synced folder with id {id}"))
})?;
let locked_mode =
ApplicableMountMode::parse(&current.mode).map_err(|error| {
CoreError::invalid(format!("unsupported current mode: {error}"))
})?;
if locked_mode == target_mode {
self.db
.sync_folder_set_pending_mode(id, None)
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
return Ok(if current.pending_mode.is_some() {
format!("staying {mode_str}")
} else {
format!("already {mode_str}")
});
}
}
None => {
// A transition or reconcile owns the folder lock. Record
// this request instead of clearing the old target: if a
// queued switch already passed its recheck, its mode write
// preserves this opposite marker and the engine switches
// back next.
self.db
.sync_folder_set_pending_mode(id, Some(mode_str))
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
let _ = self.sync_tx.send(sync::SyncMsg::Reconcile(id));
return Ok(format!("staying {mode_str}"));
}
} }
return Ok(format!("already {mode}"));
} }
let mode = target_mode;
let mode_str = target_mode.as_str();
match self.apply_sync_folder_mode(id, mode) { match self.apply_sync_folder_mode(id, mode, None) {
Ok(message) => Ok(message), Ok(message) => Ok(message),
// Not switchable this instant — remember the intent instead of making // Not switchable this instant — remember the intent instead of making
// the user retry, and kick a pass to clear whatever is in the way. // the user retry, and kick a pass to clear whatever is in the way.
Err(SwitchBlocked::NotNow) => { Err(SwitchBlocked::NotNow) => {
self.db self.db
.sync_folder_set_pending_mode(id, Some(mode)) .sync_folder_set_pending_mode(id, Some(mode_str))
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?; .map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
let _ = self.sync_tx.send(sync::SyncMsg::Reconcile(id)); let _ = self.sync_tx.send(sync::SyncMsg::Reconcile(id));
Ok(match mode { Ok(match mode {
"ondemand" => format!( ApplicableMountMode::OnDemand => format!(
"{} will go on-demand once its local changes are uploaded", "{} will go on-demand once its local changes are uploaded",
base_name(&folder.local_path) base_name(&folder.local_path)
), ),
_ => format!( ApplicableMountMode::Mirror => format!(
"{} will start mirroring once the current sync finishes", "{} will start mirroring once the current sync finishes",
base_name(&folder.local_path) base_name(&folder.local_path)
), ),
}) })
} }
Err(SwitchBlocked::Superseded) => Err(CoreError::internal(
"direct mode request was unexpectedly superseded",
)),
Err(SwitchBlocked::Failed(e)) => Err(CoreError::internal(e)), Err(SwitchBlocked::Failed(e)) => Err(CoreError::internal(e)),
} }
} }
@@ -581,27 +697,45 @@ impl Core {
let Ok(Some(folder)) = self.db.sync_folder_get(id) else { let Ok(Some(folder)) = self.db.sync_folder_get(id) else {
return; return;
}; };
let Some(mode) = folder.pending_mode.as_deref() else { let Some(expected_mode) = folder.pending_mode.as_deref() else {
return; return;
}; };
if folder.mode == mode { let mode = match select_mode_transition(&folder.mode, expected_mode) {
let _ = self.db.sync_folder_set_pending_mode(id, None); Ok(Some(mode)) => mode,
return; Ok(None) => {
} let _ = self.db.sync_folder_clear_pending_mode_if(id, expected_mode);
match self.apply_sync_folder_mode(id, mode) { return;
}
Err(error) => {
warn!(
id,
current_mode = %folder.mode,
pending_mode = %expected_mode,
%error,
"withdrawing unsafe queued mode transition"
);
let _ = self.db.sync_folder_clear_pending_mode_if(id, expected_mode);
return;
}
};
let mode_str = mode.as_str();
match self.apply_sync_folder_mode(id, mode, Some(expected_mode)) {
Ok(message) => { Ok(message) => {
info!(id, mode, "applied queued mode switch"); info!(id, mode = mode_str, "applied queued mode switch");
self.log_activity(ActivityKind::Sync, &message, "", true); self.log_activity(ActivityKind::Sync, &message, "", true);
} }
// Still blocked: the pass could not get everything up, so the local copy // Still blocked: the pass could not get everything up, so the local copy
// is not safe to evict yet. Leave the request standing — the next pass // is not safe to evict yet. Leave the request standing — the next pass
// (poll, or the retry the engine schedules) tries again. // (poll, or the retry the engine schedules) tries again.
Err(SwitchBlocked::NotNow) => { Err(SwitchBlocked::NotNow) => {
info!(id, mode, "queued mode switch still waiting"); info!(id, mode = mode_str, "queued mode switch still waiting");
}
Err(SwitchBlocked::Superseded) => {
info!(id, mode = mode_str, "queued mode switch was replaced");
} }
Err(SwitchBlocked::Failed(e)) => { Err(SwitchBlocked::Failed(e)) => {
warn!(id, mode, error = %e, "queued mode switch failed; withdrawing"); warn!(id, mode = mode_str, error = %e, "queued mode switch failed; withdrawing");
let _ = self.db.sync_folder_set_pending_mode(id, None); let _ = self.db.sync_folder_clear_pending_mode_if(id, expected_mode);
self.log_activity( self.log_activity(
ActivityKind::Sync, ActivityKind::Sync,
format!("couldn't switch {}", base_name(&folder.local_path)), format!("couldn't switch {}", base_name(&folder.local_path)),
@@ -627,8 +761,10 @@ impl Core {
pub(crate) fn apply_sync_folder_mode( pub(crate) fn apply_sync_folder_mode(
&self, &self,
id: i64, id: i64,
mode: &str, mode: ApplicableMountMode,
expected_pending: Option<&str>,
) -> Result<String, SwitchBlocked> { ) -> Result<String, SwitchBlocked> {
let mode_str = mode.as_str();
// Hold the folder's lock for the whole switch so no reconcile pass can be // 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 // 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` // it). A pass in flight holds the lock for its full duration, so `try_lock`
@@ -646,13 +782,18 @@ impl Core {
.sync_folder_get(id) .sync_folder_get(id)
.map_err(|e| SwitchBlocked::Failed(format!("db: {e:?}")))? .map_err(|e| SwitchBlocked::Failed(format!("db: {e:?}")))?
.ok_or_else(|| SwitchBlocked::Failed(format!("no synced folder with id {id}")))?; .ok_or_else(|| SwitchBlocked::Failed(format!("no synced folder with id {id}")))?;
if folder.mode == mode { if !pending_intent_matches(folder.pending_mode.as_deref(), expected_pending) {
return Ok(format!("already {mode}")); return Err(SwitchBlocked::Superseded);
}
let current_mode = ApplicableMountMode::parse(&folder.mode)
.map_err(|e| SwitchBlocked::Failed(format!("unsupported current mode: {e}")))?;
if current_mode == mode {
return Ok(format!("already {mode_str}"));
} }
let local = PathBuf::from(&folder.local_path); let local = PathBuf::from(&folder.local_path);
match mode { match mode {
"ondemand" => { ApplicableMountMode::OnDemand => {
// Only flip a folder that is fully in sync — a failed reconcile means // 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 // local edits could still be un-uploaded, and we are about to delete
// the local copy. Not an error: a pass makes this true, and the queued // the local copy. Not an error: a pass makes this true, and the queued
@@ -699,25 +840,28 @@ impl Core {
if let Err(e) = evict_dir_contents(&local) { if let Err(e) = evict_dir_contents(&local) {
warn!(id, path = %local.display(), error = %e, "evict local dir contents failed"); warn!(id, path = %local.display(), error = %e, "evict local dir contents failed");
} }
let session = self.spawn_ondemand_mount(&local, root).map_err(|e| { let mount = self.spawn_ondemand_mount(&local, root).map_err(|e| {
SwitchBlocked::Failed(format!( SwitchBlocked::Failed(format!(
"mount ondemand failed after local cache eviction; switch back to mirror to restore it: {e}" "mount ondemand failed after local cache eviction; switch back to mirror to restore it: {e}"
)) ))
})?; })?;
self.mounts self.publish_secondary_mount(id, mount)
.lock() .map_err(SwitchBlocked::Failed)?;
.insert(id, (session, fuse_connection_id(&local)));
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch); let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
self.db.sync_folder_set_state(id, "idle", now_secs()).ok(); self.db.sync_folder_set_state(id, "idle", now_secs()).ok();
info!(id, path = %local.display(), "mounted sync folder on-demand"); info!(id, path = %local.display(), "mounted sync folder on-demand");
Ok(format!("{} is now on-demand", local.display())) Ok(format!("{} is now on-demand", local.display()))
} }
_ => { ApplicableMountMode::Mirror => {
// ondemand→mirror: tear down the secondary mount first. // ondemand→mirror: tear down the secondary mount first.
if let Some((session, conn)) = self.mounts.lock().remove(&id) if let Some(mount) = self.mounts.lock().remove(&id)
&& let Err(e) = umount_session_unblocked(session, conn) && let Err(error) = commit_after_teardown(mount.teardown(), || ())
{ {
warn!(id, error = %e, "unmount on-demand folder failed"); let _ = self.db.sync_folder_set_state(id, "error", now_secs());
return Err(SwitchBlocked::Failed(format!(
"unmount {} failed; folder remains on-demand: {error}",
local.display()
)));
} }
// The evicted local tree makes the old baseline lie ("everything // The evicted local tree makes the old baseline lie ("everything
// deleted locally"); clear it so the reconcile is a pure download. // deleted locally"); clear it so the reconcile is a pure download.
@@ -745,23 +889,52 @@ impl Core {
&self, &self,
local: &Path, local: &Path,
root: Node, root: Node,
) -> CoreResult<BackgroundSession> { ) -> CoreResult<SecondaryMount> {
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 core = self.fork_state(); let core = self.fork_state();
let fs = ProtonFs::new(core.clone(), root); let session_live = core.session_live.clone();
let session = Session::new(fs, local, &config) // The fork owns this state/path registration. `spawn_session` only
.and_then(|s| s.spawn()) // marks it live after FUSE has spawned successfully.
core.register_state(local);
let session = spawn_session(&core, local, root)
.map_err(|e| CoreError::internal(format!("mount {}: {e}", local.display())))?; .map_err(|e| CoreError::internal(format!("mount {}: {e}", local.display())))?;
// Hand this fork its own session's notification channel, so a background Ok(SecondaryMount::new(
// size upgrade can drop the kernel's cached attrs for these inodes. session,
let _ = core.notifier.set(session.notifier()); fuse_connection_id(local),
Ok(session) session_live,
))
}
/// Publish one newly spawned secondary or explicitly tear it down when
/// shutdown has closed publication or another session already owns the id.
fn publish_secondary_mount(&self, id: i64, mount: SecondaryMount) -> Result<(), String> {
let rejected = self.mounts.lock().insert(id, mount);
let Err((reason, mount)) = rejected else {
return Ok(());
};
let reason = match reason {
SecondaryInsertRejection::Closed => "daemon is shutting down",
SecondaryInsertRejection::Duplicate => "folder already has a live mount",
};
match mount.teardown() {
Ok(()) => Err(reason.to_string()),
Err(error) => Err(format!("{reason}; rejected mount teardown failed: {error}")),
}
}
fn mark_restore_error_if_current(&self, snapshot: &StoredSyncFolder) {
let lock = self.sync_lock(snapshot.id);
let _guard = lock.lock();
if self
.db
.sync_folder_get(snapshot.id)
.ok()
.flatten()
.is_some_and(|current| restore_snapshot_matches(snapshot, &current))
{
let _ = self
.db
.sync_folder_set_state(snapshot.id, "error", now_secs());
}
} }
/// Re-establish FUSE mounts for folders left in `ondemand` mode across a daemon /// Re-establish FUSE mounts for folders left in `ondemand` mode across a daemon
@@ -782,14 +955,57 @@ impl Core {
if folder.mode != "ondemand" { if folder.mode != "ondemand" {
continue; continue;
} }
let local = PathBuf::from(&folder.local_path); let Some(root_uid) = parse_uid(&folder.remote_uid) else {
// A daemon killed before it could unmount (systemd's stop timeout, warn!(id = folder.id, "restore on-demand: bad remote uid");
// say) leaves this path attached to a dead FUSE connection: it still self.mark_restore_error_if_current(&folder);
// exists, but every stat on it fails with ENOTCONN — so `is_dir()` continue;
// reads it as *missing* and the folder gets parked in `error` on };
// every subsequent start, with no way back but a manual let root = match self
// `fusermount -u`. Detach the corpse first; the directory .rt
// underneath then restores normally. .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");
self.mark_restore_error_if_current(&folder);
continue;
}
},
Err(e) => {
warn!(id = folder.id, error = %e, "restore on-demand: fetch remote failed");
self.mark_restore_error_if_current(&folder);
continue;
}
};
let lock = self.sync_lock(folder.id);
let _guard = lock.lock();
let current = match self.db.sync_folder_get(folder.id) {
Ok(Some(current)) if restore_snapshot_matches(&folder, &current) => current,
Ok(_) => {
info!(
id = folder.id,
"restore on-demand: folder changed while fetching"
);
continue;
}
Err(error) => {
warn!(
id = folder.id,
?error,
"restore on-demand: cannot re-read folder"
);
continue;
}
};
if !self.mounts.lock().is_accepting() {
info!(id = folder.id, "restore on-demand: shutdown in progress");
continue;
}
let local = PathBuf::from(&current.local_path);
// A daemon killed before it could unmount can leave a dead FUSE
// connection over the path. Clear it only after the row identity
// and mode have been revalidated under the folder lock.
if is_stale_mount(&local) { if is_stale_mount(&local) {
warn!(id = folder.id, path = %local.display(), "restore on-demand: clearing stale mount"); warn!(id = folder.id, path = %local.display(), "restore on-demand: clearing stale mount");
clear_stale_mount(&local); clear_stale_mount(&local);
@@ -801,11 +1017,6 @@ impl Core {
.sync_folder_set_state(folder.id, "error", now_secs()); .sync_folder_set_state(folder.id, "error", now_secs());
continue; 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) { match dir_is_empty(&local) {
Ok(true) => {} Ok(true) => {}
Ok(false) => { Ok(false) => {
@@ -827,40 +1038,16 @@ impl Core {
continue; continue;
} }
} }
let Some(root_uid) = parse_uid(&folder.remote_uid) else { match self.spawn_ondemand_mount(&local, root) {
warn!(id = folder.id, "restore on-demand: bad remote uid"); Ok(mount) => match self.publish_secondary_mount(folder.id, mount) {
continue; Ok(()) => {
}; let _ = self.db.sync_folder_set_state(folder.id, "idle", now_secs());
let root = match self info!(id = folder.id, path = %local.display(), "remounted on-demand folder");
.rt }
.block_on(self.client.enumerate_nodes(std::slice::from_ref(&root_uid))) Err(error) => {
{ warn!(id = folder.id, %error, "restore on-demand: mount rejected");
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()
.insert(folder.id, (session, fuse_connection_id(&local)));
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) => { Err(e) => {
warn!(id = folder.id, error = %e, "restore on-demand: mount failed"); warn!(id = folder.id, error = %e, "restore on-demand: mount failed");
let _ = self let _ = self
@@ -871,3 +1058,118 @@ impl Core {
} }
} }
} }
#[cfg(test)]
mod tests {
use pdfs_core::db::StoredSyncFolder;
use super::{
ApplicableMountMode, commit_after_teardown, pending_intent_matches,
restore_snapshot_matches, select_mode_transition,
};
#[test]
fn unknown_mode_cannot_select_a_destructive_transition() {
let mut unmounts = 0;
let mut baseline_clears = 0;
let mut mode_writes = 0;
if let Ok(mode) = ApplicableMountMode::parse("streaming") {
match mode {
ApplicableMountMode::Mirror => {
unmounts += 1;
baseline_clears += 1;
mode_writes += 1;
}
ApplicableMountMode::OnDemand => {
mode_writes += 1;
}
}
}
assert_eq!((unmounts, baseline_clears, mode_writes), (0, 0, 0));
assert_eq!(
ApplicableMountMode::parse("streaming"),
Err("unknown mode \"streaming\" (want mirror|ondemand)".to_string())
);
assert_eq!(
ApplicableMountMode::parse("mirror"),
Ok(ApplicableMountMode::Mirror)
);
assert_eq!(
ApplicableMountMode::parse("ondemand"),
Ok(ApplicableMountMode::OnDemand)
);
}
#[test]
fn unknown_source_mode_with_pending_mirror_cannot_select_a_transition() {
let mut destructive_helper_reached = false;
let selected = select_mode_transition("streaming", "mirror");
if let Ok(Some(_)) = selected {
destructive_helper_reached = true;
}
assert!(!destructive_helper_reached);
assert_eq!(
selected,
Err(
"unsupported current mode: unknown mode \"streaming\" (want mirror|ondemand)"
.to_string()
)
);
}
fn folder(local_path: &str, remote_uid: &str, mode: &str) -> StoredSyncFolder {
StoredSyncFolder {
id: 7,
local_path: local_path.to_string(),
remote_uid: remote_uid.to_string(),
remote_share_id: "share".to_string(),
mode: mode.to_string(),
pending_mode: None,
state: "idle".to_string(),
last_sync: 0,
}
}
#[test]
fn stale_restore_identity_or_mode_is_rejected() {
let snapshot = folder("/mnt/folder", "volume~node", "ondemand");
assert!(restore_snapshot_matches(
&snapshot,
&folder("/mnt/folder", "volume~node", "ondemand")
));
assert!(!restore_snapshot_matches(
&snapshot,
&folder("/mnt/replaced", "volume~node", "ondemand")
));
assert!(!restore_snapshot_matches(
&snapshot,
&folder("/mnt/folder", "volume~other", "ondemand")
));
assert!(!restore_snapshot_matches(
&snapshot,
&folder("/mnt/folder", "volume~node", "mirror")
));
}
#[test]
fn teardown_failure_prevents_follow_up_state_changes() {
let mut commits = 0;
let result = commit_after_teardown::<_, &str>(Err("busy"), || {
commits += 1;
});
assert_eq!(result, Err("busy"));
assert_eq!(commits, 0);
commit_after_teardown::<_, &str>(Ok(()), || commits += 1).unwrap();
assert_eq!(commits, 1);
}
#[test]
fn canceled_or_replaced_queued_intent_cannot_transition() {
assert!(pending_intent_matches(Some("ondemand"), Some("ondemand")));
assert!(!pending_intent_matches(None, Some("ondemand")));
assert!(!pending_intent_matches(Some("mirror"), Some("ondemand")));
assert!(pending_intent_matches(Some("mirror"), None));
}
}
+275 -3
View File
@@ -27,7 +27,7 @@ use std::time::Duration;
use pdfs_core::cache::{Baseline, StagedWrite}; use pdfs_core::cache::{Baseline, StagedWrite};
use pdfs_core::control::{ActivityKind, TransferDirection}; use pdfs_core::control::{ActivityKind, TransferDirection};
use pdfs_core::db::{OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PendingOp}; use pdfs_core::db::{OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PendingOp, RenameMeta};
use proton_drive_rs::Node; use proton_drive_rs::Node;
use proton_drive_rs::proton_sdk::ids::NodeUid; use proton_drive_rs::proton_sdk::ids::NodeUid;
use tracing::{debug, error, info, warn}; use tracing::{debug, error, info, warn};
@@ -40,6 +40,78 @@ use super::{
now_millis, now_secs, parse_node_uid, now_millis, now_secs, parse_node_uid,
}; };
const DRAIN_ACCESS_RECHECK: Duration = Duration::from_secs(5);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DrainDisposition {
Applied,
AccessDeferred,
}
fn pending_op_authorities(op: &PendingOp) -> Result<Vec<NodeUid>, Box<dyn std::error::Error>> {
let uid = || -> Result<NodeUid, Box<dyn std::error::Error>> {
parse_node_uid(&op.uid)
.ok_or_else(|| Box::<dyn std::error::Error>::from("pending op has an unparseable uid"))
};
let parent = || -> Result<NodeUid, Box<dyn std::error::Error>> {
let parent = op
.parent_uid
.as_deref()
.ok_or("pending op has no parent authority")?;
parse_node_uid(parent).ok_or_else(|| {
Box::<dyn std::error::Error>::from("pending op has an unparseable parent")
})
};
match op.kind.as_str() {
OP_REVISION | OP_TRASH => Ok(vec![uid()?]),
OP_CREATE | OP_MKDIR => Ok(vec![parent()?]),
OP_RENAME => {
let mut authorities = vec![uid()?];
if let Some(json) = op.meta_json.as_deref() {
let meta: RenameMeta = serde_json::from_str(json)?;
authorities.push(
parse_node_uid(&meta.original_parent_uid)
.ok_or("rename op has an unparseable original parent")?,
);
} else {
// Rows written by 1.1.1 predate foreign shared mounts. Their
// source tree was necessarily owned, so the node plus desired
// destination are the complete available authority set.
}
authorities.push(parent()?);
authorities.dedup();
Ok(authorities)
}
other => Err(format!("unknown pending op kind {other:?}").into()),
}
}
fn run_authorized_drain(
op: &PendingOp,
mut require: impl FnMut(&NodeUid) -> Result<(), fuser::Errno>,
apply: impl FnOnce() -> Result<(), Box<dyn std::error::Error>>,
) -> Result<DrainDisposition, Box<dyn std::error::Error>> {
// This check is the admission/linearization point. A downgrade that lands
// first defers the op; one observed afterwards does not revoke this admitted
// attempt. Every later queued operation rechecks. No permission lock is
// held across remote latency.
for authority in pending_op_authorities(op)? {
match require(&authority) {
Ok(()) => {}
Err(error) if error.code() == libc::EACCES => {
return Ok(DrainDisposition::AccessDeferred);
}
Err(error) => {
return Err(
format!("drain access check failed with errno {}", error.code()).into(),
);
}
}
}
apply()?;
Ok(DrainDisposition::Applied)
}
impl Core { impl Core {
/// Drain the pending-op queue: the background half of every write /// Drain the pending-op queue: the background half of every write
/// (offline.md Phase 3). /// (offline.md Phase 3).
@@ -69,7 +141,30 @@ impl Core {
self.wait_for_drain_work_or_due(); self.wait_for_drain_work_or_due();
continue; continue;
}; };
if let Err(e) = self.drain_op(&op) { let outcome = run_authorized_drain(
&op,
|uid| self.require_uid_writable(uid),
|| self.drain_op(&op),
);
if matches!(outcome, Ok(DrainDisposition::AccessDeferred)) {
let next_attempt_at = now_millis() + DRAIN_ACCESS_RECHECK.as_millis() as i64;
if let Err(error) = self.db.defer_op_without_attempt(op.id, next_attempt_at) {
error!(
uid = %op.uid,
%error,
"deferring access-blocked pending operation failed"
);
self.wait_for_drain_work();
} else {
debug!(
uid = %op.uid,
next_attempt_at,
"pending operation deferred until access is writable"
);
}
continue;
}
if let Err(e) = outcome {
let attempts = op.attempts + 1; let attempts = op.attempts + 1;
let err_str = e.to_string(); let err_str = e.to_string();
// Never infer that user data is disposable from an error string // Never infer that user data is disposable from an error string
@@ -252,7 +347,7 @@ impl Core {
} }
Err(e) => return Err(e.into()), Err(e) => return Err(e.into()),
} }
self.db.delete_op(op.id)?; self.db.complete_trash_op(op.id, &uid)?;
self.own_sealed_revs.lock().remove(&uid); self.own_sealed_revs.lock().remove(&uid);
self.invalidate_trash(); self.invalidate_trash();
self.log_activity(ActivityKind::Trash, &name, "trashed", true); self.log_activity(ActivityKind::Trash, &name, "trashed", true);
@@ -996,8 +1091,184 @@ fn is_own_self_supersede(complete: bool, remote_rev: Option<&str>, own_rev: Opti
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use pdfs_core::Access;
use pdfs_core::db::Db;
use proton_drive_rs::NodeKind; use proton_drive_rs::NodeKind;
use proton_drive_rs::proton_sdk::ids::{LinkId, VolumeId}; use proton_drive_rs::proton_sdk::ids::{LinkId, VolumeId};
use std::cell::Cell;
fn pending(kind: &str) -> PendingOp {
PendingOp {
id: 0,
kind: kind.to_string(),
uid: NodeUid::new(VolumeId::from("v"), LinkId::from("node")).to_string(),
parent_uid: Some(NodeUid::new(VolumeId::from("v"), LinkId::from("parent")).to_string()),
name: Some("name".to_string()),
blob_path: (kind == OP_REVISION).then(|| "/staging/blob".to_string()),
meta_json: None,
created_at: 1,
attempts: 0,
last_error: None,
next_attempt_at: 0,
}
}
#[test]
fn drain_authorities_match_each_operation_kind() {
for kind in [OP_REVISION, OP_TRASH] {
assert_eq!(pending_op_authorities(&pending(kind)).unwrap().len(), 1);
}
for kind in [OP_CREATE, OP_MKDIR] {
let authorities = pending_op_authorities(&pending(kind)).unwrap();
assert_eq!(authorities.len(), 1);
assert_eq!(authorities[0].to_string(), "v~parent");
}
let authorities = pending_op_authorities(&pending(OP_RENAME)).unwrap();
assert_eq!(authorities.len(), 2);
assert_eq!(authorities[0].to_string(), "v~node");
assert_eq!(authorities[1].to_string(), "v~parent");
}
fn folder_node(link: &str, parent: Option<&str>) -> Node {
Node {
uid: NodeUid::new(VolumeId::from("v"), LinkId::from(link)),
parent_uid: parent
.map(|parent| NodeUid::new(VolumeId::from("v"), LinkId::from(parent))),
kind: NodeKind::Folder,
name: link.to_string(),
creation_time: 0,
modification_time: 0,
trashed: false,
is_shared: false,
is_shared_publicly: false,
signature_email: None,
membership: None,
verification: Default::default(),
}
}
#[test]
fn queued_cross_parent_rename_retains_viewer_source_authority_after_local_move() {
use std::sync::atomic::{AtomicU64, Ordering};
static N: AtomicU64 = AtomicU64::new(0);
let dir = std::env::temp_dir().join(format!(
"pdfs-drain-rename-access-{}-{}",
std::process::id(),
N.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir_all(&dir).unwrap();
let db = Db::open(&dir.join("cache.db")).unwrap();
let source = folder_node("source", None);
let destination = folder_node("destination", None);
let mut moved = file_node(1, 0, None);
moved.parent_uid = Some(destination.uid.clone());
for node in [&source, &destination, &moved] {
db.upsert_node(node).unwrap();
}
db.set_share_access(&source.uid, Access::Viewer).unwrap();
db.set_share_access(&destination.uid, Access::Editor)
.unwrap();
let mut op = pending(OP_RENAME);
op.uid = moved.uid.to_string();
op.parent_uid = Some(destination.uid.to_string());
op.meta_json = Some(
serde_json::to_string(&RenameMeta {
original_parent_uid: source.uid.to_string(),
})
.unwrap(),
);
let authorities = pending_op_authorities(&op).unwrap();
assert_eq!(authorities.len(), 3);
assert_eq!(authorities[1], source.uid);
let applied = Cell::new(false);
let disposition = run_authorized_drain(
&op,
|uid| {
db.effective_node_access(uid)
.unwrap()
.filter(|access| access.writable())
.map(|_| ())
.ok_or(fuser::Errno::EACCES)
},
|| {
applied.set(true);
Ok(())
},
)
.unwrap();
assert_eq!(disposition, DrainDisposition::AccessDeferred);
assert!(!applied.get());
assert_eq!(
db.effective_node_access(&moved.uid).unwrap(),
Some(Access::Editor),
"the node's optimistic destination ancestry is writable"
);
drop(db);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn viewer_deferred_drain_never_applies_or_consumes_an_attempt() {
use std::sync::atomic::{AtomicU64, Ordering};
static N: AtomicU64 = AtomicU64::new(0);
let dir = std::env::temp_dir().join(format!(
"pdfs-drain-access-{}-{}",
std::process::id(),
N.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir_all(&dir).unwrap();
let db = Db::open(&dir.join("cache.db")).unwrap();
let mut queued = pending(OP_REVISION);
queued.attempts = 2;
db.enqueue_op(&queued).unwrap();
let op = db.pending_ops().unwrap().remove(0);
let before_count = db.pending_op_counts().unwrap();
let applied = Cell::new(false);
let disposition = run_authorized_drain(
&op,
|_| Err(fuser::Errno::EACCES),
|| {
applied.set(true);
Ok(())
},
)
.unwrap();
assert_eq!(disposition, DrainDisposition::AccessDeferred);
assert!(!applied.get(), "no API surrogate may run while read-only");
db.defer_op_without_attempt(op.id, 5_000).unwrap();
let retained = db.pending_ops().unwrap();
let after_count = db.pending_op_counts().unwrap();
assert_eq!(retained.len(), 1);
assert_eq!(retained[0].attempts, op.attempts);
assert_eq!(after_count.uploads, before_count.uploads);
assert_eq!(after_count.changes, before_count.changes);
drop(db);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn owner_and_editor_drain_authorities_apply_normally() {
for access in [Access::Owner, Access::Editor] {
let applied = Cell::new(false);
let disposition = run_authorized_drain(
&pending(OP_REVISION),
|_| access.writable().then_some(()).ok_or(fuser::Errno::EACCES),
|| {
applied.set(true);
Ok(())
},
)
.unwrap();
assert_eq!(disposition, DrainDisposition::Applied);
assert!(applied.get());
}
}
fn file_node(mtime: i64, size: i64, rev: Option<&str>) -> Node { fn file_node(mtime: i64, size: i64, rev: Option<&str>) -> Node {
Node { Node {
@@ -1019,6 +1290,7 @@ mod tests {
is_shared: false, is_shared: false,
is_shared_publicly: false, is_shared_publicly: false,
signature_email: None, signature_email: None,
membership: None,
verification: Default::default(), verification: Default::default(),
} }
} }
+188 -44
View File
@@ -46,7 +46,7 @@ impl Filesystem for ProtonFs {
} }
) )
.then(|| (e.parent, e.uid.clone())); .then(|| (e.parent, e.uid.clone()));
(self.attr(ino.0, &e.node), provisional) (self.attr(ino.0, &e.node, e.access), provisional)
} }
None => { None => {
reply.error(Errno::ENOENT); reply.error(Errno::ENOENT);
@@ -79,7 +79,7 @@ impl Filesystem for ProtonFs {
let attr = { let attr = {
let st = fs.core.state.lock(); let st = fs.core.state.lock();
match st.entries.get(&ino.0) { match st.entries.get(&ino.0) {
Some(e) => fs.attr(ino.0, &e.node), Some(e) => fs.attr(ino.0, &e.node, e.access),
// Forgotten while we waited. // Forgotten while we waited.
None => attr, None => attr,
} }
@@ -110,10 +110,15 @@ impl Filesystem for ProtonFs {
} }
fn open(&self, _req: &Request, ino: INodeNo, flags: OpenFlags, reply: ReplyOpen) { fn open(&self, _req: &Request, ino: INodeNo, flags: OpenFlags, reply: ReplyOpen) {
let write_requested = flags.acc_mode() != OpenAccMode::O_RDONLY;
let (uid, base_mtime, base_size, base_revision_id) = { let (uid, base_mtime, base_size, base_revision_id) = {
let mut st = self.core.state.lock(); let mut st = self.core.state.lock();
match st.entries.get_mut(&ino.0) { match st.entries.get_mut(&ino.0) {
Some(e) if e.node.is_file() => { Some(e) if e.node.is_file() => {
if write_requested && !e.writable() {
reply.error(Errno::EACCES);
return;
}
e.open_count = e.open_count.saturating_add(1); e.open_count = e.open_count.saturating_add(1);
( (
e.uid.clone(), e.uid.clone(),
@@ -315,6 +320,10 @@ impl Filesystem for ProtonFs {
reply: ReplyCreate, reply: ReplyCreate,
) { ) {
let parent = parent.0; let parent = parent.0;
if let Err(error) = self.core.require_writable(parent) {
reply.error(error);
return;
}
let name = match fuse_name(name) { let name = match fuse_name(name) {
Ok(name) => name, Ok(name) => name,
Err(error) => { Err(error) => {
@@ -346,6 +355,10 @@ impl Filesystem for ProtonFs {
_lock_owner: Option<LockOwner>, _lock_owner: Option<LockOwner>,
reply: ReplyWrite, reply: ReplyWrite,
) { ) {
if let Err(error) = self.core.require_writable(ino.0) {
reply.error(error);
return;
}
let fh = fh.0; let fh = fh.0;
// Stage the bytes straight into the scratch file (no base download): only // Stage the bytes straight into the scratch file (no base download): only
// the untouched remainder is pulled from the remote, and only at commit. // the untouched remainder is pulled from the remote, and only at commit.
@@ -413,6 +426,10 @@ impl Filesystem for ProtonFs {
_flags: Option<BsdFileFlags>, _flags: Option<BsdFileFlags>,
reply: ReplyAttr, reply: ReplyAttr,
) { ) {
if let Err(error) = self.core.require_writable(ino.0) {
reply.error(error);
return;
}
// Only resizes change remote state; everything else (mode/owner/times) is // Only resizes change remote state; everything else (mode/owner/times) is
// accepted and ignored so tools that chmod/utimes after writing succeed. // accepted and ignored so tools that chmod/utimes after writing succeed.
if let Some(size) = size { if let Some(size) = size {
@@ -476,6 +493,10 @@ impl Filesystem for ProtonFs {
mode: i32, mode: i32,
reply: ReplyEmpty, reply: ReplyEmpty,
) { ) {
if let Err(error) = self.core.require_writable(ino.0) {
reply.error(error);
return;
}
let new_end = offset.saturating_add(length); let new_end = offset.saturating_add(length);
let keep_size = (mode & 1) != 0; // FALLOC_FL_KEEP_SIZE let keep_size = (mode & 1) != 0; // FALLOC_FL_KEEP_SIZE
@@ -637,18 +658,7 @@ impl Filesystem for ProtonFs {
) { ) {
let (handle, unlinked_uid) = { let (handle, unlinked_uid) = {
let mut st = self.core.state.lock(); let mut st = self.core.state.lock();
let unlinked_uid = if let Some(entry) = st.entries.get_mut(&_ino.0) { let unlinked_uid = release_unlinked_entry(&mut st, _ino.0);
entry.open_count = entry.open_count.saturating_sub(1);
if entry.open_count == 0 && entry.unlinked {
let uid = entry.uid.clone();
st.forget(&uid);
Some(uid)
} else {
None
}
} else {
None
};
let ino = st.handles.remove(&fh.0); let ino = st.handles.remove(&fh.0);
let h = ino.and_then(|i| { let h = ino.and_then(|i| {
let aw = st.active_writes.get_mut(&i)?; let aw = st.active_writes.get_mut(&i)?;
@@ -679,6 +689,10 @@ impl Filesystem for ProtonFs {
fn unlink(&self, _req: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEmpty) { fn unlink(&self, _req: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEmpty) {
let parent = parent.0; let parent = parent.0;
if let Err(error) = self.core.require_writable(parent) {
reply.error(error);
return;
}
let name_str = match fuse_name(name) { let name_str = match fuse_name(name) {
Ok(name) => name, Ok(name) => name,
Err(error) => { Err(error) => {
@@ -695,6 +709,10 @@ impl Filesystem for ProtonFs {
fn rmdir(&self, _req: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEmpty) { fn rmdir(&self, _req: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEmpty) {
let parent = parent.0; let parent = parent.0;
if let Err(error) = self.core.require_writable(parent) {
reply.error(error);
return;
}
let name_str = match fuse_name(name) { let name_str = match fuse_name(name) {
Ok(name) => name, Ok(name) => name,
Err(error) => { Err(error) => {
@@ -720,6 +738,10 @@ impl Filesystem for ProtonFs {
reply: ReplyEntry, reply: ReplyEntry,
) { ) {
let parent = parent.0; let parent = parent.0;
if let Err(error) = self.core.require_writable(parent) {
reply.error(error);
return;
}
let name = match fuse_name(name) { let name = match fuse_name(name) {
Ok(name) => name, Ok(name) => name,
Err(error) => { Err(error) => {
@@ -748,6 +770,14 @@ impl Filesystem for ProtonFs {
) { ) {
let parent = parent.0; let parent = parent.0;
let newparent = newparent.0; let newparent = newparent.0;
if let Err(error) = self
.core
.require_writable(parent)
.and_then(|()| self.core.require_writable(newparent))
{
reply.error(error);
return;
}
let name = match fuse_name(name) { let name = match fuse_name(name) {
Ok(name) => name, Ok(name) => name,
Err(error) => { Err(error) => {
@@ -771,6 +801,19 @@ impl Filesystem for ProtonFs {
}); });
} }
fn access(&self, _req: &Request, ino: INodeNo, mask: AccessFlags, reply: ReplyEmpty) {
let state = self.core.state.lock();
let Some(entry) = state.entries.get(&ino.0) else {
reply.error(Errno::ENOENT);
return;
};
if !access_allowed(entry.node.is_folder(), entry.access, mask) {
reply.error(Errno::EACCES);
} else {
reply.ok();
}
}
fn getxattr(&self, _req: &Request, ino: INodeNo, name: &OsStr, size: u32, reply: ReplyXattr) { fn getxattr(&self, _req: &Request, ino: INodeNo, name: &OsStr, size: u32, reply: ReplyXattr) {
// Always off the dispatch loop: a miss goes to the wire, and a lister // Always off the dispatch loop: a miss goes to the wire, and a lister
// that stats a directory issues one of these per file per advertised // that stats a directory issues one of these per file per advertised
@@ -935,6 +978,11 @@ pub struct ProtonFs {
gid: u32, gid: u32,
} }
pub(crate) fn access_allowed(is_dir: bool, access: Access, mask: AccessFlags) -> bool {
(!mask.contains(AccessFlags::W_OK) || access.writable())
&& (!mask.contains(AccessFlags::X_OK) || is_dir)
}
impl ProtonFs { impl ProtonFs {
/// Build the filesystem rooted at `root` (the user's My Files folder). /// Build the filesystem rooted at `root` (the user's My Files folder).
pub(super) fn new(core: Core, root: Node) -> Self { pub(super) fn new(core: Core, root: Node) -> Self {
@@ -944,12 +992,14 @@ impl ProtonFs {
warn!(uid = %root.uid, error = %e, "db upsert root failed"); warn!(uid = %root.uid, error = %e, "db upsert root failed");
} }
st.by_uid.insert(root.uid.clone(), ROOT_INO); st.by_uid.insert(root.uid.clone(), ROOT_INO);
let access = st.access_for_node(ROOT_INO, &root);
st.entries.insert( st.entries.insert(
ROOT_INO, ROOT_INO,
Entry { Entry {
uid: root.uid.clone(), uid: root.uid.clone(),
parent: ROOT_INO, parent: ROOT_INO,
node: root, node: root,
access,
lookup_count: 1, lookup_count: 1,
open_count: 0, open_count: 0,
unlinked: false, unlinked: false,
@@ -991,7 +1041,7 @@ impl ProtonFs {
} }
) )
.then(|| (e.parent, e.uid.clone())); .then(|| (e.parent, e.uid.clone()));
(ino, self.attr(ino, &e.node), provisional) (ino, self.attr(ino, &e.node, e.access), provisional)
}) })
}) })
}) })
@@ -1023,7 +1073,7 @@ impl ProtonFs {
let st = self.core.state.lock(); let st = self.core.state.lock();
st.entries st.entries
.get(&ino) .get(&ino)
.map_or(attr, |e| self.attr(ino, &e.node)) .map_or(attr, |e| self.attr(ino, &e.node, e.access))
}; };
reply.entry(&TTL, &attr, Generation(0)); reply.entry(&TTL, &attr, Generation(0));
} }
@@ -1065,11 +1115,12 @@ impl ProtonFs {
reply.ok(); reply.ok();
} }
fn attr(&self, ino: u64, node: &Node) -> FileAttr { fn attr(&self, ino: u64, node: &Node, access: Access) -> FileAttr {
let (kind, perm) = match node.kind { let kind = match node.kind {
NodeKind::Folder => (FileType::Directory, 0o755), NodeKind::Folder => FileType::Directory,
NodeKind::File { .. } => (FileType::RegularFile, 0o644), NodeKind::File { .. } => FileType::RegularFile,
}; };
let perm = perm_bits(node.is_folder(), access);
let size = node_size(node); let size = node_size(node);
let mtime = unix_secs(node.modification_time); let mtime = unix_secs(node.modification_time);
let crtime = unix_secs(node.creation_time); let crtime = unix_secs(node.creation_time);
@@ -1097,6 +1148,10 @@ impl ProtonFs {
/// subtrees, so an `rmdir` of a non-empty dir behaves the same). /// subtrees, so an `rmdir` of a non-empty dir behaves the same).
/// The body of [`Filesystem::create`], run off the dispatch loop. /// The body of [`Filesystem::create`], run off the dispatch loop.
fn serve_create(&self, parent: u64, name: &str, reply: ReplyCreate) { fn serve_create(&self, parent: u64, name: &str, reply: ReplyCreate) {
if let Err(error) = self.core.require_writable(parent) {
reply.error(error);
return;
}
if let Err(e) = self.core.ensure_children(parent) { if let Err(e) = self.core.ensure_children(parent) {
reply.error(e); reply.error(e);
return; return;
@@ -1128,6 +1183,10 @@ impl ProtonFs {
let node = let node =
if !transient && self.core.online.load(Ordering::Relaxed) && !is_local_uid(&parent_uid) if !transient && self.core.online.load(Ordering::Relaxed) && !is_local_uid(&parent_uid)
{ {
if let Err(error) = self.core.require_uid_writable(&parent_uid) {
reply.error(error);
return;
}
// Create an empty file on the remote so it has a real uid immediately; // 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. // 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( let new_uid = match self.core.rt.block_on(self.core.client.upload_file(
@@ -1211,7 +1270,10 @@ impl ProtonFs {
}); });
aw.open_count += 1; aw.open_count += 1;
st.handles.insert(fh, ino); st.handles.insert(fh, ino);
let attr = self.attr(ino, &st.entries.get(&ino).unwrap().node); let entry = st.entries.get(&ino).unwrap();
let attr = self.attr(ino, &entry.node, entry.access);
drop(st);
self.core.flush_access_changes();
reply.created( reply.created(
&TTL, &TTL,
&attr, &attr,
@@ -1223,6 +1285,10 @@ impl ProtonFs {
/// The body of [`Filesystem::mkdir`], run off the dispatch loop. /// The body of [`Filesystem::mkdir`], run off the dispatch loop.
fn serve_mkdir(&self, parent: u64, name: &str, reply: ReplyEntry) { fn serve_mkdir(&self, parent: u64, name: &str, reply: ReplyEntry) {
if let Err(error) = self.core.require_writable(parent) {
reply.error(error);
return;
}
if let Err(e) = self.core.ensure_children(parent) { if let Err(e) = self.core.ensure_children(parent) {
reply.error(e); reply.error(e);
return; return;
@@ -1245,6 +1311,10 @@ impl ProtonFs {
// queued — the folder becomes a placeholder that the drain turns into a // queued — the folder becomes a placeholder that the drain turns into a
// real one (offline.md Phase 3b). // real one (offline.md Phase 3b).
let node = if self.core.online.load(Ordering::Relaxed) && !is_local_uid(&parent_uid) { let node = if self.core.online.load(Ordering::Relaxed) && !is_local_uid(&parent_uid) {
if let Err(error) = self.core.require_uid_writable(&parent_uid) {
reply.error(error);
return;
}
let new_uid = let new_uid =
match self match self
.core .core
@@ -1292,12 +1362,19 @@ impl ProtonFs {
warn!(%uid, error = %e, "db set_listed(true) failed for local folder"); warn!(%uid, error = %e, "db set_listed(true) failed for local folder");
} }
} }
let attr = self.attr(ino, &st.entries.get(&ino).unwrap().node); let entry = st.entries.get(&ino).unwrap();
let attr = self.attr(ino, &entry.node, entry.access);
drop(st);
self.core.flush_access_changes();
reply.entry(&TTL, &attr, Generation(0)); reply.entry(&TTL, &attr, Generation(0));
} }
/// The body of [`Filesystem::unlink`], run off the dispatch loop. /// The body of [`Filesystem::unlink`], run off the dispatch loop.
fn serve_unlink(&self, parent: u64, name_str: &str, reply: ReplyEmpty) { fn serve_unlink(&self, parent: u64, name_str: &str, reply: ReplyEmpty) {
if let Err(error) = self.core.require_writable(parent) {
reply.error(error);
return;
}
if let Ok((ino, _)) = self.core.lookup_child(parent, name_str) { if let Ok((ino, _)) = self.core.lookup_child(parent, name_str) {
let st = self.core.state.lock(); let st = self.core.state.lock();
if let Some(entry) = st.entries.get(&ino) if let Some(entry) = st.entries.get(&ino)
@@ -1312,6 +1389,10 @@ impl ProtonFs {
/// The body of [`Filesystem::rmdir`], run off the dispatch loop. /// The body of [`Filesystem::rmdir`], run off the dispatch loop.
fn serve_rmdir(&self, parent: u64, name_str: &str, reply: ReplyEmpty) { fn serve_rmdir(&self, parent: u64, name_str: &str, reply: ReplyEmpty) {
if let Err(error) = self.core.require_writable(parent) {
reply.error(error);
return;
}
if let Ok((ino, _)) = self.core.lookup_child(parent, name_str) { if let Ok((ino, _)) = self.core.lookup_child(parent, name_str) {
{ {
let st = self.core.state.lock(); let st = self.core.state.lock();
@@ -1348,10 +1429,18 @@ impl ProtonFs {
reply: ReplyEmpty, reply: ReplyEmpty,
) { ) {
let was_unlinked = unlinked_uid.is_some(); let was_unlinked = unlinked_uid.is_some();
if let Some(uid) = unlinked_uid { if let Some(uid) = unlinked_uid
self.core.discard_queued_ops(&uid); && release_can_discard_unlinked(&self.core.db, &uid)
self.core.cache.evict(&uid); {
self.core.evict_reader(&uid); if let Err(error) = self.core.discard_queued_ops(&uid) {
warn!(%uid, ?error, "queued-op cleanup failed; retaining unlinked state");
} else {
if let Err(error) = self.core.db.delete_node(&uid) {
warn!(%uid, %error, "deleting released unlinked node state failed");
}
self.core.cache.evict(&uid);
self.core.evict_reader(&uid);
}
} }
// Hand the bytes to the queue rather than uploading them here: the // Hand the bytes to the queue rather than uploading them here: the
// scratch file is the only copy of what was just written, and blocking // scratch file is the only copy of what was just written, and blocking
@@ -1366,6 +1455,7 @@ impl ProtonFs {
{ {
warn!(path = %h.path.display(), error = %e, "discarding unlinked scratch file failed"); warn!(path = %h.path.display(), error = %e, "discarding unlinked scratch file failed");
} }
self.core.cache.clear_scratch_durable(&h.path);
reply.ok(); reply.ok();
} }
(Some(h), false) => match self.core.queue_revision(&h) { (Some(h), false) => match self.core.queue_revision(&h) {
@@ -1386,6 +1476,14 @@ impl ProtonFs {
flags: RenameFlags, flags: RenameFlags,
reply: ReplyEmpty, reply: ReplyEmpty,
) { ) {
if let Err(error) = self
.core
.require_writable(parent)
.and_then(|()| self.core.require_writable(newparent))
{
reply.error(error);
return;
}
let (ino, uid) = match self.core.lookup_child(parent, name) { let (ino, uid) = match self.core.lookup_child(parent, name) {
Ok(x) => x, Ok(x) => x,
Err(e) => { Err(e) => {
@@ -1413,11 +1511,11 @@ impl ProtonFs {
reply.error(e); reply.error(e);
return; return;
} }
let new_parent_uid = { let (old_parent_uid, new_parent_uid) = {
let st = self.core.state.lock(); let st = self.core.state.lock();
match st.entries.get(&newparent) { match (st.entries.get(&parent), st.entries.get(&newparent)) {
Some(e) => e.uid.clone(), (Some(old), Some(new)) => (old.uid.clone(), new.uid.clone()),
None => { _ => {
reply.error(Errno::ENOENT); reply.error(Errno::ENOENT);
return; return;
} }
@@ -1446,7 +1544,7 @@ impl ProtonFs {
return; return;
} }
}; };
if let Some((victim_ino, victim_uid)) = &victim { let replacement_authorized = if let Some((victim_ino, victim_uid)) = &victim {
if flags.contains(RenameFlags::RENAME_NOREPLACE) { if flags.contains(RenameFlags::RENAME_NOREPLACE) {
reply.error(Errno::EEXIST); reply.error(Errno::EEXIST);
return; return;
@@ -1490,11 +1588,23 @@ impl ProtonFs {
reply.error(e); reply.error(e);
return; return;
} }
if let Err(error) = require_rename_access(
|authority| self.core.require_uid_writable(authority),
&uid,
&old_parent_uid,
&new_parent_uid,
) {
reply.error(error);
return;
}
if let Err(e) = self.core.remove_replaced(victim_uid, newname) { if let Err(e) = self.core.remove_replaced(victim_uid, newname) {
reply.error(e); reply.error(e);
return; return;
} }
} true
} else {
false
};
// A node whose own creation is still queued has no server-side identity // A node whose own creation is still queued has no server-side identity
// to rename: the queued op *is* the node, so rewriting its target is the // to rename: the queued op *is* the node, so rewriting its target is the
// whole rename. Nothing reaches the API, which is why this works offline // whole rename. Nothing reaches the API, which is why this works offline
@@ -1557,10 +1667,26 @@ impl ProtonFs {
newparent != parent, newparent != parent,
newname != name, newname != name,
) { ) {
match self let queued = if replacement_authorized {
.core self.core.queue_rename_authorized(
.queue_rename(ino, &uid, newparent, &new_parent_uid, newname) ino,
{ &uid,
&old_parent_uid,
newparent,
&new_parent_uid,
newname,
)
} else {
self.core.queue_rename(
ino,
&uid,
&old_parent_uid,
newparent,
&new_parent_uid,
newname,
)
};
match queued {
Ok(()) => { Ok(()) => {
// Send the rename response first. Kernel-side rename cache // Send the rename response first. Kernel-side rename cache
// updates happen while processing that response; notifying // updates happen while processing that response; notifying
@@ -1588,6 +1714,16 @@ impl ProtonFs {
// A failure past this point has already trashed any node the rename was // A failure past this point has already trashed any node the rename was
// replacing, so put it back rather than leaving the caller with neither // replacing, so put it back rather than leaving the caller with neither
// the source moved nor the destination intact. // the source moved nor the destination intact.
if let Err(error) = require_rename_access(
|authority| self.core.require_uid_writable(authority),
&uid,
&old_parent_uid,
&new_parent_uid,
) {
self.core.restore_replaced(victim.as_ref(), newname);
reply.error(error);
return;
}
if newname != name if newname != name
&& let Err(e) = self && let Err(e) = self
.core .core
@@ -1655,7 +1791,7 @@ impl ProtonFs {
let st = self.core.state.lock(); let st = self.core.state.lock();
match st.entries.get(&ino) { match st.entries.get(&ino) {
Some(e) => { Some(e) => {
let attr = self.attr(ino, &e.node); let attr = self.attr(ino, &e.node, e.access);
reply.attr(&TTL, &attr); reply.attr(&TTL, &attr);
} }
None => reply.error(Errno::ENOENT), None => reply.error(Errno::ENOENT),
@@ -1663,6 +1799,10 @@ impl ProtonFs {
} }
fn trash_child(&self, parent: u64, name: &str, reply: ReplyEmpty) { fn trash_child(&self, parent: u64, name: &str, reply: ReplyEmpty) {
if let Err(error) = self.core.require_writable(parent) {
reply.error(error);
return;
}
let (ino, uid) = match self.core.lookup_child(parent, name) { let (ino, uid) = match self.core.lookup_child(parent, name) {
Ok(x) => x, Ok(x) => x,
Err(e) => { Err(e) => {
@@ -1681,8 +1821,9 @@ impl ProtonFs {
// it just means its queued creation is no longer wanted. This works // it just means its queued creation is no longer wanted. This works
// offline, which the remote path below cannot (offline.md Phase 3b). // offline, which the remote path below cannot (offline.md Phase 3b).
if is_local_uid(&uid) { if is_local_uid(&uid) {
if !open_now { if !open_now && let Err(error) = self.core.discard_queued_ops(&uid) {
self.core.discard_queued_ops(&uid); reply.error(error);
return;
} }
self.core.state.lock().forget_or_unlink(&uid); self.core.state.lock().forget_or_unlink(&uid);
debug!(%uid, name, "deleted a node that had not been created remotely yet"); debug!(%uid, name, "deleted a node that had not been created remotely yet");
@@ -1694,14 +1835,15 @@ impl ProtonFs {
// command returns (offline.md Phase 3b). // command returns (offline.md Phase 3b).
if !self.core.online.load(Ordering::Relaxed) { if !self.core.online.load(Ordering::Relaxed) {
match self.core.queue_trash(&uid, name) { match self.core.queue_trash(&uid, name) {
Ok(()) => { Ok(()) => reply.ok(),
self.core.state.lock().forget_or_unlink(&uid);
reply.ok();
}
Err(e) => reply.error(e), Err(e) => reply.error(e),
} }
return; return;
} }
if let Err(e) = self.core.require_uid_writable(&uid) {
reply.error(e);
return;
}
if let Err(e) = self if let Err(e) = self
.core .core
.rt .rt
@@ -1718,7 +1860,9 @@ impl ProtonFs {
self.core.hidden.lock().insert(uid.clone()); self.core.hidden.lock().insert(uid.clone());
self.core.state.lock().forget_or_unlink(&uid); self.core.state.lock().forget_or_unlink(&uid);
if !open_now { if !open_now {
self.core.discard_queued_ops(&uid); if let Err(error) = self.core.discard_queued_ops(&uid) {
warn!(%uid, ?error, "remote trash landed but queued-op cleanup failed");
}
self.core.cache.evict(&uid); self.core.cache.evict(&uid);
self.core.evict_reader(&uid); self.core.evict_reader(&uid);
} }
+2214 -110
View File
File diff suppressed because it is too large. Load diff
+290 -56
View File
@@ -28,10 +28,101 @@ pub(crate) fn is_stale_mount(path: &Path) -> bool {
) )
} }
/// A secondary (on-demand sync folder) FUSE session, paired with its FUSE /// A secondary (on-demand sync folder) FUSE session and the exact liveness flag
/// connection id so teardown can abort a mid-transfer connection rather than /// published with its forked inode state.
/// block on it. See [`Core::mounts`] and [`umount_session_unblocked`]. ///
pub(super) type SecondaryMount = (BackgroundSession, Option<u32>); /// Keeping these together makes every teardown mark the fork unroutable before
/// unmounting, including paths that can block on an in-flight FUSE request.
pub(super) struct SecondaryMount {
session: BackgroundSession,
conn: Option<u32>,
session_live: Arc<AtomicBool>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum SecondaryInsertRejection {
Closed,
Duplicate,
}
/// Secondary sessions and the daemon-wide gate controlling whether newly
/// spawned sessions may become visible. The gate and map share one lock so
/// shutdown cannot race an insertion between checking and publishing.
pub(super) struct SecondaryMountRegistry<T> {
accepting: bool,
entries: HashMap<i64, T>,
}
impl<T> Default for SecondaryMountRegistry<T> {
fn default() -> Self {
Self {
accepting: false,
entries: HashMap::new(),
}
}
}
impl<T> SecondaryMountRegistry<T> {
pub(super) fn open(&mut self) {
self.accepting = true;
}
pub(super) fn is_accepting(&self) -> bool {
self.accepting
}
pub(super) fn close(&mut self) {
self.accepting = false;
}
pub(super) fn insert(
&mut self,
id: i64,
mount: T,
) -> Result<(), (SecondaryInsertRejection, T)> {
if !self.accepting {
return Err((SecondaryInsertRejection::Closed, mount));
}
if self.entries.contains_key(&id) {
return Err((SecondaryInsertRejection::Duplicate, mount));
}
self.entries.insert(id, mount);
Ok(())
}
pub(super) fn remove(&mut self, id: &i64) -> Option<T> {
self.entries.remove(id)
}
pub(super) fn contains_key(&self, id: &i64) -> bool {
self.entries.contains_key(id)
}
pub(super) fn close_and_drain(&mut self) -> Vec<(i64, T)> {
self.close();
self.entries.drain().collect()
}
}
impl SecondaryMount {
pub(super) fn new(
session: BackgroundSession,
conn: Option<u32>,
session_live: Arc<AtomicBool>,
) -> Self {
Self {
session,
conn,
session_live,
}
}
pub(super) fn teardown(self) -> std::io::Result<()> {
teardown_session(&self.session_live, || {
umount_session_unblocked(self.session, self.conn)
})
}
}
/// The kernel's id for the FUSE connection backing the mount at `mountpoint` — /// The kernel's id for the FUSE connection backing the mount at `mountpoint` —
/// the directory name under `/sys/fs/fuse/connections`, which is the minor /// the directory name under `/sys/fs/fuse/connections`, which is the minor
@@ -152,6 +243,71 @@ pub struct MountOptions {
pub sweep_mode: SweepMode, pub sweep_mode: SweepMode,
} }
/// Spawn one FUSE session rooted at an arbitrary remote node.
///
/// Primary My Files and secondary on-demand device locations use this same
/// construction path so mount options, stale-mount recovery, registry
/// liveness, and notifier ownership cannot drift apart. Each caller owns its
/// state/path registration and performs it exactly once before calling here.
pub(super) fn spawn_session(
core: &Core,
mountpoint: &Path,
root: Node,
) -> std::io::Result<BackgroundSession> {
let mut config = Config::default();
config.mount_options = vec![
MountOption::FSName("protondrive".to_string()),
MountOption::Subtype("protondrive".to_string()),
MountOption::DefaultPermissions,
];
clear_stale_mount(mountpoint);
info!(mountpoint = %mountpoint.display(), "mounting Proton Drive location");
let fs = ProtonFs::new(core.clone(), root);
if core.primary {
// Only My Files rehydrates the daemon-wide node cache. A secondary
// inode space is rooted at one device subtree and must not import every
// unrelated persisted node.
core.hydrate();
}
let session = Session::new(fs, mountpoint, &config)?.spawn()?;
let _ = core.notifier.set(session.notifier());
core.session_live.store(true, Ordering::Release);
Ok(session)
}
/// Make a session unroutable before beginning any teardown that may block.
fn teardown_session<T>(session_live: &AtomicBool, teardown: impl FnOnce() -> T) -> T {
session_live.store(false, Ordering::Release);
teardown()
}
impl Core {
/// Resolve the primary share id away from startup and publish it only if the
/// projected root has not changed while the request was in flight.
pub(crate) fn repair_primary_share_id(&self, root_uid: &NodeUid) {
match self.rt.block_on(self.client.context_share_id(root_uid)) {
Ok(share_id) => {
match self
.db
.mount_repair_my_files_share_id(&root_uid.to_string(), &share_id.to_string())
{
Ok(true) => debug!(%root_uid, "repaired My Files share id"),
Ok(false) => {
debug!(%root_uid, "discarded stale My Files share-id result");
}
Err(error) => {
warn!(%root_uid, error = %error, "persist My Files share id failed");
}
}
}
Err(error) => {
debug!(%root_uid, error = %error, "resolve My Files share id failed");
}
}
}
}
/// Mount the filesystem at `mountpoint` and block until it is unmounted or the /// Mount the filesystem at `mountpoint` and block until it is unmounted or the
/// daemon is asked to stop. /// daemon is asked to stop.
/// ///
@@ -179,6 +335,11 @@ pub fn mount(
} = options; } = options;
let (root, online) = fetch_or_recall_root(&client, &rt, &db)?; let (root, online) = fetch_or_recall_root(&client, &rt, &db)?;
let scope = root.tree_event_scope_id(); let scope = root.tree_event_scope_id();
db.mount_upsert_my_files(&mountpoint.to_string_lossy(), &root.uid.to_string(), None)
.map_err(|error| std::io::Error::other(format!("project My Files mount: {error}")))?;
let share_access = db
.all_share_access()
.map_err(|error| std::io::Error::other(format!("load shared access: {error}")))?;
// The folder-sync engine (devices.md Phase 2) runs on its own thread and is // 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 // nudged over this channel; the sender lives in Core so control-socket
@@ -188,6 +349,8 @@ pub fn mount(
let core = Core { let core = Core {
client: client.clone(), client: client.clone(),
rt: rt.clone(), rt: rt.clone(),
primary_root_uid: root.uid.clone(),
primary: true,
state: Arc::new(Mutex::new(State { state: Arc::new(Mutex::new(State {
entries: HashMap::new(), entries: HashMap::new(),
by_uid: HashMap::new(), by_uid: HashMap::new(),
@@ -196,6 +359,8 @@ pub fn mount(
active_writes: HashMap::new(), active_writes: HashMap::new(),
handles: HashMap::new(), handles: HashMap::new(),
next_fh: 1, next_fh: 1,
access_changes: HashSet::new(),
share_access,
db: db.clone(), db: db.clone(),
})), })),
cache: Arc::new(cache), cache: Arc::new(cache),
@@ -203,12 +368,16 @@ pub fn mount(
stream_ring: Arc::new(Mutex::new(StreamRing::default())), stream_ring: Arc::new(Mutex::new(StreamRing::default())),
workers: Arc::new(Workers::new(FUSE_WORKERS)?), workers: Arc::new(Workers::new(FUSE_WORKERS)?),
db, db,
shared_publication: Arc::new(Mutex::new(())),
shared_generation: Arc::new(AtomicU64::new(0)),
shared_refresh_deadlines: Arc::new(Mutex::new(SharedRefreshDeadlines::default())),
online: Arc::new(AtomicBool::new(online)), online: Arc::new(AtomicBool::new(online)),
pending: Arc::new(Mutex::new(HashMap::new())), pending: Arc::new(Mutex::new(HashMap::new())),
hidden: Arc::new(Mutex::new(HashSet::new())), hidden: Arc::new(Mutex::new(HashSet::new())),
drain_wake: Arc::new((Mutex::new(false), Condvar::new())), drain_wake: Arc::new((Mutex::new(false), Condvar::new())),
timeline_refreshing: Arc::new(AtomicBool::new(false)), timeline_refreshing: Arc::new(AtomicBool::new(false)),
trash_refreshing: Arc::new(AtomicBool::new(false)), trash_refreshing: Arc::new(AtomicBool::new(false)),
trash_progress: Arc::new(tokio::sync::Notify::new()),
conflict_notified: Arc::new(Mutex::new(HashSet::new())), conflict_notified: Arc::new(Mutex::new(HashSet::new())),
sweep_mode, sweep_mode,
own_sealed_revs: Arc::new(Mutex::new(HashMap::new())), own_sealed_revs: Arc::new(Mutex::new(HashMap::new())),
@@ -217,17 +386,18 @@ pub fn mount(
no_thumbnail: Arc::new(Mutex::new(HashMap::new())), no_thumbnail: Arc::new(Mutex::new(HashMap::new())),
size_upgrades: Arc::new(Mutex::new(HashMap::new())), size_upgrades: Arc::new(Mutex::new(HashMap::new())),
notifier: Arc::new(OnceLock::new()), notifier: Arc::new(OnceLock::new()),
session_live: Arc::new(AtomicBool::new(false)),
transfers: TransferRegistry::new(), transfers: TransferRegistry::new(),
indexing: Arc::new(AtomicBool::new(false)), indexing: Arc::new(AtomicBool::new(false)),
sync_progress: Arc::new(Mutex::new(HashMap::new())), sync_progress: Arc::new(Mutex::new(HashMap::new())),
sync_tx, sync_tx,
mounts: Arc::new(Mutex::new(HashMap::new())), mounts: Arc::new(Mutex::new(SecondaryMountRegistry::default())),
sync_locks: Arc::new(Mutex::new(HashMap::new())), sync_locks: Arc::new(Mutex::new(HashMap::new())),
states: Arc::new(StateRegistry::default()), states: Arc::new(StateRegistry::default()),
}; };
// Before anything can queue work against it: the drain thread below reaches // Before anything can queue work against it: the drain thread below reaches
// every mount's inode space through this registry, not through `core.state`. // every mount's inode space through this registry, not through `core.state`.
core.register_state(); core.register_state(mountpoint);
// Writes queued by a previous run (or left behind by a crash) are still owed // Writes queued by a previous run (or left behind by a crash) are still owed
// an upload, and reads must be served from their staged blobs until they land. // an upload, and reads must be served from their staged blobs until they land.
@@ -271,16 +441,6 @@ pub fn mount(
.spawn(move || core.run_online_probe())?; .spawn(move || core.run_online_probe())?;
} }
// 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: // 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. // the walk is I/O-heavy and must never sit in front of a FUSE callback.
{ {
@@ -293,18 +453,6 @@ pub fn mount(
.spawn(move || run_local_index(db, indexing, transfers, mountpoint))?; .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 // 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. // stale socket file from a previous run would block the bind, so clear it.
let _ = std::fs::remove_file(control_socket); let _ = std::fs::remove_file(control_socket);
@@ -321,32 +469,70 @@ pub fn mount(
"control socket permissions: {e}" "control socket permissions: {e}"
))); )));
} }
{ // Do not accept control requests until `spawn_session` has transitioned the
let core = core.clone(); // primary registration to live. The listener may queue a connection during
let username = username.clone(); // this short window, but its handler cannot observe `mounted = false`.
let mountpoint = mountpoint.to_path_buf(); let bg = match spawn_session(&core, mountpoint, root) {
std::thread::Builder::new() Ok(session) => session,
.name("pdfs-control".into()) Err(error) => {
.spawn(move || run_control_socket(core, username, mountpoint, listener))?; let _ = std::fs::remove_file(control_socket);
} return Err(error);
}
let fs = ProtonFs::new(core.clone(), root); };
// Warm the in-memory maps from the DB so a cold start serves previously core.mounts.lock().open();
// 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()?;
// The connection id, captured now while the mount is live, so a stop signal // The connection id, captured now while the mount is live, so a stop signal
// mid-transfer can abort it rather than block `join` (see `abort_fuse_connection`). // mid-transfer can abort it rather than block `join` (see `abort_fuse_connection`).
let main_conn = fuse_connection_id(mountpoint); let main_conn = fuse_connection_id(mountpoint);
let notifier = bg.notifier(); {
// Same channel, kept on the `Core` so background work (a size upgrade, say) let control_core = core.clone();
// can invalidate kernel-cached metadata without threading a handle through. let username = username.clone();
let _ = core.notifier.set(notifier.clone()); let mountpoint = mountpoint.to_path_buf();
if let Err(error) = std::thread::Builder::new()
.name("pdfs-control".into())
.spawn(move || run_control_socket(control_core, username, mountpoint, listener))
{
let secondaries = core.mounts.lock().close_and_drain();
for (id, mount) in secondaries {
if let Err(unmount_error) = mount.teardown() {
warn!(id, error = %unmount_error, "secondary teardown after control startup failure failed");
}
}
if let Err(unmount_error) = teardown_session(&core.session_live, || {
umount_session_unblocked(bg, main_conn)
}) {
warn!(error = %unmount_error, "unmount after control startup failure failed");
}
let _ = std::fs::remove_file(control_socket);
return Err(error);
}
}
// Re-establish on-demand mounts only after the primary session is live.
// Keep the worker so shutdown can close publication, wait for any in-flight
// fetch to observe that closure, and then drain a stable registry.
let restore_worker = {
let restore_core = core.clone();
match std::thread::Builder::new()
.name("pdfs-restore-ondemand".into())
.spawn(move || restore_core.restore_ondemand_mounts())
{
Ok(worker) => Some(worker),
Err(error) => {
warn!(%error, "start on-demand restore worker failed");
None
}
}
};
rt.spawn(run_event_sync(client, scope, core.clone())); rt.spawn(run_event_sync(client, scope, core.clone()));
if online {
let repair_core = core.clone();
let root_uid = core.primary_root_uid.clone();
if let Err(error) = std::thread::Builder::new()
.name("pdfs-share-id".into())
.spawn(move || repair_core.repair_primary_share_id(&root_uid))
{
warn!(%error, "start My Files share-id repair failed");
}
}
// Stop signals (SIGTERM from `systemctl --user stop`, SIGINT from Ctrl-C) // Stop signals (SIGTERM from `systemctl --user stop`, SIGINT from Ctrl-C)
// are delivered onto the async runtime; bridge them onto a sync channel so // are delivered onto the async runtime; bridge them onto a sync channel so
@@ -385,7 +571,10 @@ pub fn mount(
match sig_rx.recv_timeout(Duration::from_millis(500)) { match sig_rx.recv_timeout(Duration::from_millis(500)) {
Ok(()) => { Ok(()) => {
info!("stop requested; unmounting"); info!("stop requested; unmounting");
if let Err(e) = umount_session_unblocked(bg, main_conn) { core.mounts.lock().close();
if let Err(e) = teardown_session(&core.session_live, || {
umount_session_unblocked(bg, main_conn)
}) {
warn!(error = %e, "umount_and_join failed"); warn!(error = %e, "umount_and_join failed");
} }
break MountOutcome::Shutdown; break MountOutcome::Shutdown;
@@ -393,7 +582,8 @@ pub fn mount(
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
if bg.guard.is_finished() { if bg.guard.is_finished() {
info!("mount ended externally"); info!("mount ended externally");
if let Err(e) = bg.join() { core.mounts.lock().close();
if let Err(e) = teardown_session(&core.session_live, || bg.join()) {
warn!(error = %e, "session join failed"); warn!(error = %e, "session join failed");
} }
break MountOutcome::Unmounted; break MountOutcome::Unmounted;
@@ -401,7 +591,8 @@ pub fn mount(
} }
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
// Signal task gone (failed to install); fall back to join. // Signal task gone (failed to install); fall back to join.
let _ = bg.join(); core.mounts.lock().close();
let _ = teardown_session(&core.session_live, || bg.join());
break MountOutcome::Unmounted; break MountOutcome::Unmounted;
} }
} }
@@ -409,9 +600,14 @@ pub fn mount(
// Unmount every on-demand sync folder too, or the kernel mounts linger as // 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). // stale and the next start fails with EBUSY (devices.md Phase 3).
let secondaries: Vec<_> = core.mounts.lock().drain().collect(); if let Some(worker) = restore_worker
for (id, (session, conn)) in secondaries { && worker.join().is_err()
if let Err(e) = umount_session_unblocked(session, conn) { {
warn!("on-demand restore worker panicked");
}
let secondaries = core.mounts.lock().close_and_drain();
for (id, mount) in secondaries {
if let Err(e) = mount.teardown() {
warn!(id, error = %e, "unmount on-demand folder failed"); warn!(id, error = %e, "unmount on-demand folder failed");
} }
} }
@@ -419,3 +615,41 @@ pub fn mount(
let _ = std::fs::remove_file(control_socket); let _ = std::fs::remove_file(control_socket);
Ok(outcome) Ok(outcome)
} }
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicBool, Ordering};
use super::{SecondaryInsertRejection, SecondaryMountRegistry, teardown_session};
#[test]
fn session_is_unroutable_before_teardown_starts() {
let live = AtomicBool::new(true);
teardown_session(&live, || {
assert!(
!live.load(Ordering::Acquire),
"teardown must never run while the registry still reports mounted"
);
});
}
#[test]
fn secondary_registry_rejects_closed_and_duplicate_insertions() {
let mut mounts = SecondaryMountRegistry::default();
assert_eq!(
mounts.insert(7, "closed"),
Err((SecondaryInsertRejection::Closed, "closed"))
);
mounts.open();
assert_eq!(mounts.insert(7, "first"), Ok(()));
assert_eq!(
mounts.insert(7, "duplicate"),
Err((SecondaryInsertRejection::Duplicate, "duplicate"))
);
assert!(mounts.contains_key(&7));
assert_eq!(mounts.close_and_drain(), vec![(7, "first")]);
assert!(!mounts.is_accepting());
}
}
+27 -7
View File
@@ -12,6 +12,7 @@ use std::sync::OnceLock;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use pdfs_core::control::RestorableFolder; use pdfs_core::control::RestorableFolder;
use pdfs_core::mounts::MountMode;
use pdfs_core::profile::{PROFILE_FILE_NAME, PROFILE_VERSION, Profile, ProfileFolder, ProfilePin}; use pdfs_core::profile::{PROFILE_FILE_NAME, PROFILE_VERSION, Profile, ProfileFolder, ProfilePin};
use pdfs_core::{CoreError, CoreResult}; use pdfs_core::{CoreError, CoreResult};
use proton_drive_rs::proton_sdk::ids::NodeUid; use proton_drive_rs::proton_sdk::ids::NodeUid;
@@ -133,7 +134,7 @@ impl Core {
local_path: f.local_path, local_path: f.local_path,
// A queued switch is where the user asked the folder to be, so // A queued switch is where the user asked the folder to be, so
// that is what the next machine should set up. // that is what the next machine should set up.
mode: f.pending_mode.unwrap_or(f.mode), mode: MountMode::from(f.pending_mode.unwrap_or(f.mode).as_str()),
}) })
.collect(); .collect();
let pins = self let pins = self
@@ -267,7 +268,7 @@ impl Core {
name: n.name, name: n.name,
local_path: proposed.to_string_lossy().to_string(), local_path: proposed.to_string_lossy().to_string(),
mode: saved mode: saved
.map(|f| f.mode.clone()) .map(|f| f.mode.to_string())
.unwrap_or_else(|| "mirror".into()), .unwrap_or_else(|| "mirror".into()),
} }
}) })
@@ -319,10 +320,10 @@ impl Core {
skipped.push(format!("{}: {e}", item.local_path)); skipped.push(format!("{}: {e}", item.local_path));
continue; continue;
} }
let mode = match item.mode.as_str() { let mode = match restore_mode(&item.mode) {
"mirror" | "ondemand" => item.mode.as_str(), Some(mode) => mode,
other => { None => {
skipped.push(format!("{}: unknown mode {other}", item.local_path)); skipped.push(format!("{}: unknown mode {}", item.local_path, item.mode));
continue; continue;
} }
}; };
@@ -333,7 +334,7 @@ impl Core {
// `ondemand` is applied through the normal request path, which // `ondemand` is applied through the normal request path, which
// mounts the FUSE session and handles the folder being busy — the // mounts the FUSE session and handles the folder being busy — the
// restore does not get its own copy of that logic. // restore does not get its own copy of that logic.
if mode == "ondemand" { if mode == MountMode::OnDemand {
let _ = self.request_sync_folder_mode(id, "ondemand"); let _ = self.request_sync_folder_mode(id, "ondemand");
} }
let _ = self.sync_tx.send(sync::SyncMsg::Reconcile(id)); let _ = self.sync_tx.send(sync::SyncMsg::Reconcile(id));
@@ -350,6 +351,17 @@ impl Core {
} }
} }
/// Accept only modes this build can safely restore. `MountMode` itself is
/// tolerant so a future value does not reject the whole profile; the folder
/// carrying that value is skipped here with the message assembled above.
fn restore_mode(value: &str) -> Option<MountMode> {
match MountMode::from(value) {
MountMode::Mirror => Some(MountMode::Mirror),
MountMode::OnDemand => Some(MountMode::OnDemand),
MountMode::Unknown => None,
}
}
/// Whether a path recorded on another machine can be reused verbatim here. /// Whether a path recorded on another machine can be reused verbatim here.
/// ///
/// Requires the *parent* to exist: `~/Documents` on a machine with the same /// Requires the *parent* to exist: `~/Documents` on a machine with the same
@@ -382,6 +394,14 @@ mod tests {
assert!(!path_is_usable_here(Path::new("relative/path"))); assert!(!path_is_usable_here(Path::new("relative/path")));
} }
#[test]
fn unknown_profile_mode_is_skipped_without_rejecting_known_modes() {
assert_eq!(restore_mode("mirror"), Some(MountMode::Mirror));
assert_eq!(restore_mode("ondemand"), Some(MountMode::OnDemand));
assert_eq!(restore_mode("streaming"), None);
assert_eq!(restore_mode("unknown"), None);
}
/// A path whose parent exists but which does not itself is exactly the /// A path whose parent exists but which does not itself is exactly the
/// restore case — the directory is about to be created. /// restore case — the directory is about to be created.
#[test] #[test]
+127 -15
View File
@@ -57,11 +57,33 @@ impl Core {
message: Option<&str>, message: Option<&str>,
) -> CoreResult<(usize, usize)> { ) -> CoreResult<(usize, usize)> {
let (_ino, uid) = self.resolve(rel)?; let (_ino, uid) = self.resolve(rel)?;
self.share_node_for_uid(&uid, emails, role, message)
}
/// [`Core::share_node`] for a node addressed through any daemon location.
pub(crate) fn share_node_by_uid(
&self,
uid: &str,
emails: &[String],
role: &str,
message: Option<&str>,
) -> CoreResult<(usize, usize)> {
let uid = self.resolve_anywhere(uid)?;
self.share_node_for_uid(&uid, emails, role, message)
}
fn share_node_for_uid(
&self,
uid: &NodeUid,
emails: &[String],
role: &str,
message: Option<&str>,
) -> CoreResult<(usize, usize)> {
let role = role_from_str(role)?; let role = role_from_str(role)?;
let invitees: Vec<(String, MemberRole)> = let invitees: Vec<(String, MemberRole)> =
emails.iter().map(|e| (e.clone(), role)).collect(); emails.iter().map(|e| (e.clone(), role)).collect();
self.rt self.rt
.block_on(self.client.invite_users(&uid, &invitees, message)) .block_on(self.client.invite_users(uid, &invitees, message))
.map_err(|e| CoreError::from_api(&e, "share")) .map_err(|e| CoreError::from_api(&e, "share"))
} }
@@ -71,11 +93,26 @@ impl Core {
rel: &Path, rel: &Path,
) -> CoreResult<(Vec<ShareEntry>, Option<PublicLinkInfo>)> { ) -> CoreResult<(Vec<ShareEntry>, Option<PublicLinkInfo>)> {
let (_ino, uid) = self.resolve(rel)?; let (_ino, uid) = self.resolve(rel)?;
self.list_share_for_uid(&uid)
}
/// [`Core::list_share`] for a node addressed through any daemon location.
pub(crate) fn list_share_by_uid(
&self,
uid: &str,
) -> CoreResult<(Vec<ShareEntry>, Option<PublicLinkInfo>)> {
let uid = self.resolve_anywhere(uid)?;
self.list_share_for_uid(&uid)
}
fn list_share_for_uid(
&self,
uid: &NodeUid,
) -> CoreResult<(Vec<ShareEntry>, Option<PublicLinkInfo>)> {
let mut entries = Vec::new(); let mut entries = Vec::new();
for m in self for m in self
.rt .rt
.block_on(self.client.list_share_members(&uid)) .block_on(self.client.list_share_members(uid))
.map_err(|e| CoreError::from_api(&e, "list members"))? .map_err(|e| CoreError::from_api(&e, "list members"))?
{ {
entries.push(ShareEntry { entries.push(ShareEntry {
@@ -87,7 +124,7 @@ impl Core {
} }
for inv in self for inv in self
.rt .rt
.block_on(self.client.list_share_invitations(&uid)) .block_on(self.client.list_share_invitations(uid))
.map_err(|e| CoreError::from_api(&e, "list invitations"))? .map_err(|e| CoreError::from_api(&e, "list invitations"))?
{ {
entries.push(ShareEntry { entries.push(ShareEntry {
@@ -99,7 +136,7 @@ impl Core {
} }
for ext in self for ext in self
.rt .rt
.block_on(self.client.list_external_invitations(&uid)) .block_on(self.client.list_external_invitations(uid))
.map_err(|e| CoreError::from_api(&e, "list external invitations"))? .map_err(|e| CoreError::from_api(&e, "list external invitations"))?
{ {
entries.push(ShareEntry { entries.push(ShareEntry {
@@ -112,7 +149,7 @@ impl Core {
let link = self let link = self
.rt .rt
.block_on(self.client.get_public_link(&uid)) .block_on(self.client.get_public_link(uid))
.map_err(|e| CoreError::from_api(&e, "get public link"))? .map_err(|e| CoreError::from_api(&e, "get public link"))?
.map(public_link_info); .map(public_link_info);
@@ -129,12 +166,35 @@ impl Core {
role: &str, role: &str,
) -> CoreResult<()> { ) -> CoreResult<()> {
let (_ino, uid) = self.resolve(rel)?; let (_ino, uid) = self.resolve(rel)?;
self.update_share_role_for_uid(&uid, id, kind, role)
}
/// [`Core::update_share_role`] for a node addressed through any daemon
/// location.
pub(crate) fn update_share_role_by_uid(
&self,
uid: &str,
id: &str,
kind: ShareEntryKind,
role: &str,
) -> CoreResult<()> {
let uid = self.resolve_anywhere(uid)?;
self.update_share_role_for_uid(&uid, id, kind, role)
}
fn update_share_role_for_uid(
&self,
uid: &NodeUid,
id: &str,
kind: ShareEntryKind,
role: &str,
) -> CoreResult<()> {
let role = role_from_str(role)?; let role = role_from_str(role)?;
match kind { match kind {
ShareEntryKind::Member => { ShareEntryKind::Member => {
let member = self let member = self
.rt .rt
.block_on(self.client.list_share_members(&uid)) .block_on(self.client.list_share_members(uid))
.map_err(|e| CoreError::from_api(&e, "list members"))? .map_err(|e| CoreError::from_api(&e, "list members"))?
.into_iter() .into_iter()
.find(|m| m.membership_id.to_string() == id) .find(|m| m.membership_id.to_string() == id)
@@ -146,7 +206,7 @@ impl Core {
ShareEntryKind::ProtonInvite => { ShareEntryKind::ProtonInvite => {
let inv = self let inv = self
.rt .rt
.block_on(self.client.list_share_invitations(&uid)) .block_on(self.client.list_share_invitations(uid))
.map_err(|e| CoreError::from_api(&e, "list invitations"))? .map_err(|e| CoreError::from_api(&e, "list invitations"))?
.into_iter() .into_iter()
.find(|i| i.invitation_id == id) .find(|i| i.invitation_id == id)
@@ -170,11 +230,32 @@ impl Core {
kind: ShareEntryKind, kind: ShareEntryKind,
) -> CoreResult<()> { ) -> CoreResult<()> {
let (_ino, uid) = self.resolve(rel)?; let (_ino, uid) = self.resolve(rel)?;
self.remove_share_entry_for_uid(&uid, id, kind)
}
/// [`Core::remove_share_entry`] for a node addressed through any daemon
/// location.
pub(crate) fn remove_share_entry_by_uid(
&self,
uid: &str,
id: &str,
kind: ShareEntryKind,
) -> CoreResult<()> {
let uid = self.resolve_anywhere(uid)?;
self.remove_share_entry_for_uid(&uid, id, kind)
}
fn remove_share_entry_for_uid(
&self,
uid: &NodeUid,
id: &str,
kind: ShareEntryKind,
) -> CoreResult<()> {
match kind { match kind {
ShareEntryKind::Member => { ShareEntryKind::Member => {
let member = self let member = self
.rt .rt
.block_on(self.client.list_share_members(&uid)) .block_on(self.client.list_share_members(uid))
.map_err(|e| CoreError::from_api(&e, "list members"))? .map_err(|e| CoreError::from_api(&e, "list members"))?
.into_iter() .into_iter()
.find(|m| m.membership_id.to_string() == id) .find(|m| m.membership_id.to_string() == id)
@@ -186,7 +267,7 @@ impl Core {
ShareEntryKind::ProtonInvite => { ShareEntryKind::ProtonInvite => {
let inv = self let inv = self
.rt .rt
.block_on(self.client.list_share_invitations(&uid)) .block_on(self.client.list_share_invitations(uid))
.map_err(|e| CoreError::from_api(&e, "list invitations"))? .map_err(|e| CoreError::from_api(&e, "list invitations"))?
.into_iter() .into_iter()
.find(|i| i.invitation_id == id) .find(|i| i.invitation_id == id)
@@ -198,7 +279,7 @@ impl Core {
ShareEntryKind::ExternalInvite => { ShareEntryKind::ExternalInvite => {
let ext = self let ext = self
.rt .rt
.block_on(self.client.list_external_invitations(&uid)) .block_on(self.client.list_external_invitations(uid))
.map_err(|e| CoreError::from_api(&e, "list external invitations"))? .map_err(|e| CoreError::from_api(&e, "list external invitations"))?
.into_iter() .into_iter()
.find(|i| i.invitation_id == id) .find(|i| i.invitation_id == id)
@@ -219,13 +300,33 @@ impl Core {
expires: Option<i64>, expires: Option<i64>,
) -> CoreResult<PublicLinkInfo> { ) -> CoreResult<PublicLinkInfo> {
let (_ino, uid) = self.resolve(rel)?; let (_ino, uid) = self.resolve(rel)?;
self.create_public_link_for_uid(&uid, role, password, expires)
}
/// [`Core::create_public_link`] for a node addressed through any daemon
/// location.
pub(crate) fn create_public_link_by_uid(
&self,
uid: &str,
role: &str,
password: Option<&str>,
expires: Option<i64>,
) -> CoreResult<PublicLinkInfo> {
let uid = self.resolve_anywhere(uid)?;
self.create_public_link_for_uid(&uid, role, password, expires)
}
fn create_public_link_for_uid(
&self,
uid: &NodeUid,
role: &str,
password: Option<&str>,
expires: Option<i64>,
) -> CoreResult<PublicLinkInfo> {
let role = role_from_str(role)?; let role = role_from_str(role)?;
let link = self let link = self
.rt .rt
.block_on( .block_on(self.client.create_public_link(uid, role, password, expires))
self.client
.create_public_link(&uid, role, password, expires),
)
.map_err(|e| CoreError::from_api(&e, "create public link"))?; .map_err(|e| CoreError::from_api(&e, "create public link"))?;
Ok(public_link_info(link)) Ok(public_link_info(link))
} }
@@ -233,9 +334,20 @@ impl Core {
/// Remove the public link `id` from the node at `rel`. /// Remove the public link `id` from the node at `rel`.
pub(crate) fn remove_public_link(&self, rel: &Path, id: &str) -> CoreResult<()> { pub(crate) fn remove_public_link(&self, rel: &Path, id: &str) -> CoreResult<()> {
let (_ino, uid) = self.resolve(rel)?; let (_ino, uid) = self.resolve(rel)?;
self.remove_public_link_for_uid(&uid, id)
}
/// [`Core::remove_public_link`] for a node addressed through any daemon
/// location.
pub(crate) fn remove_public_link_by_uid(&self, uid: &str, id: &str) -> CoreResult<()> {
let uid = self.resolve_anywhere(uid)?;
self.remove_public_link_for_uid(&uid, id)
}
fn remove_public_link_for_uid(&self, uid: &NodeUid, id: &str) -> CoreResult<()> {
let link = self let link = self
.rt .rt
.block_on(self.client.get_public_link(&uid)) .block_on(self.client.get_public_link(uid))
.map_err(|e| CoreError::from_api(&e, "get public link"))? .map_err(|e| CoreError::from_api(&e, "get public link"))?
.filter(|l| l.public_link_id == id) .filter(|l| l.public_link_id == id)
.ok_or_else(|| CoreError::not_found("public link not found"))?; .ok_or_else(|| CoreError::not_found("public link not found"))?;
File diff suppressed because it is too large. Load diff
+10 -2
View File
@@ -82,7 +82,11 @@ impl Core {
return; return;
} }
}; };
let nodes: Vec<_> = stored.into_iter().map(|s| s.node).collect(); let nodes: Vec<_> = stored
.into_iter()
.map(|stored| stored.node)
.filter(|node| self.is_own_or_virtual(&node.uid))
.collect();
// Index live (non-trashed) files by (parent, name) for sibling lookup. // Index live (non-trashed) files by (parent, name) for sibling lookup.
let mut by_parent_name: HashMap<(String, &str), &_> = HashMap::new(); let mut by_parent_name: HashMap<(String, &str), &_> = HashMap::new();
@@ -160,7 +164,10 @@ impl Core {
self.for_each_state(|st| { self.for_each_state(|st| {
st.forget_or_unlink(uid); st.forget_or_unlink(uid);
}); });
self.discard_queued_ops(uid); if let Err(error) = self.discard_queued_ops(uid) {
warn!(%uid, ?error, "conflict sweep: queued-op cleanup failed");
return;
}
self.cache.evict(uid); self.cache.evict(uid);
self.evict_reader(uid); self.evict_reader(uid);
if let Err(e) = self.db.delete_node(uid) { if let Err(e) = self.db.delete_node(uid) {
@@ -346,6 +353,7 @@ mod tests {
is_shared: false, is_shared: false,
is_shared_publicly: false, is_shared_publicly: false,
signature_email: None, signature_email: None,
membership: None,
verification: Default::default(), verification: Default::default(),
} }
} }
+30 -11
View File
@@ -63,6 +63,12 @@ async fn run_uploads(
.transfers .transfers
.begin(&t.name, "", TransferDirection::Upload, t.size); .begin(&t.name, "", TransferDirection::Upload, t.size);
let reader = OwnedCountingReader::new(file, guard); let reader = OwnedCountingReader::new(file, guard);
if let Err(error) = core.require_uid_writable(&t.parent_uid) {
return Err((
t.name,
format!("destination access denied: errno {}", error.code()),
));
}
match core match core
.client .client
.upload_file_from( .upload_file_from(
@@ -110,6 +116,8 @@ impl Core {
let (pino, parent_uid) = self.resolve(parent_rel)?; let (pino, parent_uid) = self.resolve(parent_rel)?;
self.ensure_children(pino) self.ensure_children(pino)
.map_err(|e| self.errno_error(e, "enumerate"))?; .map_err(|e| self.errno_error(e, "enumerate"))?;
self.require_uid_writable(&parent_uid)
.map_err(|error| self.errno_error(error, "upload destination"))?;
// Phase 1 (sequential): build the remote folder skeleton and collect the // Phase 1 (sequential): build the remote folder skeleton and collect the
// flat list of files to upload. Folders must exist before their children, // flat list of files to upload. Folders must exist before their children,
@@ -170,6 +178,8 @@ impl Core {
{ {
kids.push(ino); kids.push(ino);
} }
drop(st);
self.flush_access_changes();
} }
// Uploaded fine but the metadata refresh failed; it will // Uploaded fine but the metadata refresh failed; it will
// appear on the next directory enumeration regardless. // appear on the next directory enumeration regardless.
@@ -205,19 +215,24 @@ impl Core {
} }
self.ensure_children(pino) self.ensure_children(pino)
.map_err(|e| self.errno_error(e, "enumerate"))?; .map_err(|e| self.errno_error(e, "enumerate"))?;
{ let existing = {
let st = self.state.lock(); let st = self.state.lock();
if let Some(kids) = st.children.get(&pino) { st.children.get(&pino).and_then(|kids| {
for &ino in kids { kids.iter().find_map(|ino| {
if let Some(e) = st.entries.get(&ino) st.entries
&& e.node.is_folder() .get(ino)
&& e.node.name == name .filter(|entry| entry.node.is_folder() && entry.node.name == name)
{ .map(|entry| (*ino, entry.uid.clone()))
return Ok((ino, e.uid.clone())); })
} })
} };
} if let Some((ino, uid)) = existing {
self.require_uid_writable(&uid)
.map_err(|error| self.errno_error(error, "upload destination"))?;
return Ok((ino, uid));
} }
self.require_uid_writable(parent_uid)
.map_err(|error| self.errno_error(error, "upload destination"))?;
let new_uid = self let new_uid = self
.rt .rt
.block_on( .block_on(
@@ -235,6 +250,10 @@ impl Core {
{ {
kids.push(ino); kids.push(ino);
} }
drop(st);
self.flush_access_changes();
self.require_uid_writable(&new_uid)
.map_err(|error| self.errno_error(error, "created upload destination"))?;
Ok((ino, new_uid)) Ok((ino, new_uid))
} }
+318
View File
@@ -0,0 +1,318 @@
//! Synthetic nodes and naming rules for the mounted shared tree.
use std::collections::{HashMap, HashSet};
use std::time::{Duration, Instant};
use proton_drive_rs::proton_sdk::ids::{LinkId, NodeUid, VolumeId};
use proton_drive_rs::{Node, NodeKind};
pub(crate) const VIRTUAL_VOLUME: &str = "virtual";
pub(crate) const SHARED_WITH_ME_LINK: &str = "sharedwithme";
pub(crate) const SHARED_WITH_ME_BASE: &str = "Shared with me";
const MAX_COMPONENT_BYTES: usize = 255;
pub(crate) fn shared_with_me_uid() -> NodeUid {
NodeUid::new(
VolumeId::from(VIRTUAL_VOLUME),
LinkId::from(SHARED_WITH_ME_LINK),
)
}
pub(crate) fn is_virtual_uid(uid: &NodeUid) -> bool {
uid.volume_id.as_str() == VIRTUAL_VOLUME
}
pub(crate) fn is_own_or_virtual_uid(uid: &NodeUid, own_volume: &VolumeId) -> bool {
uid.volume_id == *own_volume || is_virtual_uid(uid)
}
pub(crate) fn is_primary_root_listing(
primary: bool,
folder_uid: &NodeUid,
primary_root_uid: &NodeUid,
) -> bool {
primary && folder_uid == primary_root_uid
}
pub(crate) fn virtual_node(parent_uid: NodeUid, name: String, timestamp: i64) -> Node {
Node {
uid: shared_with_me_uid(),
parent_uid: Some(parent_uid),
name,
kind: NodeKind::Folder,
creation_time: timestamp,
modification_time: timestamp,
trashed: false,
is_shared: false,
is_shared_publicly: false,
signature_email: None,
membership: None,
verification: Default::default(),
}
}
/// Choose the initial synthetic-root name. Once persisted, `pinned` always wins;
/// a later real collision only controls whether the synthetic dentry is exposed.
pub(crate) fn virtual_root_name(
real_names: &HashSet<String>,
pinned: Option<&str>,
) -> (String, bool) {
if let Some(pinned) = pinned {
return (pinned.to_string(), !real_names.contains(pinned));
}
for n in 0usize.. {
let candidate = match n {
0 => SHARED_WITH_ME_BASE.to_string(),
1 => format!("{SHARED_WITH_ME_BASE} (Proton)"),
_ => format!("{SHARED_WITH_ME_BASE} (Proton {n})"),
};
if !real_names.contains(&candidate) {
return (candidate, true);
}
}
unreachable!("the numbered virtual-root namespace is unbounded")
}
/// Give every shared root a unique Linux component name, independent of API
/// ordering. Literal suffix-shaped names reserve their basename before duplicate
/// names are numbered, so `Photos (2)` is never stolen by a second `Photos`.
pub(crate) fn disambiguate_shared_names(nodes: &mut [Node]) {
nodes.sort_by(|a, b| {
a.name
.cmp(&b.name)
.then_with(|| a.uid.to_string().cmp(&b.uid.to_string()))
});
let reserved: HashSet<String> = nodes
.iter()
.map(|node| component_with_suffix(&node.name, ""))
.collect();
let mut used = HashSet::with_capacity(nodes.len());
for node in nodes {
let original = node.name.clone();
let basename = component_with_suffix(&original, "");
let chosen = if used.insert(basename.clone()) {
basename
} else {
let mut n = 2usize;
loop {
let suffix = format!(" ({n})");
let candidate = component_with_suffix(&original, &suffix);
if !reserved.contains(&candidate) && used.insert(candidate.clone()) {
break candidate;
}
n += 1;
}
};
node.name = chosen;
}
}
pub(crate) fn listing_needs_refresh(online: bool, stale: bool) -> bool {
online && stale
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SharedListingPlan {
Resident,
Persisted,
Refresh,
}
pub(crate) fn shared_listing_plan(resident: bool, online: bool, stale: bool) -> SharedListingPlan {
if listing_needs_refresh(online, stale) {
SharedListingPlan::Refresh
} else if resident {
SharedListingPlan::Resident
} else {
SharedListingPlan::Persisted
}
}
pub(crate) fn refresh_generation_is_current(started: u64, current: u64) -> bool {
started == current
}
#[derive(Default)]
pub(crate) struct SharedRefreshDeadlines {
deadlines: HashMap<String, Instant>,
}
impl SharedRefreshDeadlines {
pub(crate) fn is_fresh(&mut self, key: &str, now: Instant) -> bool {
self.deadlines.retain(|_, deadline| *deadline > now);
self.deadlines
.get(key)
.is_some_and(|deadline| *deadline > now)
}
pub(crate) fn mark(&mut self, key: &str, now: Instant, ttl: Duration) {
self.deadlines.insert(key.to_string(), now + ttl);
}
pub(crate) fn clear(&mut self) {
self.deadlines.clear();
}
}
fn component_with_suffix(name: &str, suffix: &str) -> String {
let keep = MAX_COMPONENT_BYTES.saturating_sub(suffix.len());
let mut boundary = name.len().min(keep);
while !name.is_char_boundary(boundary) {
boundary -= 1;
}
let mut out = String::with_capacity(boundary + suffix.len());
out.push_str(&name[..boundary]);
out.push_str(suffix);
out
}
#[cfg(test)]
mod tests {
use super::*;
fn folder(volume: &str, link: &str, name: &str) -> Node {
let mut node = virtual_node(
NodeUid::new(VolumeId::from("virtual"), LinkId::from("parent")),
name.to_string(),
1,
);
node.uid = NodeUid::new(VolumeId::from(volume), LinkId::from(link));
node
}
#[test]
fn virtual_uid_and_node_round_trip() {
let parent = NodeUid::new(VolumeId::from("own"), LinkId::from("root"));
let node = virtual_node(parent.clone(), SHARED_WITH_ME_BASE.into(), 42);
assert_eq!(node.uid.to_string(), "virtual~sharedwithme");
assert!(is_virtual_uid(&node.uid));
assert_eq!(node.parent_uid.as_ref(), Some(&parent));
assert!(node.is_folder());
let json = serde_json::to_string(&node).unwrap();
let restored: Node = serde_json::from_str(&json).unwrap();
assert_eq!(restored.uid, node.uid);
assert_eq!(restored.name, SHARED_WITH_ME_BASE);
}
#[test]
fn root_collision_ladder_and_pinned_collision_policy() {
let names = HashSet::from([
SHARED_WITH_ME_BASE.to_string(),
format!("{SHARED_WITH_ME_BASE} (Proton)"),
format!("{SHARED_WITH_ME_BASE} (Proton 2)"),
]);
assert_eq!(
virtual_root_name(&names, None),
(format!("{SHARED_WITH_ME_BASE} (Proton 3)"), true)
);
let (name, visible) = virtual_root_name(&names, Some(SHARED_WITH_ME_BASE));
assert_eq!(name, SHARED_WITH_ME_BASE);
assert!(!visible, "a real later collision suppresses the dentry");
let cleared = HashSet::new();
assert_eq!(
virtual_root_name(&cleared, Some(SHARED_WITH_ME_BASE)),
(SHARED_WITH_ME_BASE.to_string(), true),
"the pinned name returns when the collision clears"
);
}
#[test]
fn duplicate_names_are_stable_under_reordering_and_reserve_suffixes() {
let input = vec![
folder("b", "2", "Photos"),
folder("c", "3", "Photos (2)"),
folder("a", "1", "Photos"),
];
let mut forward = input.clone();
let mut reverse = input;
reverse.reverse();
disambiguate_shared_names(&mut forward);
disambiguate_shared_names(&mut reverse);
let summarize = |nodes: &[Node]| {
nodes
.iter()
.map(|node| (node.uid.to_string(), node.name.clone()))
.collect::<Vec<_>>()
};
assert_eq!(summarize(&forward), summarize(&reverse));
assert_eq!(
forward
.iter()
.map(|node| node.name.as_str())
.collect::<Vec<_>>(),
["Photos", "Photos (3)", "Photos (2)"]
);
}
#[test]
fn disambiguation_respects_utf8_component_limit() {
let long = "é".repeat(200);
let mut nodes = vec![folder("a", "1", &long), folder("b", "2", &long)];
disambiguate_shared_names(&mut nodes);
assert!(nodes.iter().all(|node| node.name.len() <= 255));
assert!(
nodes
.iter()
.all(|node| node.name.is_char_boundary(node.name.len()))
);
assert_ne!(nodes[0].name, nodes[1].name);
assert!(nodes[1].name.ends_with(" (2)"));
}
#[test]
fn ttl_refresh_only_happens_online() {
assert!(!listing_needs_refresh(false, true));
assert!(!listing_needs_refresh(true, false));
assert!(listing_needs_refresh(true, true));
}
#[test]
fn offline_snapshot_suppresses_the_api_and_memory_deadline_suppresses_retries() {
assert_eq!(
shared_listing_plan(false, false, true),
SharedListingPlan::Persisted
);
assert_eq!(
shared_listing_plan(true, true, false),
SharedListingPlan::Resident
);
let now = Instant::now();
let mut deadlines = SharedRefreshDeadlines::default();
deadlines.mark("shared", now, Duration::from_secs(60));
assert!(deadlines.is_fresh("shared", now + Duration::from_secs(30)));
deadlines.clear();
assert!(!deadlines.is_fresh("shared", now + Duration::from_secs(30)));
}
#[test]
fn generation_change_rejects_an_inflight_response() {
assert!(refresh_generation_is_current(7, 7));
assert!(!refresh_generation_is_current(7, 8));
}
#[test]
fn only_the_primary_inode_space_installs_the_synthetic_root() {
let root = NodeUid::new(VolumeId::from("own"), LinkId::from("root"));
assert!(is_primary_root_listing(true, &root, &root));
assert!(!is_primary_root_listing(false, &root, &root));
assert!(!is_primary_root_listing(
true,
&NodeUid::new(VolumeId::from("own"), LinkId::from("device")),
&root
));
}
#[test]
fn ownership_predicate_excludes_foreign_share_volumes() {
let own = VolumeId::from("own");
let own_uid = NodeUid::new(own.clone(), LinkId::from("file"));
let foreign = NodeUid::new(VolumeId::from("foreign"), LinkId::from("share"));
assert!(is_own_or_virtual_uid(&own_uid, &own));
assert!(is_own_or_virtual_uid(&shared_with_me_uid(), &own));
assert!(!is_own_or_virtual_uid(&foreign, &own));
}
}
+50 -4
View File
@@ -1432,12 +1432,22 @@ added later, but it must retain the row and blob.
## B34 — Writable POSIX Exposure of Read-Only Shared Folders (MED-07) ## B34 — Writable POSIX Exposure of Read-Only Shared Folders (MED-07)
**Status:** Open **Status:** Fixed in code by P2/P3 (2026-07-29); P3 now exposes the shared tree, while credentialed
second-account and live FUSE acceptance remain pending P7
**Found:** 2026-07-21, multi-agent permissions audit (`audit_bugs.md` MED-07) **Found:** 2026-07-21, multi-agent permissions audit (`audit_bugs.md` MED-07)
**Where:** `crates/pdfs-fuse/src/lib.rs`, `crates/pdfs-fuse/src/sharing.rs` **Where:** `crates/pdfs-fuse/src/lib.rs`, `crates/pdfs-fuse/src/sharing.rs`
**Cause:** Shared "Viewer" (read-only) folders are exposed via POSIX with write permissions (`0755`). Local writes succeed initially on the mount but fail perpetually during background online drain with `403 Forbidden` errors. **Cause:** Shared "Viewer" (read-only) folders are exposed via POSIX with write permissions (`0755`). Local writes succeed initially on the mount but fail perpetually during background online drain with `403 Forbidden` errors.
**Fix:** P2 persists share authority in schema V17 `share_access`, inherits it through resident
entries, exposes read-only POSIX modes, and gates mutations at handler, queue, and drain boundaries.
Downgrades fail closed and are not acknowledged past failed durable state updates.
**Verification:** The local workspace passes formatting, locked checks, clippy with warnings denied,
and all 347 workspace tests, including P3 virtual-tree materialization and access inheritance plus
mutation gating, queued-operation authorization, and downgrade failure paths. Credentialed
second-account and live FUSE acceptance have not been run and remain required in P7.
--- ---
## B35 — IPC Unix Socket Creation Permission Race (MED-08) ## B35 — IPC Unix Socket Creation Permission Race (MED-08)
@@ -2356,12 +2366,48 @@ correct behaviour for the first live pass is to trash nothing.
## B72 — `pdfs trash` never returns ## B72 — `pdfs trash` never returns
**Status:** Open — found 2026-07-26. **Status:** Hardened in-tree 2026-07-26 — the hang is gone by construction; the
underlying slowness of the first refresh is still unmeasured (live-pending).
**Found:** capturing a trash baseline before the B69/B70 live validation. `pdfs trash` **Found:** capturing a trash baseline before the B69/B70 live validation. `pdfs trash`
produced no output and was still running when killed at 120 s. Daemon was healthy produced no output and was still running when killed at 120 s. Daemon was healthy
and every other CLI call (`pdfs sync list`, `pdfs --version`) returned promptly. and every other CLI call (`pdfs sync list`, `pdfs --version`) returned promptly.
**Where:** unconfirmed — either the `Request` handler in `crates/pdfs-fuse/src/control.rs` **Where:** `Core::list_trash` (`crates/pdfs-fuse/src/lib.rs`). Not the CLI: the
or the CLI's response read in `crates/pdfs-cli/src/main.rs`. client's 120 s `READ_TIMEOUT` (`pdfs-core/src/control.rs`) is what ends the call,
and the daemon never writes a response line.
**Root cause.** The trash of this account had never been fetched successfully —
`trash` table empty, no `trash_synced_ms` in `sync_state`. `list_trash` therefore
always took its "never fetched → `rt.block_on(refresh_trash())`" branch, which:
- materialized the *entire* trash in one `enumerate_nodes` call (one S2K unlock
per node) before persisting anything, so nothing was ever saved and no later
request could be served from the DB;
- had no single-flight guard on that branch (only `spawn_trash_refresh` did), so
every attempt started another full refresh;
- was never cancelled when the requester timed out, because the control handler
sits in `block_on` on its own thread and nothing signals it.
So each look at the trash added a permanent, full-cost refresh to the daemon.
Measured on the live daemon after a handful of GUI/CLI attempts: **11 stale
`pdfs-control` threads**, RSS 2.9 GB. Concurrent with a drain backlog that was
uploading conflict copies continuously (1991 `Conflict` activity rows, multi-GB
uploads), none of the refreshes ever finished.
**Fix (in-tree).** `list_trash` never blocks on the whole refresh: it kicks the
single-flighted background refresh, waits at most `TRASH_FIRST_WAIT` (20 s, well
under the front-end read timeout) on a `Notify`, and answers with whatever has
materialized. `refresh_trash` now materializes in `TRASH_MATERIALIZE_CHUNK` (150)
batches, persisting the cumulative listing after each one, and logs the uid count
plus per-batch and total elapsed at INFO/DEBUG — previously the whole path was
silent, which is why the stall could not be located from the journal.
**Still open:** *why* one refresh is slow enough to matter. The new INFO lines
(`trash refresh: enumerated` / `batch` / `done`) answer that on the next run:
whether `enumerate_trash_node_uids` returns at all, how many nodes the trash
holds, and the per-batch cost. If the count is large, materializing with
`enumerate_nodes_light` (skips the per-file node-key S2K; `size` would fall back
to on-storage size) is the next lever.
**Impact:** the trash listing is the user's recovery path after any destructive **Impact:** the trash listing is the user's recovery path after any destructive
operation, including the B71 sweep. It needs to work before the sweep is allowed operation, including the B71 sweep. It needs to work before the sweep is allowed
to trash anything. to trash anything.
+1 -1
View File
@@ -9,7 +9,7 @@
# Build + install: cd packaging && makepkg -fi # Build + install: cd packaging && makepkg -fi
pkgname=proton-drive-linux pkgname=proton-drive-linux
pkgver=1.1.0 pkgver=1.2.0
pkgrel=1 pkgrel=1
pkgdesc="Proton Drive client for Linux: FUSE files-on-demand mount, CLI, GTK4 app + tray" pkgdesc="Proton Drive client for Linux: FUSE files-on-demand mount, CLI, GTK4 app + tray"
arch=('x86_64') arch=('x86_64')