implemented share_access, POSIX read-only mode

This commit is contained in:
nvrl committed 2026-07-29 14:57:56 +02:00
1 parent 32817870a5
commit 295bbeadac
22 files changed
+3055 -146

No files matched your search

+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
use std::collections::{BTreeMap, HashSet};
use std::fmt;
use std::io::{Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
use std::sync::Arc;
@@ -28,10 +29,11 @@ use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::db::{CacheEntryInput, Db};
use crate::error::Result;
use crate::error::{Error, Result};
/// `cache_entries.kind` tag for whole-file content blobs.
const KIND_BLOB: &str = "blob";
static STAGING_N: AtomicU64 = AtomicU64::new(0);
/// `cache_entries.kind` tag for on-demand block-cache chunks.
const KIND_BLOCK: &str = "block";
/// `cache_entries.kind` tag for cached thumbnails.
@@ -169,6 +171,35 @@ pub struct Baseline {
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.
pub struct ContentCache {
/// Directory holding `<key>` blobs and `<key>.meta` tags.
@@ -225,6 +256,18 @@ pub struct 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
/// `pins_path` and a `max_bytes` size cap (`0` = unlimited). Both parent
/// 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> {
use std::io::Write as _;
static N: AtomicU64 = AtomicU64::new(0);
// 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 path = self.staging_path(meta);
let copied = if let Err(e) = std::fs::rename(scratch, &path) {
if e.kind() != std::io::ErrorKind::CrossesDevices {
return Err(e.into());
@@ -949,6 +983,154 @@ impl ContentCache {
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
/// remote (or the op that owned them has been superseded). Best effort:
/// 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
/// at `release`. A scratch file marked durable must therefore outlive the
/// open that clears the scratch directory — and an unmarked one must not,
+1
View File
@@ -81,6 +81,7 @@ const REPORTED_TABLES: &[&str] = &[
"pending_op",
"activity",
"local_files",
"share_access",
];
impl Db {
+15 -1
View File
@@ -9,7 +9,7 @@ use super::Db;
use crate::Result;
/// Current schema version. Bump on every forward migration added below.
pub(super) const SCHEMA_VERSION: i64 = 16;
pub(super) const SCHEMA_VERSION: i64 = 17;
impl Db {
pub(super) fn migrate(&self) -> Result<()> {
@@ -89,6 +89,9 @@ impl Db {
if current < 16 {
tx.execute_batch(MIGRATION_V16)?;
}
if current < 17 {
tx.execute_batch(MIGRATION_V17)?;
}
tx.execute(
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
@@ -411,3 +414,14 @@ CREATE INDEX IF NOT EXISTS idx_local_files_name_nocase
ON local_files(name COLLATE NOCASE);
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'))
);
";
+2 -1
View File
@@ -31,6 +31,7 @@ mod nodes;
mod ops;
mod photos;
mod pins;
mod share_access;
mod state;
mod sync;
mod trash;
@@ -43,7 +44,7 @@ pub use maintenance::{DbStats, VacuumOutcome};
pub use nodes::{SearchHit, StoredNode};
pub use ops::{
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 sync::{StoredSyncEntry, StoredSyncFolder};
+26
View File
@@ -148,6 +148,32 @@ impl Db {
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.
pub fn has_children(&self, parent_uid: &NodeUid) -> Result<bool> {
let uid_str = parent_uid.to_string();
+100
View File
@@ -3,6 +3,7 @@
//! order, so a child never drains before the parent that gives it a real uid.
use rusqlite::{OptionalExtension, params};
use serde::{Deserialize, Serialize};
use super::Db;
use crate::Result;
@@ -35,6 +36,16 @@ pub const OP_MKDIR: &str = "mkdir";
/// that folder's own drain, exactly as for [`OP_CREATE`].
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
/// (offline.md Phase 3b).
///
@@ -127,6 +138,32 @@ pub struct AttachedBlob {
}
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>)> {
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
@@ -167,6 +204,50 @@ impl Db {
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
/// any blob it previously held so the caller can discard it.
///
@@ -509,4 +590,23 @@ impl Db {
)?;
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)))
}
}
+311
View File
@@ -350,6 +350,34 @@ fn delete_node_removes_row() {
assert!(db.load_all().unwrap().is_empty());
}
#[test]
fn completing_trash_atomically_removes_the_op_and_retained_node() {
let db = Db::open_in_memory().unwrap();
let node = file("trash-me", "parent", "trash-me.txt", 4);
let uid = node.uid.clone();
db.upsert_node(&node).unwrap();
db.enqueue_op(&PendingOp {
id: 0,
kind: OP_TRASH.to_string(),
uid: uid.to_string(),
parent_uid: None,
name: Some(node.name.clone()),
blob_path: None,
meta_json: None,
created_at: 1,
attempts: 0,
last_error: None,
next_attempt_at: 0,
})
.unwrap();
let op = db.pending_ops().unwrap().remove(0);
db.complete_trash_op(op.id, &uid).unwrap();
assert!(db.node_by_uid(&uid.to_string()).unwrap().is_none());
assert!(db.pending_ops().unwrap().is_empty());
}
#[test]
fn children_if_listed_gated_on_flag() {
let db = Db::open_in_memory().unwrap();
@@ -784,6 +812,154 @@ fn opens_and_migrates() {
assert_eq!(version, SCHEMA_VERSION.to_string());
}
#[test]
fn share_access_round_trips_updates_and_deletes() {
let db = Db::open_in_memory().unwrap();
let root = uid("shared-root");
assert_eq!(db.share_access(&root).unwrap(), None);
for access in [
crate::Access::Owner,
crate::Access::Editor,
crate::Access::Viewer,
crate::Access::Unknown,
] {
db.set_share_access(&root, access).unwrap();
assert_eq!(db.share_access(&root).unwrap(), Some(access));
assert_eq!(db.all_share_access().unwrap().get(&root), Some(&access));
}
db.delete_share_access(&root).unwrap();
assert_eq!(db.share_access(&root).unwrap(), None);
assert!(db.all_share_access().unwrap().is_empty());
}
#[test]
fn effective_node_access_inherits_from_the_persisted_share_root() {
let db = Db::open_in_memory().unwrap();
db.upsert_node(&folder("root", None, "My Files")).unwrap();
db.upsert_node(&folder("shared", Some("root"), "Shared"))
.unwrap();
db.upsert_node(&folder("nested", Some("shared"), "Nested"))
.unwrap();
db.upsert_node(&file("child", "nested", "child.txt", 1))
.unwrap();
db.set_share_access(&uid("shared"), crate::Access::Viewer)
.unwrap();
assert_eq!(
db.effective_node_access(&uid("child")).unwrap(),
Some(crate::Access::Viewer)
);
assert_eq!(
db.effective_node_access(&uid("root")).unwrap(),
Some(crate::Access::Owner)
);
assert_eq!(db.effective_node_access(&uid("missing")).unwrap(), None);
}
#[test]
fn effective_node_access_uses_the_nearest_nested_share_root() {
let db = Db::open_in_memory().unwrap();
db.upsert_node(&folder("root", None, "My Files")).unwrap();
db.upsert_node(&folder("outer", Some("root"), "Outer"))
.unwrap();
db.upsert_node(&folder("inner", Some("outer"), "Inner"))
.unwrap();
db.upsert_node(&file("child", "inner", "child.txt", 1))
.unwrap();
db.set_share_access(&uid("outer"), crate::Access::Editor)
.unwrap();
db.set_share_access(&uid("inner"), crate::Access::Viewer)
.unwrap();
assert_eq!(
db.effective_node_access(&uid("child")).unwrap(),
Some(crate::Access::Viewer)
);
}
#[test]
fn effective_node_access_keeps_a_missing_share_root_tombstone_authoritative() {
let db = Db::open_in_memory().unwrap();
db.upsert_node(&folder("root", None, "My Files")).unwrap();
db.upsert_node(&folder("shared", Some("root"), "Shared"))
.unwrap();
db.upsert_node(&folder("nested", Some("shared"), "Nested"))
.unwrap();
db.upsert_node(&file("child", "nested", "child.txt", 1))
.unwrap();
db.set_share_access(&uid("shared"), crate::Access::Viewer)
.unwrap();
db.delete_node(&uid("shared")).unwrap();
assert_eq!(
db.effective_node_access(&uid("child")).unwrap(),
Some(crate::Access::Viewer)
);
}
#[test]
fn downgrade_all_share_access_leaves_unrecorded_owned_nodes_owned() {
let db = Db::open_in_memory().unwrap();
db.upsert_node(&folder("root", None, "My Files")).unwrap();
db.upsert_node(&folder("shared", Some("root"), "Shared"))
.unwrap();
db.set_share_access(&uid("shared"), crate::Access::Editor)
.unwrap();
assert_eq!(db.downgrade_all_share_access().unwrap(), 1);
assert_eq!(
db.effective_node_access(&uid("shared")).unwrap(),
Some(crate::Access::Viewer)
);
assert_eq!(
db.effective_node_access(&uid("root")).unwrap(),
Some(crate::Access::Owner)
);
}
#[test]
fn migration_v17_adds_share_access_to_a_v16_fixture() {
let path = std::env::temp_dir().join(format!(
"pdfs-db-v16-fixture-{}-{}.db",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
{
// V17 only adds `share_access`, so removing it and restoring the version
// stamp produces the exact schema a released V16 database had.
let db = Db::open(&path).unwrap();
db.set_state_str("fixture_value", "survives").unwrap();
}
{
let conn = rusqlite::Connection::open(&path).unwrap();
conn.execute_batch(
"DROP TABLE share_access;
UPDATE sync_state SET value = '16' WHERE key = 'schema_version';",
)
.unwrap();
}
let db = Db::open(&path).unwrap();
assert_eq!(
db.state_str("fixture_value").unwrap().as_deref(),
Some("survives")
);
let root = uid("offline-share");
db.set_share_access(&root, crate::Access::Viewer).unwrap();
assert_eq!(db.share_access(&root).unwrap(), Some(crate::Access::Viewer));
assert_eq!(
db.state_str("schema_version").unwrap().as_deref(),
Some("17")
);
drop(db);
let _ = std::fs::remove_file(path);
}
#[test]
fn refuses_a_schema_newer_than_the_running_build() {
let path = std::env::temp_dir().join(format!(
@@ -935,6 +1111,82 @@ fn failed_superseding_insert_keeps_the_old_pending_op() {
assert_eq!(ops[0].blob_path.as_deref(), Some("/staging/original"));
}
#[test]
fn atomic_trash_replacement_returns_owned_blobs_after_commit() {
let db = Db::open_in_memory().unwrap();
let root = uid("trash-root").to_string();
let child = uid("trash-child").to_string();
let revision = |uid: &str, parent_uid: Option<&str>, blob: &str| PendingOp {
id: 0,
kind: OP_REVISION.to_string(),
uid: uid.to_string(),
parent_uid: parent_uid.map(str::to_string),
name: None,
blob_path: Some(blob.to_string()),
meta_json: Some("{}".to_string()),
created_at: 1,
attempts: 0,
last_error: None,
next_attempt_at: 0,
};
db.enqueue_op(&revision(&root, None, "/staging/root"))
.unwrap();
db.enqueue_op(&revision(&child, Some(&root), "/staging/child"))
.unwrap();
let (trash_id, mut blobs) = db.replace_ops_with_trash(&root, "removed.txt", 2).unwrap();
blobs.sort();
assert_eq!(blobs, vec!["/staging/child", "/staging/root"]);
let ops = db.pending_ops().unwrap();
assert_eq!(ops.len(), 1);
assert_eq!(ops[0].id, trash_id);
assert_eq!(ops[0].kind, OP_TRASH);
assert_eq!(ops[0].uid, root);
assert_eq!(ops[0].name.as_deref(), Some("removed.txt"));
assert!(ops[0].blob_path.is_none());
}
#[test]
fn failed_atomic_trash_insert_keeps_prior_revision_and_blob_ownership() {
let db = Db::open_in_memory().unwrap();
let node = uid("atomic-trash").to_string();
db.enqueue_op(&PendingOp {
id: 0,
kind: OP_REVISION.to_string(),
uid: node.clone(),
parent_uid: None,
name: None,
blob_path: Some("/staging/still-owned".to_string()),
meta_json: Some("{}".to_string()),
created_at: 1,
attempts: 0,
last_error: None,
next_attempt_at: 0,
})
.unwrap();
db.with_conn(|conn| {
conn.execute_batch(
"CREATE TRIGGER reject_trash_insert BEFORE INSERT ON pending_op
WHEN NEW.kind = 'trash'
BEGIN SELECT RAISE(ABORT, 'injected trash insert failure'); END;",
)?;
Ok(())
})
.unwrap();
assert!(db.replace_ops_with_trash(&node, "removed.txt", 2).is_err());
let ops = db.pending_ops().unwrap();
assert_eq!(ops.len(), 1);
assert_eq!(ops[0].kind, OP_REVISION);
assert_eq!(ops[0].uid, node);
assert_eq!(
ops[0].blob_path.as_deref(),
Some("/staging/still-owned"),
"the rolled-back revision still owns its staged bytes"
);
}
/// Deleting a folder that was created offline must take the ops queued
/// underneath it with it: they name a placeholder parent that will now never
/// become real, so nothing could ever drain them and nothing is left to
@@ -1202,6 +1454,63 @@ fn a_failed_op_stays_queued_with_backoff() {
assert_eq!(db.pending_op_counts().unwrap().uploads, 0);
}
#[test]
fn an_access_blocked_op_is_deferred_without_counting_an_attempt() {
let db = Db::open_in_memory().unwrap();
db.enqueue_op(&PendingOp {
id: 0,
kind: OP_REVISION.to_string(),
uid: uid("blocked").to_string(),
parent_uid: None,
name: None,
blob_path: Some("/staging/blocked".to_string()),
meta_json: Some("{}".to_string()),
created_at: 1,
attempts: 3,
last_error: Some("earlier network failure".to_string()),
next_attempt_at: 0,
})
.unwrap();
let before = db.pending_ops().unwrap();
let id = before[0].id;
let count = db.pending_op_counts().unwrap();
db.defer_op_without_attempt(id, 5_000).unwrap();
let after = db.pending_ops().unwrap();
assert_eq!(after.len(), 1);
let after_count = db.pending_op_counts().unwrap();
assert_eq!(after_count.uploads, count.uploads);
assert_eq!(after_count.changes, count.changes);
assert_eq!(after[0].attempts, before[0].attempts);
assert_eq!(after[0].last_error, before[0].last_error);
assert_eq!(after[0].next_attempt_at, 5_000);
}
#[test]
fn access_deferral_does_not_unpark_a_transient_create() {
let db = Db::open_in_memory().unwrap();
db.enqueue_op(&PendingOp {
id: 0,
kind: OP_CREATE.to_string(),
uid: uid("transient").to_string(),
parent_uid: Some(uid("parent").to_string()),
name: Some(".part".to_string()),
blob_path: Some("/staging/transient".to_string()),
meta_json: Some("{}".to_string()),
created_at: 1,
attempts: 0,
last_error: None,
next_attempt_at: PARK_UNTIL,
})
.unwrap();
let op = db.pending_ops().unwrap().remove(0);
db.defer_op_without_attempt(op.id, 5_000).unwrap();
assert_eq!(db.pending_ops().unwrap()[0].next_attempt_at, PARK_UNTIL);
}
#[test]
fn migrate_is_idempotent() {
let db = Db::open_in_memory().unwrap();
@@ -1846,6 +2155,7 @@ fn test_db_has_children_and_trashed_filtering() {
is_shared: false,
is_shared_publicly: false,
signature_email: None,
membership: None,
verification: Default::default(),
};
db.upsert_node(&parent_node).unwrap();
@@ -1869,6 +2179,7 @@ fn test_db_has_children_and_trashed_filtering() {
is_shared: false,
is_shared_publicly: false,
signature_email: None,
membership: None,
verification: Default::default(),
};
db.upsert_node(&child_node).unwrap();
+2
View File
@@ -2,6 +2,7 @@
//! identity, XDG paths, and the login/session/keyring layer that every
//! front-end (CLI today, daemon + GUI later) shares.
pub mod access;
pub mod auth;
pub mod cache;
pub mod config;
@@ -15,6 +16,7 @@ pub mod service;
pub mod shell;
pub mod syncignore;
pub use access::{Access, access_for, perm_bits};
pub use error::{CoreError, CoreResult, Error, Result};
// Re-export the SDK surface downstream crates need, so they depend on