Files
proton-drive-linux/crates/pdfs-fuse/src/state.rs
T
nvrl ea10a43216 fix(fuse): a copy right after a save waits for the edit (B256)
close(2) returns before the kernel sends release, and release takes the
write handle out of the open writes a few milliseconds before the
revision is in the queue. A `pdfs copy` checked in that gap found the
edit nowhere and copied the version Drive had. A folder copy looked only
at the queue, so it also missed files below it still open with unsaved
writes.

release now keeps the inode in State::releasing until the revision is
queued. A copy refuses a file or folder with unsaved writes, or a
release still queueing, at it or below it, and a move checks the same
set.
2026-10-26 17:45:14 +01:00

2376 lines
94 KiB
Rust

//! Inode bookkeeping: the maps that give a remote node a stable kernel inode,
//! the open write handles, and the interval set that tracks which bytes of a
//! staged write are authored locally.
use std::collections::{HashMap, HashSet, VecDeque};
use std::fs::File;
use std::path::PathBuf;
use std::sync::Arc;
use pdfs_core::cache::StagedWrite;
use pdfs_core::db::Db;
use pdfs_core::{Access, access_for};
#[cfg(test)]
use proton_drive_rs::MemberRole;
use proton_drive_rs::proton_sdk::ids::NodeUid;
use proton_drive_rs::{Node, NodeKind};
use tracing::warn;
/// A node known to the filesystem, addressed by its kernel inode.
pub(crate) struct Entry {
pub(crate) uid: NodeUid,
pub(crate) parent: u64,
pub(crate) node: Node,
pub(crate) access: Access,
pub(crate) lookup_count: u64,
pub(crate) open_count: u32,
pub(crate) unlinked: bool,
}
impl Entry {
pub(crate) fn writable(&self) -> bool {
self.access.writable()
}
}
/// A set of non-overlapping `[start, end)` byte ranges, kept sorted and merged.
/// Tracks which bytes of a [`WriteHandle`]'s scratch file were authored locally
/// (vs. still living only in the remote base), so reads and the commit gap-fill
/// know which regions to pull from the network.
#[derive(Clone, Default)]
pub(crate) struct Intervals(pub(crate) Vec<(u64, u64)>);
impl Intervals {
/// Mark `[start, end)` as authored, coalescing with any touching ranges.
pub(crate) fn add(&mut self, start: u64, end: u64) {
if start >= end {
return;
}
self.0.push((start, end));
self.0.sort_by_key(|&(s, _)| s);
let mut merged: Vec<(u64, u64)> = Vec::with_capacity(self.0.len());
for &(s, e) in &self.0 {
match merged.last_mut() {
Some(last) if s <= last.1 => last.1 = last.1.max(e),
_ => merged.push((s, e)),
}
}
self.0 = merged;
}
/// Drop everything at or beyond `len` (a shrink/truncate).
pub(crate) fn clip(&mut self, len: u64) {
self.0.retain(|&(s, _)| s < len);
for iv in &mut self.0 {
iv.1 = iv.1.min(len);
}
}
/// Split `[start, end)` into contiguous `(s, e, authored)` segments, in
/// order. `authored == true` means the bytes live in the scratch file;
/// `false` means they must come from the remote base (or are a hole).
pub(crate) fn segments(&self, start: u64, end: u64) -> Vec<(u64, u64, bool)> {
let mut out = Vec::new();
let mut pos = start;
for &(s, e) in &self.0 {
if e <= start {
continue;
}
if s >= end {
break;
}
let ws = s.max(start);
let we = e.min(end);
if pos < ws {
out.push((pos, ws, false));
}
out.push((ws, we, true));
pos = we;
}
if pos < end {
out.push((pos, end, false));
}
out
}
}
/// State for a file opened for writing. Authored bytes are staged in an on-disk
/// scratch file (positional reads/writes) rather than RAM, so a multi-GiB write
/// never balloons the daemon. On flush/release the scratch file — gap-filled
/// from the remote base where untouched — is streamed up as one new revision,
/// since the SDK seals whole revisions rather than byte ranges.
pub(crate) struct WriteHandle {
pub(crate) ino: u64,
pub(crate) uid: NodeUid,
/// Disk-backed staging buffer. Shared (`Arc`) so reads can use it without
/// holding the state lock across I/O. Accessed positionally (`read_at`/
/// `write_at`), so a clone never disturbs another's file offset.
pub(crate) file: Arc<File>,
/// Scratch file path, removed on release.
pub(crate) path: PathBuf,
/// Byte ranges authored into `file`. Everything else in `[0, len)` is base.
pub(crate) written: Intervals,
/// Logical file size (may exceed authored bytes after a truncate-extend).
pub(crate) len: u64,
/// Size of the remote base at open, for serving untouched ranges.
pub(crate) base_size: u64,
/// Modification time of the remote base at open, validating its block cache.
pub(crate) base_mtime: i64,
/// Server revision id of the remote base at open, if it had one. This is the
/// stable identity the drain conflict-checks against: `base_mtime` alone
/// drifts when the server re-stamps the *same* revision, which reads as a
/// spurious conflict (see B16/B25). `None` for a file with no sealed remote
/// revision, or one read through a surface that does not carry the id.
pub(crate) base_revision_id: Option<String>,
/// Whether anything diverged from the remote and needs an upload.
pub(crate) dirty: bool,
/// Number of file handles currently sharing this scratch state.
pub(crate) open_count: usize,
}
impl WriteHandle {
/// A handle on a file `create` just made: an empty base, so everything
/// written through it is authored.
///
/// The base is `node` as it actually exists — the empty file the server just
/// minted, or a queued create's placeholder — never the local clock or a
/// blank. `queue_revision` turns it into `StagedWrite::based_on` and the
/// drain compares the remote against that. A `now_secs()` mtime differs from
/// the server's stamp by the create round-trip. A missing revision id drops
/// the check to `(mtime, size)`, and moving or renaming the file advances
/// its mtime on the server. Either way the first write to a brand-new file
/// lands as a conflict copy of its own empty create (`docs/BUGS.md` B113).
pub(crate) fn created(ino: u64, node: &Node, file: File, path: PathBuf) -> Self {
Self {
ino,
uid: node.uid.clone(),
file: Arc::new(file),
path,
written: Intervals::default(),
len: 0,
base_size: 0,
base_mtime: node.modification_time,
base_revision_id: crate::node_revision_id(node),
dirty: false,
open_count: 0,
}
}
/// Write over `node` from now on: a revision this daemon has just put on the
/// server under the handle, by sealing an upload or by landing the queued
/// create the handle was opened on. The base the handle opened over is gone,
/// and keeping it would make the drain read our own revision as another
/// device's edit.
pub(crate) fn rebase_onto(&mut self, node: &Node) {
self.base_mtime = node.modification_time;
self.base_size = crate::node_size(node);
self.base_revision_id = crate::node_revision_id(node);
}
/// Account for `[offset, end)` having been written through this handle.
///
/// A write past the end leaves a hole that reads as zeros, exactly like a
/// grow through `setattr`, so the hole is authored too. Left unauthored, a
/// hole below the base size was refilled from the base at commit: a file
/// shrunk and then written past its new end got its old bytes back.
pub(crate) fn record_write(&mut self, offset: u64, end: u64) {
if offset > self.len {
self.written.add(self.len, offset);
}
self.written.add(offset, end);
self.len = self.len.max(end);
self.dirty = true;
}
}
/// A released write whose upload has not happened yet.
///
/// The bytes live in the content cache's staging dir and the intent lives in the
/// `pending_op` table; this pairs them in memory so a read can be served without
/// a database round trip.
#[derive(Clone)]
pub(crate) struct PendingRevision {
/// Staged blob holding the written bytes.
pub(crate) path: PathBuf,
/// Which of those bytes are real, and what base the gaps refer to.
pub(crate) meta: StagedWrite,
}
/// One directory's entries as `readdir` reports them: `(ino, is_dir, name)` per
/// entry, in reply order. The kernel's file-type enum is mapped in at reply
/// time so this module stays free of `fuser`.
pub(crate) type DirListing = Vec<(u64, bool, String)>;
/// Where [`State::next_ino`] starts: far above any local id a database hands
/// out, so a fallback inode never names a node that has a row.
pub(crate) const FALLBACK_INO: u64 = 1 << 48;
/// The inode of the node with local id `lid` ([`Db::lids_for`]). One above the
/// lid, so no node takes [`crate::ROOT_INO`], which each session uses for its
/// own root. The same in every mount and after a restart.
pub(crate) fn lid_ino(lid: i64) -> u64 {
lid.unsigned_abs() + crate::ROOT_INO
}
/// The inodes of the stored nodes among `nodes`, from their local ids, for
/// [`State::intern_batch_with_inos`]. Called before the state lock is taken;
/// a node that already has a row keeps its id. Empty when the database fails,
/// which leaves the allocation to the lock holder, as before.
pub(crate) fn inos_for(db: &Db, nodes: &[Node]) -> HashMap<NodeUid, u64> {
let stored: Vec<&Node> = nodes
.iter()
.filter(|node| !crate::r#virtual::is_virtual_uid(&node.uid))
.collect();
if stored.is_empty() {
return HashMap::new();
}
match db.lids_for(&stored) {
Ok(lids) => stored
.iter()
.zip(lids)
.map(|(node, lid)| (node.uid.clone(), lid_ino(lid)))
.collect(),
Err(error) => {
warn!(%error, "no local ids before the listing; allocating them under the lock");
HashMap::new()
}
}
}
/// The local id of the node at inode `ino`, the inverse of [`lid_ino`]:
/// `None` for the root and a fallback inode.
pub(crate) fn ino_lid(ino: u64) -> Option<i64> {
(ino > crate::ROOT_INO && ino < FALLBACK_INO).then(|| (ino - crate::ROOT_INO) as i64)
}
/// Mutable inode bookkeeping, guarded by a mutex because fuser drives the
/// `Filesystem` trait through `&self`.
///
/// One tree for every mount of the daemon. Each FUSE session sees it from its
/// own root ([`State::roots`]) and calls that root inode 1; `ProtonFs`
/// translates at the kernel boundary.
pub(crate) struct State {
/// inode -> node metadata.
pub(crate) entries: HashMap<u64, Entry>,
/// Dedupe inodes by node uid so a node keeps a stable inode across lookups.
pub(crate) by_uid: HashMap<NodeUid, u64>,
/// Cached directory listings: parent inode -> child inodes. Presence of a
/// key means the directory has been enumerated.
pub(crate) children: HashMap<u64, Vec<u64>>,
/// The inodes some session has been rooted at ([`State::install_root`]).
/// A root is its own parent. Kept after its session unmounts: the node is
/// still one of ours.
pub(crate) roots: HashSet<u64>,
/// The next inode for a node the database could not give a local id, from
/// a range lids never reach ([`FALLBACK_INO`]).
pub(crate) next_ino: u64,
/// Shared write state keyed by inode. Concurrent writers share the scratch file.
pub(crate) active_writes: HashMap<u64, WriteHandle>,
/// Inodes whose last write handle `release` has taken out of
/// [`State::active_writes`] and is still queueing. `close(2)` returns
/// before that, and in between the edit is in neither the handles nor the
/// queue, so a check for changes not on Drive yet looks here too.
pub(crate) releasing: HashSet<u64>,
/// Maps file handle id (fh) to inode (ino). Read-only opens use fh 0 and
/// have no entry here.
pub(crate) handles: HashMap<u64, u64>,
pub(crate) next_fh: u64,
/// One frozen listing per open directory handle, taken at `opendir`.
///
/// `readdir` resumes from a byte offset into the listing it is walking, so
/// that listing has to stay the same across the calls of one `getdents`
/// sequence. Rebuilding it live meant a create or a trash landing between
/// two pages shifted every later entry, and the caller silently skipped or
/// repeated whatever crossed the page boundary. Each entry is
/// `(ino, is_dir, name)` — the kernel type is mapped at reply time so this
/// module stays free of `fuser` (`docs/BUGS.md` B43).
pub(crate) dir_snapshots: HashMap<u64, Arc<DirListing>>,
/// Resident inode attributes whose effective access changed during an
/// intern/root refresh. Core drains this set and notifies the matching
/// kernel only after releasing the State lock.
pub(crate) access_changes: HashSet<u64>,
/// Persisted shared-root authority, loaded once and updated
/// on every explicit role/tombstone change. Descendant inheritance reads
/// this map instead of issuing one SQLite query per interned inode.
pub(crate) share_access: HashMap<NodeUid, Access>,
/// Unified SQLite metadata cache. Read from here (`has_children`) but never
/// written: mutations go to [`State::outbox`] and are applied after the lock
/// is released. See [`StateGuard`].
pub(crate) db: Arc<Db>,
/// Write-throughs this mutation owes SQLite, applied by [`StateGuard`] once
/// the inode lock is released.
///
/// The DB stays the authoritative copy across restarts, so every map
/// mutation still writes through — it just no longer does so with the
/// whole-mount lock held. A single listing's write-through is one
/// transaction over up to a few thousand rows, and every other FUSE callback
/// used to wait behind it for no reason: the maps were already updated
/// before the first row was written.
outbox: Vec<DbWrite>,
}
/// A write-through queued by a `State` mutation, applied after the lock drops.
///
/// Deliberately a small closed set rather than a boxed closure: these run
/// outside the lock, and what they can touch should be readable here.
pub(crate) enum DbWrite {
/// One node or a whole listing. Both go through `upsert_nodes`, which is a
/// single transaction either way, so there is nothing to gain by splitting
/// them — and a `Node` is large enough that a variant holding one inline
/// would set the size of every other.
Upsert(Vec<Node>),
Delete(NodeUid),
SetListed(NodeUid, bool),
SetShareAccess(NodeUid, Access),
}
impl DbWrite {
fn apply(self, db: &Db) {
let outcome = match &self {
Self::Upsert(nodes) => db.upsert_nodes(nodes),
Self::Delete(uid) => db.delete_node(uid),
Self::SetListed(uid, listed) => db.set_listed(uid, *listed),
Self::SetShareAccess(uid, access) => db.set_share_access(uid, *access),
};
if let Err(error) = outcome {
let what = match &self {
Self::Upsert(_) => "upsert_nodes",
Self::Delete(_) => "delete_node",
Self::SetListed(..) => "set_listed",
Self::SetShareAccess(..) => "set_share_access",
};
warn!(%error, write = what, "db write-through failed");
}
}
}
/// The inode lock, held for the duration of a mutation, plus the write-throughs
/// that mutation owed the database.
///
/// Dropping it releases the lock *first* and only then writes, which is the
/// whole point: interning a five-thousand-child listing commits one transaction
/// — several fsyncs' worth — and nothing about that needs the maps frozen. The
/// write still happens synchronously on this thread before the caller proceeds,
/// so a read that follows a mutation still sees it.
pub(crate) struct StateGuard<'a> {
guard: Option<parking_lot::MutexGuard<'a, State>>,
db: &'a Arc<Db>,
}
impl<'a> StateGuard<'a> {
pub(crate) fn new(guard: parking_lot::MutexGuard<'a, State>, db: &'a Arc<Db>) -> Self {
Self {
guard: Some(guard),
db,
}
}
}
impl std::ops::Deref for StateGuard<'_> {
type Target = State;
fn deref(&self) -> &State {
self.guard.as_ref().expect("held until drop")
}
}
impl std::ops::DerefMut for StateGuard<'_> {
fn deref_mut(&mut self) -> &mut State {
self.guard.as_mut().expect("held until drop")
}
}
impl Drop for StateGuard<'_> {
fn drop(&mut self) {
let Some(mut guard) = self.guard.take() else {
return;
};
let writes = std::mem::take(&mut guard.outbox);
// The lock goes before the writes do. That ordering is the feature.
drop(guard);
for write in writes {
write.apply(self.db);
}
}
}
impl State {
/// A fresh, empty inode space over `db`.
///
/// Apply the write-throughs this state owes SQLite, here and now.
///
/// Production code never calls this: [`StateGuard`] releases the inode lock
/// first and then applies them, which is the whole point. A test driving a
/// bare `State` has no guard, so it calls this where the daemon would have
/// dropped one.
#[cfg(test)]
pub(crate) fn flush_outbox(&mut self) {
let db = self.db.clone();
for write in std::mem::take(&mut self.outbox) {
write.apply(&db);
}
}
/// The database, with everything this state owes it already written.
///
/// What a test means by `st.db` is almost always "the database as of now",
/// which in the daemon is the state after the guard dropped. Handing back an
/// owned `Arc` rather than a borrow keeps it usable inside an `assert_eq!`
/// that also touches `st`.
#[cfg(test)]
pub(crate) fn flushed_db(&mut self) -> Arc<Db> {
self.flush_outbox();
self.db.clone()
}
/// A fresh, empty tree over `db`. A node's inode comes from its local id
/// ([`lid_ino`]).
pub(crate) fn new(db: Arc<Db>, share_access: HashMap<NodeUid, Access>) -> Self {
Self {
entries: HashMap::new(),
by_uid: HashMap::new(),
children: HashMap::new(),
roots: HashSet::new(),
next_ino: FALLBACK_INO,
active_writes: HashMap::new(),
releasing: HashSet::new(),
handles: HashMap::new(),
next_fh: 1,
dir_snapshots: HashMap::new(),
access_changes: HashSet::new(),
share_access,
db,
outbox: Vec::new(),
}
}
/// Make `root` the root of a session and return its inode, which comes
/// from its local id like any other node's.
///
/// The root is always owned. It must never be classified as a shared root,
/// even when the API returns membership data (which happens when the owner
/// shares the folder with someone else), so a stale share row a downgrade
/// may have left for it is dropped. A node already in the tree keeps its
/// inode and its lookups, and stops naming a parent: a session's `..` at
/// its root is the root.
pub(crate) fn install_root(&mut self, root: Node) -> u64 {
if let Err(e) = self.db.upsert_node(&root) {
warn!(uid = %root.uid, error = %e, "db upsert root failed");
}
self.share_access.remove(&root.uid);
let _ = self.db.delete_share_access(&root.uid);
let ino = match self.by_uid.get(&root.uid) {
Some(&ino) => ino,
None => self.new_inos(&[&root])[&root.uid],
};
if let Some(old) = self.entries.get(&ino).map(|e| e.parent)
&& old != ino
&& let Some(kids) = self.children.get_mut(&old)
{
kids.retain(|&k| k != ino);
}
self.by_uid.insert(root.uid.clone(), ino);
let lookup_count = self.entries.get(&ino).map_or(1, |e| e.lookup_count.max(1));
self.entries.insert(
ino,
Entry {
uid: root.uid.clone(),
parent: ino,
node: root,
access: Access::Owner,
lookup_count,
open_count: 0,
unlinked: false,
},
);
self.roots.insert(ino);
ino
}
/// Whether `uid` names an owned node currently reachable from one of
/// `roots`, the roots of the sessions that are mounted. Resident entries
/// can outlive their dentries for open-handle and access-revocation
/// semantics, so `by_uid` membership alone is not proof that a
/// control-socket operation may address the node.
pub(crate) fn owns_visible_uid(&self, uid: &NodeUid, roots: &[u64]) -> bool {
let Some(&target) = self.by_uid.get(uid) else {
return false;
};
let mut current = target;
let mut seen = HashSet::new();
loop {
if !seen.insert(current) {
return false;
}
let Some(entry) = self.entries.get(&current) else {
return false;
};
// The node's root scopes it to its own volume, excluding foreign
// shared-with-me residents from uid-addressed sharing.
if entry.uid.volume_id != uid.volume_id
|| entry.unlinked
|| entry.node.trashed
|| entry.uid != entry.node.uid
|| self.by_uid.get(&entry.uid) != Some(&current)
{
return false;
}
if roots.contains(&current) {
return true;
}
let parent = entry.parent;
if parent == 0 || parent == current {
return false;
}
let Some(parent_entry) = self.entries.get(&parent) else {
return false;
};
if entry.node.parent_uid.as_ref() != Some(&parent_entry.uid)
|| self
.children
.get(&parent)
.is_some_and(|children| !children.contains(&current))
{
return false;
}
current = parent;
}
}
pub(crate) fn require_writable(&self, ino: u64) -> Result<(), fuser::Errno> {
match self.entries.get(&ino) {
Some(entry) if entry.writable() => Ok(()),
Some(_) => Err(fuser::Errno::EACCES),
None => Err(fuser::Errno::ENOENT),
}
}
pub(crate) fn access_by_uid(&self, uid: &NodeUid) -> Option<Access> {
self.by_uid
.get(uid)
.and_then(|ino| self.entries.get(ino))
.map(|entry| entry.access)
}
pub(crate) fn take_access_changes(&mut self) -> Vec<u64> {
self.access_changes.drain().collect()
}
/// Whether `uid` lives on a volume some mount is rooted in.
///
/// Used to decide how a node with no resolvable parent is classified:
/// our own volume fails open (it is owned content), a foreign volume
/// fails closed (it can only be there because of a share).
pub(crate) fn is_own_volume(&self, uid: &NodeUid) -> bool {
self.roots.iter().any(|ino| {
self.entries
.get(ino)
.is_some_and(|root| root.uid.volume_id == uid.volume_id)
})
}
fn stored_share_access(&self, uid: &NodeUid) -> Option<Access> {
self.share_access.get(uid).copied()
}
pub(crate) fn access_for_node(&mut self, parent: u64, node: &Node) -> Access {
let parent_access = self
.entries
.get(&parent)
.map_or(Access::Owner, |entry| entry.access);
let stored = self.stored_share_access(&node.uid);
// A persisted row is the offline authority. A live membership on a
// node directly below an owned root identifies a newly discovered
// share root and refreshes that row. Descendants inherit directly.
let is_new_share_root = node.membership.is_some() && parent_access == Access::Owner;
if stored.is_some() || is_new_share_root {
let access = node.membership.as_ref().map_or_else(
|| stored.unwrap_or(Access::Viewer),
|membership| access_for(membership.role_exact(), parent_access, true),
);
self.outbox
.push(DbWrite::SetShareAccess(node.uid.clone(), access));
self.share_access.insert(node.uid.clone(), access);
return access;
}
parent_access
}
/// Record a role observed by an authority-bearing shared-root surface.
///
/// Ordinary node enumeration, especially `enumerate_nodes_light` and old
/// cached JSON, may omit membership and must continue to trust persisted
/// `share_access`. P3 can call this method only when its share-root endpoint
/// explicitly observed a role; explicit absence or an unknown wire role is
/// represented by `None` and fails closed to Viewer.
#[cfg(test)]
pub(crate) fn record_observed_share_root_role(
&mut self,
uid: &NodeUid,
role: Option<MemberRole>,
) -> Vec<u64> {
let (root, parent_access) = match self.by_uid.get(uid).copied() {
Some(root) => {
let parent_access = self
.entries
.get(&root)
.and_then(|entry| self.entries.get(&entry.parent))
.map_or(Access::Owner, |entry| entry.access);
(Some(root), parent_access)
}
None => (None, Access::Unknown),
};
let access = access_for(role, parent_access, true);
self.outbox
.push(DbWrite::SetShareAccess(uid.clone(), access));
self.share_access.insert(uid.clone(), access);
let Some(root) = root else {
return Vec::new();
};
let mut changed = Vec::new();
if let Some(entry) = self.entries.get_mut(&root)
&& entry.access != access
{
entry.access = access;
changed.push(root);
}
changed.extend(self.recompute_descendant_access(root));
self.access_changes.extend(changed.iter().copied());
changed
}
/// Install access already committed atomically with the root node. Unlike
/// the observation hook this performs no database write and cannot swallow
/// a publication failure.
pub(crate) fn intern_published_share_root(
&mut self,
parent: u64,
node: Node,
access: Access,
) -> u64 {
self.share_access.insert(node.uid.clone(), access);
self.intern_mem_with_access(parent, node, access)
}
/// Withdraw a revoked shared root from the namespace without deleting its
/// resident inode or persisted authority. Open handles remain usable for
/// reads, while removing the root's cached listing guarantees a later
/// reappearance re-enumerates descendants instead of exposing stale rows.
pub(crate) fn hide_shared_root(&mut self, uid: &NodeUid) -> Vec<u64> {
let changed = self.downgrade_shared_subtree(uid);
self.hide_subtree_mem(uid);
changed
}
/// Withdraw a foreign child removed by its authoritative folder listing.
/// Authority is retained because this is content deletion, not access loss.
pub(crate) fn hide_foreign_subtree(&mut self, uid: &NodeUid) {
self.hide_subtree_mem(uid);
}
fn hide_subtree_mem(&mut self, uid: &NodeUid) {
let Some(&ino) = self.by_uid.get(uid) else {
return;
};
let Some(entry) = self.entries.get_mut(&ino) else {
return;
};
entry.node.trashed = true;
let parent = entry.parent;
if let Some(children) = self.children.get_mut(&parent) {
children.retain(|child| *child != ino);
}
self.children.remove(&ino);
}
fn resident_children(&self) -> HashMap<u64, Vec<u64>> {
let mut children: HashMap<u64, Vec<u64>> = HashMap::new();
for (&ino, entry) in &self.entries {
if entry.parent != ino {
children.entry(entry.parent).or_default().push(ino);
}
}
children
}
/// Recompute all resident descendants after a root's effective access
/// changes. A nested persisted share root remains its own authority.
fn recompute_descendant_access(&mut self, root: u64) -> Vec<u64> {
let mut changed = Vec::new();
let mut queue = VecDeque::from([root]);
let mut seen = HashSet::new();
let children = self.resident_children();
while let Some(parent) = queue.pop_front() {
if !seen.insert(parent) {
continue;
}
let parent_access = match self.entries.get(&parent) {
Some(entry) => entry.access,
None => continue,
};
for &child in children.get(&parent).into_iter().flatten() {
let access = self
.entries
.get(&child)
.and_then(|entry| self.share_access.get(&entry.uid).copied())
.unwrap_or(parent_access);
if let Some(entry) = self.entries.get_mut(&child)
&& entry.access != access
{
entry.access = access;
changed.push(child);
}
queue.push_back(child);
}
}
changed
}
/// Force a known shared root and every resident descendant read-only.
///
/// This intentionally ignores nested authorities: losing access to an
/// outer tree must not leave a cached inner entry writable.
pub(crate) fn downgrade_shared_subtree(&mut self, uid: &NodeUid) -> Vec<u64> {
self.share_access.insert(uid.clone(), Access::Viewer);
let Some(&root) = self.by_uid.get(uid) else {
return Vec::new();
};
let mut changed = Vec::new();
let mut queue = VecDeque::from([root]);
let mut seen = HashSet::new();
let children = self.resident_children();
while let Some(ino) = queue.pop_front() {
if !seen.insert(ino) {
continue;
}
if let Some(entry) = self.entries.get_mut(&ino)
&& entry.access != Access::Viewer
{
entry.access = Access::Viewer;
changed.push(ino);
}
queue.extend(children.get(&ino).into_iter().flatten().copied());
}
changed
}
/// Downgrade resident shared subtrees identified by persisted authority,
/// live membership, or already-inherited non-owner access. Owned and device
/// entries remain `Owner`.
pub(crate) fn downgrade_known_shared_access(&mut self) -> Vec<u64> {
for access in self.share_access.values_mut() {
*access = Access::Viewer;
}
let roots: Vec<u64> = self
.entries
.iter()
.filter(|&(&ino, entry)| {
// The filesystem root (parent == self) is always owned; never
// downgrade it even if it carries membership from the owner
// sharing this folder with someone else.
if entry.parent == ino {
return false;
}
entry.access != Access::Owner
|| entry.node.membership.is_some()
|| self.share_access.contains_key(&entry.uid)
})
.map(|(&ino, _)| ino)
.collect();
let children = self.resident_children();
let mut queue = VecDeque::from(roots);
let mut seen = HashSet::new();
let mut changed = Vec::new();
while let Some(ino) = queue.pop_front() {
if !seen.insert(ino) {
continue;
}
if let Some(entry) = self.entries.get_mut(&ino)
&& entry.access != Access::Viewer
{
entry.access = Access::Viewer;
changed.push(ino);
}
queue.extend(children.get(&ino).into_iter().flatten().copied());
}
changed
}
/// Rebuild effective access after cold hydration. The memo makes each
/// parent edge resolve once, then every child is a direct inheritance.
pub(crate) fn hydrate_access(&mut self) {
fn resolve(
state: &State,
ino: u64,
memo: &mut HashMap<u64, Access>,
visiting: &mut HashSet<u64>,
) -> Access {
if let Some(access) = memo.get(&ino) {
return *access;
}
let Some(entry) = state.entries.get(&ino) else {
return Access::Unknown;
};
if !visiting.insert(ino) {
return Access::Unknown;
}
let stored = state.share_access.get(&entry.uid).copied();
// The root of this mount is always owned, regardless of any
// stale share_access row that a global downgrade may have left.
let access = if entry.parent == ino {
Access::Owner
} else if let Some(stored) = stored {
stored
} else if !state.entries.contains_key(&entry.parent) {
// The parent is not resident, so there is nothing to inherit
// from. This is the normal shape of a device-folder root: its
// parent is the device root, which is never persisted as a
// node, so every on-demand folder hydrates parentless here.
// Answer the way the persisted authority (`effective_node_access`)
// does — a recorded share row above it if we have one, otherwise
// fail open on our own volume and closed on a foreign one.
// Failing closed on our own volume denies every write in every
// secondary mount, because `require_uid_writable` intersects
// this state with the mount that actually owns the node.
entry
.node
.parent_uid
.as_ref()
.and_then(|parent_uid| state.share_access.get(parent_uid).copied())
.unwrap_or_else(|| {
if state.is_own_volume(&entry.uid) {
Access::Owner
} else {
Access::Unknown
}
})
} else {
let parent_access = resolve(state, entry.parent, memo, visiting);
if entry.node.membership.is_none() || parent_access != Access::Owner {
parent_access
} else {
access_for(
entry
.node
.membership
.as_ref()
.and_then(|membership| membership.role_exact()),
parent_access,
true,
)
}
};
visiting.remove(&ino);
memo.insert(ino, access);
access
}
let mut memo = HashMap::new();
let mut visiting = HashSet::new();
for ino in self.entries.keys().copied().collect::<Vec<_>>() {
resolve(self, ino, &mut memo, &mut visiting);
}
for (ino, access) in memo {
if let Some(entry) = self.entries.get_mut(&ino) {
entry.access = access;
}
}
let discovered: Vec<(NodeUid, Access)> = self
.entries
.iter()
.filter_map(|(&ino, entry)| {
// The filesystem root is owned, not a discovered share root.
if entry.parent == ino {
return None;
}
let parent_access = self
.entries
.get(&entry.parent)
.map_or(Access::Unknown, |parent| parent.access);
(entry.node.membership.is_some()
&& parent_access == Access::Owner
&& !self.share_access.contains_key(&entry.uid))
.then(|| {
(
entry.uid.clone(),
access_for(
entry
.node
.membership
.as_ref()
.and_then(|membership| membership.role_exact()),
parent_access,
true,
),
)
})
})
.collect();
for (uid, access) in discovered {
self.outbox
.push(DbWrite::SetShareAccess(uid.clone(), access));
self.share_access.insert(uid, access);
}
}
/// Write `node` through as it is, in order with the tree's other changes.
pub(crate) fn write_through(&mut self, node: Node) {
self.outbox.push(DbWrite::Upsert(vec![node]));
}
pub(crate) fn intern(&mut self, parent: u64, mut node: Node) -> u64 {
self.keep_revision_mtime(&mut node);
self.outbox.push(DbWrite::Upsert(vec![node.clone()]));
self.intern_mem(parent, node)
}
/// Keep what is already known of a file whose content has not changed.
///
/// Drive stamps a link's `ModifyTime` on a rename or a move as well, while
/// POSIX leaves a file's mtime alone there. The content cache is keyed by
/// it, too: taking the new stamp made the cached bytes unreachable, so the
/// next partial write could not fill its gaps (docs/BUGS.md B123). The
/// active revision id says whether the content is still the same, and so
/// whether the size and digest a listing left out are still the ones known.
fn keep_revision_mtime(&self, node: &mut Node) {
let Some(entry) = self
.by_uid
.get(&node.uid)
.and_then(|ino| self.entries.get(ino))
else {
return;
};
if let (
NodeKind::File {
active_revision_id: Some(now),
claimed_size,
content_sha1,
..
},
NodeKind::File {
active_revision_id: Some(was),
claimed_size: size_was,
content_sha1: sha1_was,
..
},
) = (&mut node.kind, &entry.node.kind)
&& now == was
{
node.modification_time = entry.node.modification_time;
if claimed_size.is_none() {
*claimed_size = *size_was;
}
if content_sha1.is_none() {
content_sha1.clone_from(sha1_was);
}
}
}
/// Allocate (or reuse) a stable inode for a node, updating the hot-cache maps
/// only. Every caller owes the DB a write-through — see the callers below;
/// the split exists so a batch can pay for one transaction instead of `n`.
pub(crate) fn intern_mem(&mut self, parent: u64, node: Node) -> u64 {
let access = self.access_for_node(parent, &node);
self.intern_mem_with_access(parent, node, access)
}
fn intern_mem_with_access(&mut self, parent: u64, node: Node, access: Access) -> u64 {
self.intern_mem_at(parent, node, access, None)
}
/// [`State::intern_mem_with_access`], with the inode a new entry takes when
/// the caller already has it ([`State::new_inos`]).
fn intern_mem_at(
&mut self,
parent: u64,
mut node: Node,
access: Access,
ino: Option<u64>,
) -> u64 {
if let Some(&ino) = self.by_uid.get(&node.uid) {
let changed = self
.entries
.get(&ino)
.is_some_and(|entry| entry.access != access);
self.keep_open_write_size(ino, &mut node);
if let Some(e) = self.entries.get_mut(&ino) {
e.node = node;
e.parent = parent;
e.access = access;
}
if changed {
self.access_changes.insert(ino);
let descendants = self.recompute_descendant_access(ino);
self.access_changes.extend(descendants);
}
return ino;
}
let ino = match ino {
Some(ino) => ino,
None => self
.new_inos(&[&node])
.remove(&node.uid)
.unwrap_or_default(),
};
self.by_uid.insert(node.uid.clone(), ino);
self.entries.insert(
ino,
Entry {
uid: node.uid.clone(),
parent,
node,
access,
lookup_count: 1,
open_count: 0,
unlinked: false,
},
);
ino
}
/// The inodes for nodes that have no entry yet, from their local ids, in
/// one transaction. Should the database fail, they come from
/// [`State::next_ino`] instead, so the mount keeps working, with inodes
/// that last only this run. So do the inodes of virtual folders, which are
/// never stored.
fn new_inos(&mut self, nodes: &[&Node]) -> HashMap<NodeUid, u64> {
let (virtual_nodes, stored): (Vec<&Node>, Vec<&Node>) = nodes
.iter()
.partition(|node| crate::r#virtual::is_virtual_uid(&node.uid));
let mut inos = match self.db.lids_for(&stored) {
Ok(lids) => stored
.iter()
.zip(lids)
.map(|(node, lid)| (node.uid.clone(), lid_ino(lid)))
.collect(),
Err(error) => {
warn!(%error, "no local ids for new inodes; numbering them for this run");
self.run_inos(&stored)
}
};
inos.extend(self.run_inos(&virtual_nodes));
inos
}
fn run_inos(&mut self, nodes: &[&Node]) -> HashMap<NodeUid, u64> {
nodes
.iter()
.map(|node| {
let ino = self.next_ino;
self.next_ino += 1;
(node.uid.clone(), ino)
})
.collect()
}
/// Decrement lookup count for an inode and prune if lookup_count == 0 && open_count == 0 && unlinked.
pub(crate) fn forget_lookup(&mut self, ino: u64, nlookup: u64) -> Option<(u64, String)> {
if let Some(entry) = self.entries.get_mut(&ino) {
entry.lookup_count = entry.lookup_count.saturating_sub(nlookup);
if entry.lookup_count == 0 && entry.open_count == 0 && entry.unlinked {
let uid = entry.uid.clone();
return self.forget(&uid);
}
}
None
}
/// Allocate (or reuse) a stable inode for a node that came *from* the
/// database, which is why nothing is written back.
/// Carry the size and mtime of a file open for writing over onto `node`,
/// which is about to replace its entry.
///
/// The open handle is ahead of anything Drive can report until release
/// queues it. Taking the remote's size shrank a SQLite database to its empty
/// create while SQLite held it open, and the kernel then capped every read
/// at that size (docs/BUGS.md B109).
pub(crate) fn keep_open_write_size(&self, ino: u64, node: &mut Node) {
let (Some(aw), Some(entry)) = (self.active_writes.get(&ino), self.entries.get(&ino)) else {
return;
};
if let NodeKind::File { claimed_size, .. } = &mut node.kind {
*claimed_size = Some(aw.len as i64);
node.modification_time = entry.node.modification_time;
}
}
pub(crate) fn intern_from_db(&mut self, parent: u64, node: Node) -> u64 {
self.intern_mem(parent, node)
}
/// Allocate (or reuse) stable inodes for a whole listing, writing every node
/// through in a single transaction.
///
/// This is what keeps `ls` on a large folder quick: one commit for the
/// listing, rather than one autocommit — and one fsync — per child.
#[cfg(test)]
pub(crate) fn intern_batch(&mut self, parent: u64, nodes: Vec<Node>) -> Vec<u64> {
self.intern_batch_with_inos(parent, nodes, HashMap::new())
}
/// [`State::intern_batch`], with inodes for new entries that the caller
/// allocated before it took the lock, from [`inos_for`]. Allocating them
/// is a write transaction, and under the lock every inline handler of
/// every mount waited for it (`docs/BUGS.md` B221). A node missing from
/// `inos` still gets one here.
pub(crate) fn intern_batch_with_inos(
&mut self,
parent: u64,
mut nodes: Vec<Node>,
mut inos: HashMap<NodeUid, u64>,
) -> Vec<u64> {
for node in &mut nodes {
self.keep_revision_mtime(node);
}
self.outbox.push(DbWrite::Upsert(nodes.clone()));
let fresh: Vec<&Node> = nodes
.iter()
.filter(|node| !self.by_uid.contains_key(&node.uid) && !inos.contains_key(&node.uid))
.collect();
if !fresh.is_empty() {
let allocated = self.new_inos(&fresh);
inos.extend(allocated);
}
nodes
.into_iter()
.map(|node| {
let access = self.access_for_node(parent, &node);
let ino = inos.remove(&node.uid);
self.intern_mem_at(parent, node, access, ino)
})
.collect()
}
/// Record `uid`'s listing as complete once the lock is released, after the
/// rows of its children: written the other way round, a crash between the
/// two left a folder marked complete with no children to show.
pub(crate) fn record_listed(&mut self, uid: NodeUid) {
self.outbox.push(DbWrite::SetListed(uid, true));
}
/// Check if a directory inode has any child nodes in memory or in the database.
pub(crate) fn has_children(&self, parent: u64) -> bool {
// A resident listing is authoritative even when empty. Falling through
// from `Some([])` to SQLite lets an obsolete child row contradict the
// listing `ensure_children` just established, so readdir says empty but
// rmdir returns ENOTEMPTY forever.
if let Some(kids) = self.children.get(&parent) {
return !kids.is_empty();
}
if let Some(entry) = self.entries.get(&parent)
&& let Ok(has_kids) = self.db.has_children(&entry.uid)
{
return has_kids;
}
false
}
/// Check if `ancestor_ino` is `target_ino` or an ancestor of `target_ino` (for rename cycle prevention).
pub(crate) fn is_ancestor_of(&self, ancestor_ino: u64, mut target_ino: u64) -> bool {
if ancestor_ino == target_ino {
return true;
}
let mut visited = std::collections::HashSet::new();
visited.insert(target_ino);
while let Some(entry) = self.entries.get(&target_ino) {
let parent = entry.parent;
if parent == ancestor_ino {
return true;
}
if parent == 0 || !visited.insert(parent) {
break;
}
target_ino = parent;
}
false
}
/// Forget a node or, if open handles exist (open_count > 0), mark it unlinked
/// and remove it from parent children so lookups fail while open reads succeed.
pub(crate) fn forget_or_unlink(&mut self, uid: &NodeUid) -> Option<(u64, String)> {
if let Some(&ino) = self.by_uid.get(uid)
&& let Some(entry) = self.entries.get_mut(&ino)
&& entry.open_count > 0
{
entry.unlinked = true;
if let Some(kids) = self.children.get_mut(&entry.parent) {
kids.retain(|&k| k != ino);
}
return Some((entry.parent, entry.node.name.clone()));
}
self.forget(uid)
}
/// Remove a dentry while retaining both open inode state and the persisted
/// node row. Queued trash needs the row as drain-time authority; if the node
/// is open, POSIX also requires its inode to survive until the final close.
pub(crate) fn unlink_mem(&mut self, uid: &NodeUid) -> Option<(u64, String)> {
if let Some(&ino) = self.by_uid.get(uid)
&& let Some(entry) = self.entries.get_mut(&ino)
&& entry.open_count > 0
{
entry.unlinked = true;
if let Some(kids) = self.children.get_mut(&entry.parent) {
kids.retain(|&kid| kid != ino);
}
return Some((entry.parent, entry.node.name.clone()));
}
self.forget_mem(uid)
}
/// Forget a node entirely: drop its inode, its uid mapping, its own cached
/// listing, its slot in its parent's listing, and its DB row. Returns
/// `(parent_ino, name)` when the node was known, so the caller can notify
/// the kernel.
pub(crate) fn forget(&mut self, uid: &NodeUid) -> Option<(u64, String)> {
self.outbox.push(DbWrite::Delete(uid.clone()));
self.forget_mem(uid)
}
/// Forget a resident node while retaining its persisted row.
///
/// A queued trash uses the row as its drain-time access authority and only
/// deletes it after the remote mutation lands.
pub(crate) fn forget_mem(&mut self, uid: &NodeUid) -> Option<(u64, String)> {
let ino = self.by_uid.remove(uid)?;
let entry = self.entries.remove(&ino)?;
self.children.remove(&ino);
if let Some(kids) = self.children.get_mut(&entry.parent) {
kids.retain(|&k| k != ino);
}
Some((entry.parent, entry.node.name))
}
/// Move a node to a new parent and/or name within the tree, writing it
/// through like any other mutation.
///
/// The online rename instead forgets the node and lets the destination
/// re-enumerate, which is the cheaper way to stay honest about what the
/// server did. A queued rename cannot: re-enumerating needs the network, and
/// the server has not been told yet in any case — so this *is* the tree's
/// new truth until the op drains.
pub(crate) fn rename_in_place(
&mut self,
ino: u64,
new_parent: u64,
new_parent_uid: &NodeUid,
name: &str,
) {
let parent_access = self
.entries
.get(&new_parent)
.map_or(Access::Owner, |entry| entry.access);
let access = self
.entries
.get(&ino)
.and_then(|entry| self.share_access.get(&entry.uid).copied())
.unwrap_or(parent_access);
let Some(entry) = self.entries.get_mut(&ino) else {
return;
};
let old_parent = entry.parent;
entry.parent = new_parent;
entry.access = access;
entry.node.name = name.to_string();
entry.node.parent_uid = Some(new_parent_uid.clone());
let node = entry.node.clone();
if old_parent != new_parent {
if let Some(kids) = self.children.get_mut(&old_parent) {
kids.retain(|&k| k != ino);
}
// Only if the destination is listed: pushing into a listing that was
// never enumerated would invent a one-child folder.
if let Some(kids) = self.children.get_mut(&new_parent)
&& !kids.contains(&ino)
{
kids.push(ino);
}
}
self.outbox.push(DbWrite::Upsert(vec![node]));
self.recompute_descendant_access(ino);
}
/// Drop a directory's cached child listing and mark it unlisted in the DB,
/// so the next access re-enumerates instead of trusting a stale listing.
///
/// The DB flag is cleared whether or not the listing was resident. A folder
/// trimmed from the hot cache but still `listed` in the DB is exactly the
/// case that needs invalidating — returning early there would leave
/// `ensure_children` free to rebuild the stale listing from disk.
pub(crate) fn invalidate_listing(&mut self, ino: u64) {
self.children.remove(&ino);
if let Some(e) = self.entries.get(&ino) {
let uid = e.uid.clone();
self.outbox.push(DbWrite::SetListed(uid, false));
}
}
/// Settle the local state after a node has been moved (and possibly renamed)
/// on the remote: rewrite it in place, and drop **both** directories'
/// listings so each re-enumerates.
///
/// Both, because each is stale for its own reason. The destination has
/// gained a child it does not know about; the source has lost one. The
/// source is the subtler half, and was audit A5: pruning the moved node from
/// the source's in-memory children looks sufficient while that entry stays
/// resident, but the source's DB row is left `listed = 1`. Once it is
/// evicted from the hot cache, or the daemon restarts, `ensure_children`
/// rebuilds the listing from the DB and declares it complete, so anything
/// else that changed remotely under the source since is never seen.
///
/// What this must *not* do is `forget` the node. Forgetting drops its
/// `by_uid` mapping, so the re-enumeration hands it a fresh inode — while
/// the kernel has already carried the renamed dentry over to the *old* one.
/// Every lookup through that dentry then resolves to an inode `entries` no
/// longer holds and fails `ENOENT`, so the renamed directory reads as
/// missing (`ls` on it errors while `ls` of its parent lists it) until the
/// entry TTL expires. Keeping the inode keeps the kernel's dentry valid.
///
/// A pure rename (`from == to`) invalidates that one directory once.
pub(crate) fn relocate(
&mut self,
ino: u64,
from_parent: u64,
to_parent: u64,
to_parent_uid: &NodeUid,
name: &str,
) {
self.rename_in_place(ino, to_parent, to_parent_uid, name);
self.invalidate_listing(to_parent);
if from_parent != to_parent {
self.invalidate_listing(from_parent);
}
}
/// Update a file entry's recorded plaintext size so `getattr` reflects an
/// in-progress write before the new revision is sealed.
pub(crate) fn set_size(&mut self, ino: u64, size: u64) {
if let Some(e) = self.entries.get_mut(&ino)
&& let NodeKind::File { claimed_size, .. } = &mut e.node.kind
{
*claimed_size = Some(size as i64);
}
}
/// Update a file entry's modification time (epoch seconds).
pub(crate) fn touch_mtime(&mut self, ino: u64, secs: i64) {
if let Some(e) = self.entries.get_mut(&ino) {
e.node.modification_time = secs;
}
}
/// Record the size and mtime of a write that has been accepted but not yet
/// uploaded, persisting them like any other node mutation.
///
/// The write-through is what makes the new size outlive the process: until
/// the op drains, the remote still holds the old revision (or, for a node
/// created offline, nothing at all), so this row is the only record that the
/// file is as long as the caller was told it is. Without it a restart serves
/// the stale size and the file reads as truncated — or empty — while its
/// bytes sit safely in staging.
/// Returns the node to persist rather than queueing it in the outbox, and is
/// the one mutation here whose write-through the caller must do itself:
/// failure has to be reported, not logged. Dropping the row silently means
/// the daemon acknowledged a write and then serves the old size for it, so
/// the caller has to be able to turn it into a failed `release`.
pub(crate) fn record_pending_write(&mut self, ino: u64, size: u64, mtime: i64) -> Option<Node> {
self.set_size(ino, size);
self.touch_mtime(ino, mtime);
self.entries.get(&ino).map(|e| e.node.clone())
}
}
#[cfg(test)]
mod tests {
use super::*;
use proton_drive_rs::ShareMembership;
use proton_drive_rs::proton_sdk::ids::{LinkId, ShareId, ShareMembershipId, VolumeId};
#[test]
fn an_inode_gives_back_the_lid_it_was_made_from() {
assert_eq!(ino_lid(lid_ino(7)), Some(7));
assert_eq!(ino_lid(crate::ROOT_INO), None);
assert_eq!(ino_lid(FALLBACK_INO + 3), None);
}
fn uid(link: &str) -> NodeUid {
NodeUid::new(VolumeId::from("vol"), LinkId::from(link))
}
fn node(link: &str, parent: &str, name: &str, is_dir: bool) -> Node {
Node {
uid: uid(link),
parent_uid: Some(uid(parent)),
kind: if is_dir {
NodeKind::Folder
} else {
NodeKind::File {
media_type: "text/plain".into(),
total_size_on_storage: 0,
active_revision_state: None,
active_revision_id: None,
content_sha1: None,
claimed_size: Some(0),
claimed_modification_time: None,
}
},
name: name.into(),
creation_time: 100,
modification_time: 100,
trashed: false,
is_shared: false,
is_shared_publicly: false,
signature_email: None,
membership: None,
photo: None,
album: None,
verification: Default::default(),
direct_role: None,
share_id: None,
}
}
fn membership(permissions: i32) -> ShareMembership {
ShareMembership {
share_id: ShareId::from("share"),
membership_id: ShareMembershipId::from("membership"),
permissions,
invite_time: None,
inviter_email: None,
inviter_verification: None,
}
}
/// A unique temp directory removed on drop; avoids a dev-dependency.
struct TempDir(PathBuf);
impl TempDir {
fn new() -> Self {
use std::sync::atomic::{AtomicU64, Ordering};
static N: AtomicU64 = AtomicU64::new(0);
let p = std::env::temp_dir().join(format!(
"pdfs-state-{}-{}",
std::process::id(),
N.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir_all(&p).unwrap();
TempDir(p)
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// `Db::open_in_memory` is `#[cfg(test)]` inside `pdfs-core`, so it does not
/// exist for this crate's tests — a temp file is the equivalent here. The
/// directory outlives the state because the state holds the open database.
fn state() -> (State, TempDir) {
let dir = TempDir::new();
let db = Db::open(&dir.0.join("cache.db")).unwrap();
let share_access = db.all_share_access().unwrap();
let st = State::new(Arc::new(db), share_access);
(st, dir)
}
/// Two folders, each holding one file, both listed — the state a move
/// starts from. Returns `(state, src_ino, dst_ino)`.
fn two_folders() -> (State, TempDir, u64, u64) {
let (mut st, dir) = state();
let src = st.intern(0, node("src", "root", "src", true));
let dst = st.intern(0, node("dst", "root", "dst", true));
let f = st.intern(src, node("f", "src", "f.txt", false));
st.children.insert(src, vec![f]);
st.children.insert(dst, vec![]);
st.flushed_db().set_listed(&uid("src"), true).unwrap();
st.flushed_db().set_listed(&uid("dst"), true).unwrap();
(st, dir, src, dst)
}
/// A node's inode comes from its row's local id, so it is the same in
/// every mount, after the hot cache drops it, and after a restart.
#[test]
fn a_node_keeps_its_inode_across_mounts_evictions_and_restarts() {
let (mut st, _dir) = state();
let inos = st.intern_batch(
0,
vec![
node("a", "root", "a.txt", false),
node("b", "root", "b.txt", false),
],
);
st.flush_outbox();
let db = st.db.clone();
assert_ne!(inos[0], inos[1]);
assert!(!inos.contains(&crate::ROOT_INO));
let mut other = State::new(db.clone(), HashMap::new());
assert_eq!(other.intern(0, node("b", "root", "b.txt", false)), inos[1]);
st.forget_mem(&uid("a"));
assert_eq!(st.intern(0, node("a", "root", "a.txt", false)), inos[0]);
let mut restarted = State::new(db, HashMap::new());
assert_eq!(
restarted.intern_batch(0, vec![node("a", "root", "a.txt", false)]),
[inos[0]]
);
}
/// A listing's inodes are allocated before the lock is taken, and are the
/// ones a node would get under it (`docs/BUGS.md` B221).
#[test]
fn inodes_allocated_before_the_lock_are_the_ones_the_node_keeps() {
let (mut st, _dir) = state();
let db = st.db.clone();
let listing = vec![
node("a", "root", "a.txt", false),
node("b", "root", "b.txt", false),
];
let early = inos_for(&db, &listing);
assert_eq!(early.len(), 2);
let inos = st.intern_batch_with_inos(0, listing, early.clone());
assert_eq!(inos, [early[&uid("a")], early[&uid("b")]]);
st.flush_outbox();
let mut restarted = State::new(db, HashMap::new());
assert_eq!(
restarted.intern(0, node("b", "root", "b.txt", false)),
early[&uid("b")]
);
}
/// The listing is recorded complete with its children's rows, when the
/// lock goes, never ahead of them.
#[test]
fn a_listing_is_recorded_complete_after_its_children() {
let (mut st, _dir) = state();
let root = st.intern(0, node("root", "none", "root", true));
st.flush_outbox();
st.intern_batch(root, vec![node("a", "root", "a.txt", false)]);
st.record_listed(uid("root"));
assert!(st.db.children_if_listed(&uid("root")).unwrap().is_none());
st.flush_outbox();
let kids = st.db.children_if_listed(&uid("root")).unwrap().unwrap();
assert_eq!(kids.len(), 1);
}
/// Audit A5. Moving a file out of a folder leaves that folder's DB row
/// claiming a complete listing, so once the hot cache drops it, remote
/// changes under it stop being seen. `relocate` has to clear both sides.
#[test]
fn relocate_invalidates_the_source_as_well_as_the_destination() {
let (mut st, _dir, src, dst) = two_folders();
let f = st.by_uid[&uid("f")];
st.relocate(f, src, dst, &uid("dst"), "f.txt");
assert!(
st.flushed_db()
.children_if_listed(&uid("dst"))
.unwrap()
.is_none(),
"destination re-enumerates: it gained a child whose row was just deleted"
);
assert!(
st.flushed_db()
.children_if_listed(&uid("src"))
.unwrap()
.is_none(),
"source re-enumerates: its listing predates the move, and a stale \
`listed` flag would hide every later remote change under it"
);
assert!(!st.children.contains_key(&src));
assert!(!st.children.contains_key(&dst));
assert_eq!(
st.by_uid.get(&uid("f")),
Some(&f),
"the inode survives the move: the kernel has already pointed the \
renamed dentry at it, so re-interning under a fresh one would make \
every lookup through that dentry ENOENT"
);
assert_eq!(st.entries[&f].parent, dst);
}
/// A pure rename never leaves the directory, so there is one listing to
/// drop, not two — and dropping it must not depend on the parents differing.
#[test]
fn relocate_within_one_directory_still_invalidates_it() {
let (mut st, _dir, src, _dst) = two_folders();
let f = st.by_uid[&uid("f")];
st.relocate(f, src, src, &uid("src"), "renamed.txt");
assert!(
st.flushed_db()
.children_if_listed(&uid("src"))
.unwrap()
.is_none()
);
assert!(!st.children.contains_key(&src));
assert_eq!(st.by_uid.get(&uid("f")), Some(&f), "the inode is stable");
assert_eq!(st.entries[&f].node.name, "renamed.txt");
}
/// DB1: a mutation's write-through must not happen with the inode lock
/// held. Interning a listing commits a transaction over every child, and
/// every other FUSE callback used to wait behind that for no reason — the
/// maps it needs were updated before the first row was written.
#[test]
fn a_mutation_writes_through_only_after_the_lock_is_released() {
let (st, _dir) = state();
let db = st.db.clone();
let root = uid("root").to_string();
let state = parking_lot::Mutex::new(st);
{
let mut guard = StateGuard::new(state.lock(), &db);
guard.intern(0, node("root", "none", "My Files", true));
assert!(
db.node_by_uid(&root).unwrap().is_none(),
"the row must not be written while the lock is held"
);
}
assert!(
db.node_by_uid(&root).unwrap().is_some(),
"…and must be written by the time the guard is gone"
);
}
/// The failure mode the fix exists for, stated directly: forgetting the node
/// prunes it from the resident listing but leaves the DB claiming the source
/// is fully enumerated. This pins *why* `relocate` cannot just call `forget`.
#[test]
fn forget_alone_leaves_the_source_claiming_a_complete_listing() {
let (mut st, _dir, _src, _dst) = two_folders();
st.forget(&uid("f"));
let listed = st.flushed_db().children_if_listed(&uid("src")).unwrap();
assert!(
listed.is_some(),
"forget does not clear the flag — which is exactly why relocate must"
);
assert!(
listed.unwrap().is_empty(),
"and the listing it would serve is the moved file's absence, \
with no way to notice anything else changed"
);
}
#[test]
fn test_is_ancestor_of_hierarchy() {
let (mut st, _dir) = state();
let root = st.intern(0, node("root_id", "none", "root", true));
let p1 = st.intern(root, node("p1_id", "root_id", "p1", true));
let p2 = st.intern(p1, node("p2_id", "p1_id", "p2", true));
let child = st.intern(p2, node("child_id", "p2_id", "child", true));
assert!(st.is_ancestor_of(root, root), "self is ancestor");
assert!(st.is_ancestor_of(root, p1), "root is ancestor of p1");
assert!(
st.is_ancestor_of(root, child),
"root is ancestor of deep child"
);
assert!(st.is_ancestor_of(p1, child), "p1 is ancestor of deep child");
assert!(
!st.is_ancestor_of(child, root),
"child is not ancestor of root"
);
assert!(!st.is_ancestor_of(child, p1), "child is not ancestor of p1");
}
#[test]
fn test_state_has_children_mem_and_db() {
let (mut st, _dir) = state();
let folder = st.intern_mem(0, node("dir_uid", "none", "dir", true));
assert!(!st.has_children(folder), "initially empty");
// Memory-only children (using intern_mem so DB is not populated yet)
let f1 = st.intern_mem(folder, node("file1_uid", "dir_uid", "file1.txt", false));
st.children.insert(folder, vec![f1]);
assert!(st.has_children(folder), "has child in memory");
st.children.insert(folder, vec![]);
assert!(!st.has_children(folder), "cleared memory children");
// DB children
st.flushed_db()
.upsert_node(&node("file2_uid", "dir_uid", "file2.txt", false))
.unwrap();
assert!(
!st.has_children(folder),
"a resident empty listing overrides stale DB children"
);
st.children.remove(&folder);
assert!(st.has_children(folder), "has child in db");
}
#[test]
fn share_root_access_is_persisted_and_children_inherit_in_constant_time() {
let (mut st, _dir) = state();
let owned_root = st.intern(0, node("root", "none", "My Files", true));
let mut shared = node("shared", "root", "Shared", true);
shared.membership = Some(membership(6));
let shared_ino = st.intern(owned_root, shared);
let child = st.intern(
shared_ino,
node("shared-child", "shared", "child.txt", false),
);
assert_eq!(st.entries[&shared_ino].access, Access::Editor);
assert_eq!(st.entries[&child].access, Access::Editor);
assert_eq!(
st.flushed_db().share_access(&uid("shared")).unwrap(),
Some(Access::Editor)
);
}
#[test]
fn omitted_accepted_root_is_visible_but_resident_access_fails_closed() {
let (mut st, _dir) = state();
let owned_root = st.intern(0, node("root", "none", "My Files", true));
let virtual_uid = NodeUid::new(VolumeId::from("virtual"), LinkId::from("sharedwithme"));
let mut virtual_root = node("placeholder", "root", "Shared with me", true);
virtual_root.uid = virtual_uid.clone();
st.flushed_db().upsert_node(&virtual_root).unwrap();
st.flushed_db()
.set_share_access(&virtual_uid, Access::Viewer)
.unwrap();
let virtual_ino = st.intern_published_share_root(owned_root, virtual_root, Access::Viewer);
let mut shared = node("shared", "placeholder", "Retained", true);
shared.parent_uid = Some(virtual_uid.clone());
shared.membership = Some(membership(6));
st.flushed_db().upsert_node(&shared).unwrap();
st.flushed_db()
.set_share_access(&shared.uid, Access::Editor)
.unwrap();
let shared_ino =
st.intern_published_share_root(virtual_ino, shared.clone(), Access::Editor);
let child = st.intern(
shared_ino,
node("shared-child", "shared", "pending.txt", false),
);
st.flushed_db()
.enqueue_op(&pdfs_core::db::PendingOp {
id: 0,
kind: pdfs_core::db::OP_RENAME.to_string(),
uid: uid("shared-child").to_string(),
parent_uid: Some(shared.uid.to_string()),
name: Some("pending-renamed.txt".to_string()),
blob_path: None,
meta_json: Some("{}".to_string()),
created_at: 1,
attempts: 0,
last_error: None,
next_attempt_at: 0,
})
.unwrap();
assert_eq!(st.entries[&shared_ino].access, Access::Editor);
assert_eq!(st.entries[&child].access, Access::Editor);
st.flushed_db()
.publish_shared_roots(&virtual_uid, std::slice::from_ref(&shared.uid), &[])
.unwrap();
let snapshot = st.flushed_db().visible_children(&virtual_uid).unwrap();
assert_eq!(snapshot.len(), 1);
st.intern_published_share_root(virtual_ino, snapshot[0].clone(), Access::Viewer);
assert_eq!(
st.flushed_db().share_access(&shared.uid).unwrap(),
Some(Access::Viewer)
);
assert_eq!(st.entries[&shared_ino].access, Access::Viewer);
assert_eq!(st.entries[&child].access, Access::Viewer);
assert_eq!(st.flushed_db().pending_ops().unwrap().len(), 1);
}
#[test]
fn persisted_share_access_controls_offline_hydration() {
let (mut st, _dir) = state();
let root_node = node("root", "none", "My Files", true);
let shared_node = node("offline-share", "root", "Offline share", true);
let child_node = node("offline-child", "offline-share", "nested folder", true);
let leaf_node = node("offline-leaf", "offline-child", "child.txt", false);
for node in [&root_node, &shared_node, &child_node, &leaf_node] {
st.flushed_db().upsert_node(node).unwrap();
}
st.flushed_db()
.set_share_access(&uid("offline-share"), Access::Viewer)
.unwrap();
st.share_access = st.flushed_db().all_share_access().unwrap();
// Reconstruct in deliberately non-topological order, matching the
// database hydration path that first allocates every inode and only
// then materializes entries.
let owned_root = 1;
let shared = 2;
let child = 3;
let leaf = 4;
for (node, ino) in [
(&root_node, owned_root),
(&shared_node, shared),
(&child_node, child),
(&leaf_node, leaf),
] {
st.by_uid.insert(node.uid.clone(), ino);
}
for (ino, parent, node) in [
(leaf, child, leaf_node),
(child, shared, child_node),
(shared, owned_root, shared_node),
(owned_root, owned_root, root_node),
] {
st.entries.insert(
ino,
Entry {
uid: node.uid.clone(),
parent,
node,
access: Access::Unknown,
lookup_count: 1,
open_count: 0,
unlinked: false,
},
);
}
st.next_ino = 5;
st.hydrate_access();
assert_eq!(st.entries[&owned_root].access, Access::Owner);
assert_eq!(st.entries[&shared].access, Access::Viewer);
assert_eq!(st.entries[&child].access, Access::Viewer);
assert_eq!(st.entries[&leaf].access, Access::Viewer);
assert_eq!(
st.require_writable(shared).unwrap_err().code(),
libc::EACCES
);
assert_eq!(st.require_writable(child).unwrap_err().code(), libc::EACCES);
assert_eq!(st.require_writable(leaf).unwrap_err().code(), libc::EACCES);
assert!(st.require_writable(owned_root).is_ok());
}
/// Materialize `nodes` the way `Core::hydrate` does: allocate every inode
/// first, then insert entries with `Access::Unknown`, resolving each parent
/// by uid and parking a node whose parent never made it to disk at
/// `ORPHAN_INO`. `nodes[0]` becomes the mount root.
fn hydrate_from(st: &mut State, nodes: &[Node]) -> Vec<u64> {
for node in nodes {
st.flushed_db().upsert_node(node).unwrap();
}
let inos: Vec<u64> = nodes
.iter()
.enumerate()
.map(|(i, node)| {
let ino = crate::ROOT_INO + i as u64;
st.by_uid.insert(node.uid.clone(), ino);
ino
})
.collect();
st.next_ino = crate::ROOT_INO + nodes.len() as u64;
st.roots.insert(crate::ROOT_INO);
// Reverse order so entries are inserted before their parents, matching
// the hydration path's non-topological materialization.
for (i, node) in nodes.iter().enumerate().rev() {
let ino = inos[i];
let parent = if ino == crate::ROOT_INO {
crate::ROOT_INO
} else {
node.parent_uid
.as_ref()
.and_then(|p| st.by_uid.get(p).copied())
.unwrap_or(crate::ORPHAN_INO)
};
st.entries.insert(
ino,
Entry {
uid: node.uid.clone(),
parent,
node: node.clone(),
access: Access::Unknown,
lookup_count: 1,
open_count: 0,
unlinked: false,
},
);
}
st.hydrate_access();
inos
}
/// A device folder's parent is the device root, which is never persisted as
/// a node, so the folder hydrates with no resident parent. Classifying that
/// as `Unknown` denied every write in every on-demand mount: the mount's own
/// fork says `Owner`, but `require_uid_writable` intersects it with this
/// state, and the intersection is what reaches the queue guards.
#[test]
fn own_volume_nodes_without_a_resident_parent_stay_owned() {
let (mut st, _dir) = state();
let nodes = [
node("root", "none", "My Files", true),
// `device-root` is deliberately absent from `nodes`.
node("documents", "device-root", "Documents", true),
node("folder", "documents", "folder", true),
node("leaf", "folder", "leaf.txt", false),
];
let inos = hydrate_from(&mut st, &nodes);
for ino in inos {
assert_eq!(st.entries[&ino].access, Access::Owner);
assert!(st.require_writable(ino).is_ok());
}
}
/// The same shape on a foreign volume can only be there because of a share,
/// so it keeps failing closed.
#[test]
fn foreign_volume_nodes_without_a_resident_parent_fail_closed() {
let (mut st, _dir) = state();
let foreign = |link: &str, parent: &str, name: &str, is_dir: bool| {
let mut node = node(link, parent, name, is_dir);
node.uid = NodeUid::new(VolumeId::from("other-vol"), LinkId::from(link));
node.parent_uid = Some(NodeUid::new(
VolumeId::from("other-vol"),
LinkId::from(parent),
));
node
};
let nodes = [
node("root", "none", "My Files", true),
// `their-share` is absent, so the subtree has no resident parent.
foreign("their-folder", "their-share", "Team Budget", true),
foreign("their-leaf", "their-folder", "budget.ods", false),
];
let inos = hydrate_from(&mut st, &nodes);
assert_eq!(st.entries[&inos[0]].access, Access::Owner);
for ino in &inos[1..] {
assert_eq!(st.entries[ino].access, Access::Unknown);
assert_eq!(st.require_writable(*ino).unwrap_err().code(), libc::EACCES);
}
}
/// Fail-open is only for the absence of a recorded authority: a share row on
/// the missing parent still governs the subtree below it.
#[test]
fn persisted_authority_outranks_the_own_volume_fail_open() {
let (mut st, _dir) = state();
st.flushed_db()
.set_share_access(&uid("device-root"), Access::Viewer)
.unwrap();
st.share_access = st.flushed_db().all_share_access().unwrap();
let nodes = [
node("root", "none", "My Files", true),
node("documents", "device-root", "Documents", true),
node("leaf", "documents", "leaf.txt", false),
];
let inos = hydrate_from(&mut st, &nodes);
assert_eq!(st.entries[&inos[0]].access, Access::Owner);
for ino in &inos[1..] {
assert_eq!(st.entries[ino].access, Access::Viewer);
assert_eq!(st.require_writable(*ino).unwrap_err().code(), libc::EACCES);
}
}
#[test]
fn reinterned_share_root_propagates_a_viewer_downgrade() {
let (mut st, _dir) = state();
let owned_root = st.intern(0, node("root", "none", "My Files", true));
let mut shared_node = node("shared", "root", "Shared", true);
shared_node.membership = Some(membership(6));
let shared = st.intern(owned_root, shared_node.clone());
let child = st.intern(shared, node("child", "shared", "folder", true));
let leaf = st.intern(child, node("leaf", "child", "leaf.txt", false));
assert_eq!(st.entries[&leaf].access, Access::Editor);
shared_node.membership = Some(membership(4));
assert_eq!(st.intern(owned_root, shared_node), shared);
for ino in [shared, child, leaf] {
assert_eq!(st.entries[&ino].access, Access::Viewer);
assert_eq!(st.require_writable(ino).unwrap_err().code(), libc::EACCES);
}
assert_eq!(
st.flushed_db().share_access(&uid("shared")).unwrap(),
Some(Access::Viewer)
);
}
#[test]
fn missing_light_membership_keeps_persisted_share_authority() {
for persisted in [Access::Viewer, Access::Editor] {
let (mut st, _dir) = state();
let owned_root = st.intern(0, node("root", "none", "My Files", true));
st.flushed_db()
.set_share_access(&uid("shared"), persisted)
.unwrap();
st.share_access = st.flushed_db().all_share_access().unwrap();
let shared = st.intern(
owned_root,
node("shared", "root", "Shared without membership", true),
);
let child = st.intern(
shared,
node("child", "shared", "Light child without membership", false),
);
assert_eq!(st.entries[&shared].access, persisted);
assert_eq!(st.entries[&child].access, persisted);
assert_eq!(
st.flushed_db().share_access(&uid("shared")).unwrap(),
Some(persisted)
);
}
}
#[test]
fn explicit_missing_share_role_fails_closed_and_marks_kernel_attrs() {
let (mut st, _dir) = state();
let owned_root = st.intern(0, node("root", "none", "My Files", true));
st.flushed_db()
.set_share_access(&uid("shared"), Access::Editor)
.unwrap();
let shared = st.intern(owned_root, node("shared", "root", "Shared", true));
let child = st.intern(shared, node("child", "shared", "child.txt", false));
st.take_access_changes();
let changed = st.record_observed_share_root_role(&uid("shared"), None);
assert_eq!(st.entries[&shared].access, Access::Viewer);
assert_eq!(st.entries[&child].access, Access::Viewer);
assert_eq!(
st.flushed_db().share_access(&uid("shared")).unwrap(),
Some(Access::Viewer)
);
assert!(changed.contains(&shared));
assert!(changed.contains(&child));
let pending = st.take_access_changes();
assert!(pending.contains(&shared));
assert!(pending.contains(&child));
}
#[test]
fn synthetic_container_and_observed_roles_drive_shared_access() {
let (mut st, _dir) = state();
let owned_root = st.intern(0, node("root", "none", "My Files", true));
let virtual_uid = NodeUid::new(VolumeId::from("virtual"), LinkId::from("sharedwithme"));
let mut virtual_node = node("sharedwithme", "root", "Shared with me", true);
virtual_node.uid = virtual_uid.clone();
st.flushed_db()
.set_share_access(&virtual_uid, Access::Viewer)
.unwrap();
st.share_access.insert(virtual_uid.clone(), Access::Viewer);
let virtual_ino = st.intern(owned_root, virtual_node);
st.children.insert(virtual_ino, Vec::new());
assert_eq!(st.entries[&virtual_ino].access, Access::Viewer);
let viewer = st.intern(virtual_ino, node("viewer", "sharedwithme", "Viewer", true));
let editor = st.intern(virtual_ino, node("editor", "sharedwithme", "Editor", true));
let unknown = st.intern(
virtual_ino,
node("unknown", "sharedwithme", "Unknown", true),
);
st.record_observed_share_root_role(&uid("viewer"), Some(MemberRole::Viewer));
st.record_observed_share_root_role(&uid("editor"), Some(MemberRole::Editor));
st.record_observed_share_root_role(&uid("unknown"), None);
let child = st.intern(editor, node("child", "editor", "child.txt", false));
assert_eq!(st.entries[&viewer].access, Access::Viewer);
assert_eq!(st.entries[&editor].access, Access::Editor);
assert_eq!(st.entries[&unknown].access, Access::Viewer);
assert_eq!(st.entries[&child].access, Access::Editor);
st.children
.get_mut(&virtual_ino)
.unwrap()
.extend([viewer, editor, unknown]);
let changed = st.hide_shared_root(&uid("editor"));
assert!(changed.contains(&editor));
assert!(changed.contains(&child));
assert!(!st.children[&virtual_ino].contains(&editor));
assert!(!st.children.contains_key(&editor));
assert_eq!(st.entries[&editor].access, Access::Viewer);
assert_eq!(st.entries[&child].access, Access::Viewer);
}
#[test]
fn restored_editor_access_marks_root_and_descendants_for_invalidation() {
let (mut st, _dir) = state();
let owned_root = st.intern(0, node("root", "none", "My Files", true));
let mut shared_node = node("shared", "root", "Shared", true);
shared_node.membership = Some(membership(4));
let shared = st.intern(owned_root, shared_node.clone());
let child = st.intern(shared, node("child", "shared", "child.txt", false));
st.take_access_changes();
shared_node.membership = Some(membership(6));
assert_eq!(st.intern(owned_root, shared_node), shared);
assert_eq!(st.entries[&shared].access, Access::Editor);
assert_eq!(st.entries[&child].access, Access::Editor);
let changed = st.take_access_changes();
assert!(changed.contains(&shared));
assert!(changed.contains(&child));
}
#[test]
fn revoked_root_tombstone_keeps_resident_and_persisted_descendants_read_only() {
let (mut st, _dir) = state();
let owned_root = st.intern(0, node("root", "none", "My Files", true));
let mut shared_node = node("shared", "root", "Shared", true);
shared_node.membership = Some(membership(6));
let shared = st.intern(owned_root, shared_node);
let child = st.intern(shared, node("child", "shared", "folder", true));
let leaf = st.intern(child, node("leaf", "child", "leaf.txt", false));
st.flushed_db()
.set_share_access(&uid("shared"), Access::Viewer)
.unwrap();
let changed = st.downgrade_shared_subtree(&uid("shared"));
assert!(changed.contains(&shared));
assert!(changed.contains(&child));
assert!(changed.contains(&leaf));
st.forget(&uid("shared"));
assert!(!st.entries.contains_key(&shared));
assert_eq!(st.entries[&child].access, Access::Viewer);
assert_eq!(st.entries[&leaf].access, Access::Viewer);
assert_eq!(st.require_writable(leaf).unwrap_err().code(), libc::EACCES);
assert_eq!(
st.flushed_db().effective_node_access(&uid("leaf")).unwrap(),
Some(Access::Viewer)
);
// Restart-shaped reconstruction: the deleted root is absent from the
// inode map, so its direct child is attached to the orphan inode. Cold
// hydration must still follow the persisted parent UID to the retained
// share_access tombstone.
let child_node = st.entries[&child].node.clone();
let leaf_node = st.entries[&leaf].node.clone();
st.entries.clear();
st.by_uid.clear();
st.children.clear();
st.by_uid.insert(child_node.uid.clone(), child);
st.by_uid.insert(leaf_node.uid.clone(), leaf);
st.entries.insert(
leaf,
Entry {
uid: leaf_node.uid.clone(),
parent: child,
node: leaf_node,
access: Access::Unknown,
lookup_count: 1,
open_count: 0,
unlinked: false,
},
);
st.entries.insert(
child,
Entry {
uid: child_node.uid.clone(),
parent: 0,
node: child_node,
access: Access::Unknown,
lookup_count: 1,
open_count: 0,
unlinked: false,
},
);
st.hydrate_access();
assert_eq!(st.entries[&child].access, Access::Viewer);
assert_eq!(st.entries[&leaf].access, Access::Viewer);
assert_eq!(st.require_writable(leaf).unwrap_err().code(), libc::EACCES);
}
#[test]
fn global_access_loss_downgrades_only_recorded_shared_subtrees() {
let (mut st, _dir) = state();
let owned_root = st.intern(0, node("root", "none", "My Files", true));
let owned_child = st.intern(owned_root, node("owned", "root", "Owned", true));
let mut shared_node = node("shared", "root", "Shared", true);
shared_node.membership = Some(membership(6));
let shared = st.intern(owned_root, shared_node);
let shared_child = st.intern(shared, node("child", "shared", "child.txt", false));
st.flushed_db().downgrade_all_share_access().unwrap();
let changed = st.downgrade_known_shared_access();
assert!(changed.contains(&shared));
assert!(changed.contains(&shared_child));
assert_eq!(st.entries[&shared].access, Access::Viewer);
assert_eq!(st.entries[&shared_child].access, Access::Viewer);
assert_eq!(st.entries[&owned_root].access, Access::Owner);
assert_eq!(st.entries[&owned_child].access, Access::Owner);
}
#[test]
fn global_access_loss_visits_a_deep_shared_tree_once_per_changed_inode() {
let (mut st, _dir) = state();
let owned_root = st.intern(0, node("root", "none", "My Files", true));
let mut shared_node = node("shared", "root", "Shared", true);
shared_node.membership = Some(membership(6));
let shared = st.intern(owned_root, shared_node);
let mut parent = shared;
for index in 0..1_024 {
let link = format!("deep-{index}");
let parent_link = st.entries[&parent].uid.link_id.to_string();
parent = st.intern(parent, node(&link, &parent_link, &link, true));
}
st.flushed_db().downgrade_all_share_access().unwrap();
let changed = st.downgrade_known_shared_access();
let unique: HashSet<u64> = changed.iter().copied().collect();
assert_eq!(changed.len(), 1_025);
assert_eq!(unique.len(), changed.len());
assert_eq!(st.entries[&owned_root].access, Access::Owner);
assert!(
st.entries
.values()
.all(|entry| { entry.uid == uid("root") || entry.access == Access::Viewer })
);
}
#[test]
fn unknown_membership_permissions_fail_closed() {
let (mut st, _dir) = state();
let owned_root = st.intern(0, node("root", "none", "My Files", true));
let mut shared = node("shared", "root", "Shared", true);
shared.membership = Some(membership(38));
let shared = st.intern(owned_root, shared);
assert_eq!(st.entries[&shared].access, Access::Viewer);
}
fn at_revision(mut node: Node, revision: &str, mtime: i64) -> Node {
node.modification_time = mtime;
if let NodeKind::File {
active_revision_id, ..
} = &mut node.kind
{
*active_revision_id = Some(revision.into());
}
node
}
#[test]
fn a_renamed_file_keeps_its_mtime_until_its_content_changes() {
let (mut st, _dir) = state();
let root = st.intern(0, node("root", "none", "My Files", true));
let mut file = node("file", "root", "a.txt", false);
if let NodeKind::File {
claimed_size,
total_size_on_storage,
..
} = &mut file.kind
{
*claimed_size = Some(7);
*total_size_on_storage = 519;
}
let ino = st.intern(root, at_revision(file.clone(), "r1", 100));
// Drive stamps the link on a rename; the content is still r1.
let mut renamed = at_revision(file.clone(), "r1", 160);
renamed.name = "b.txt".into();
st.intern_batch(root, vec![renamed]);
assert_eq!(st.entries[&ino].node.modification_time, 100);
assert_eq!(st.entries[&ino].node.name, "b.txt");
// A listing that leaves the size out still describes revision r1.
let mut bare = at_revision(file.clone(), "r1", 170);
assert_eq!(crate::node_size(&st.entries[&ino].node), 7);
if let NodeKind::File { claimed_size, .. } = &mut bare.kind {
*claimed_size = None;
}
st.intern(root, bare);
assert_eq!(st.entries[&ino].node.modification_time, 100);
assert_eq!(crate::node_size(&st.entries[&ino].node), 7);
st.intern(root, at_revision(file, "r2", 200));
assert_eq!(st.entries[&ino].node.modification_time, 200);
}
fn handle(len: u64, base_size: u64) -> WriteHandle {
let path = std::env::temp_dir().join(format!("pdfs-handle-test-{}", std::process::id()));
let file = File::create(&path).unwrap();
let _ = std::fs::remove_file(&path);
WriteHandle {
ino: 2,
uid: uid("file"),
file: Arc::new(file),
path,
written: Intervals::default(),
len,
base_size,
base_mtime: 0,
base_revision_id: None,
dirty: false,
open_count: 1,
}
}
/// A file created offline is written while its queued create lands. The
/// placeholder had no revision, so the handle takes the empty file the
/// create made as its base (docs/BUGS.md B113).
#[test]
fn a_handle_on_a_placeholder_is_based_on_the_create_that_landed() {
let mut h = handle(0, 0);
assert_eq!(h.base_revision_id, None);
let mut landed = node("real", "root", "f.mkv", false);
landed.modification_time = 160;
if let NodeKind::File {
active_revision_id, ..
} = &mut landed.kind
{
*active_revision_id = Some("empty".into());
}
h.rebase_onto(&landed);
assert_eq!(h.base_mtime, 160);
assert_eq!(h.base_size, 0);
assert_eq!(h.base_revision_id.as_deref(), Some("empty"));
}
#[test]
fn a_listing_does_not_shrink_a_file_open_for_writing() {
let (mut st, _dir) = state();
let ino = st.intern(0, node("file", "root", "workload.db", false));
let mut h = handle(16384, 0);
h.ino = ino;
st.active_writes.insert(ino, h);
st.set_size(ino, 16384);
st.touch_mtime(ino, 500);
// The remote still holds the empty file the create sealed.
st.intern(0, node("file", "root", "workload.db", false));
assert_eq!(crate::node_size(&st.entries[&ino].node), 16384);
assert_eq!(st.entries[&ino].node.modification_time, 500);
// Once the handle is gone the remote is authoritative again.
st.active_writes.remove(&ino);
st.intern(0, node("file", "root", "workload.db", false));
assert_eq!(crate::node_size(&st.entries[&ino].node), 0);
}
#[test]
fn a_write_past_the_end_authors_the_hole_it_leaves() {
// Opened over a 100-byte base, shrunk to 10, then written at 50: bytes
// 10..50 are zeros now, not the base's bytes 10..50.
let mut h = handle(100, 100);
h.written.clip(10);
h.len = 10;
h.record_write(50, 60);
assert_eq!(h.len, 60);
assert_eq!(
h.written.segments(0, 60),
vec![(0, 10, false), (10, 60, true)],
"only the head still comes from the base"
);
}
#[test]
fn a_write_inside_the_file_leaves_the_rest_to_the_base() {
let mut h = handle(100, 100);
h.record_write(40, 50);
assert_eq!(h.len, 100);
assert_eq!(
h.written.segments(0, 100),
vec![(0, 40, false), (40, 50, true), (50, 100, false)]
);
}
}