mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 16:56:59 +01:00
fix(fuse): keep inode stable across rename
relocate() called forget(), dropping the node's by_uid mapping, so the re-enumeration assigned a fresh inode. The kernel had already carried the renamed dentry onto the old one and holds it for the entry TTL, so lookup/getattr/opendir resolved to an inode entries no longer held and returned ENOENT — while readdir walked children and reported the new inode. A renamed directory listed in its parent but could not be entered until the TTL expired. Rewrite the node in place and invalidate both parents' listings instead. Also stops the moved node's DB row being deleted and recreated on every move, which the B1 data-loss fix worked around
This commit is contained in:
1 parent
f9250211b8
commit
532628456e
14 files changed
+1302
-244
No files matched your search
+299
-10
@@ -179,6 +179,12 @@ pub struct ContentCache {
|
||||
/// **never** emptied on open: these are the only copy of content the user
|
||||
/// authored, and the whole point is that they outlive the daemon.
|
||||
staging_dir: PathBuf,
|
||||
/// Subdirectory holding scratch files rescued from an unclean shutdown —
|
||||
/// writes an application had `fsync`ed but not yet closed. Populated only at
|
||||
/// open, drained by the daemon into `staging_dir` once it can address the
|
||||
/// nodes they belong to. Like `staging_dir` and unlike `scratch_dir`, it is
|
||||
/// never emptied blindly.
|
||||
recovery_dir: PathBuf,
|
||||
/// JSON pin registry path.
|
||||
pins_path: PathBuf,
|
||||
/// Soft cap on total blob bytes. Exceeded only transiently: a `store`
|
||||
@@ -225,9 +231,14 @@ impl ContentCache {
|
||||
std::fs::create_dir_all(&thumb_dir)?;
|
||||
let block_dir = content_dir.join("blocks");
|
||||
std::fs::create_dir_all(&block_dir)?;
|
||||
// Scratch holds disk-backed write buffers; a previous run's leftovers are
|
||||
// worthless, so start clean.
|
||||
// Scratch holds disk-backed write buffers. A previous run's leftovers are
|
||||
// worthless *except* where the application asked for durability: a handle
|
||||
// that was `fsync`ed but never closed left a sidecar behind, and POSIX
|
||||
// says those bytes are on stable storage. Rescue those, discard the rest.
|
||||
let scratch_dir = content_dir.join("scratch");
|
||||
let recovery_dir = content_dir.join("recovery");
|
||||
std::fs::create_dir_all(&recovery_dir)?;
|
||||
Self::rescue_scratch(&scratch_dir, &recovery_dir);
|
||||
let _ = std::fs::remove_dir_all(&scratch_dir);
|
||||
std::fs::create_dir_all(&scratch_dir)?;
|
||||
// Staging, by contrast, is deliberately preserved across runs.
|
||||
@@ -242,6 +253,7 @@ impl ContentCache {
|
||||
block_dir,
|
||||
scratch_dir,
|
||||
staging_dir,
|
||||
recovery_dir,
|
||||
pins_path,
|
||||
max_bytes: AtomicU64::new(max_bytes),
|
||||
blob_bytes: AtomicU64::new(0),
|
||||
@@ -433,6 +445,36 @@ impl ContentCache {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Adopt an existing on-disk file as `uid`'s cached content, without reading
|
||||
/// it into memory.
|
||||
///
|
||||
/// [`Self::store`] takes the bytes as a slice, which means a caller holding a
|
||||
/// file has to load all of it first — fine for a thumbnail, an OOM for a
|
||||
/// pinned video. This takes the path instead and hardlinks it into place,
|
||||
/// falling back to a copy when `src` lives on another filesystem.
|
||||
///
|
||||
/// The link makes the cache blob a second name for the same inode, so `src`
|
||||
/// must not be rewritten in place afterwards — unlinking it (the staging
|
||||
/// case, which is what this exists for) is fine and leaves the blob intact.
|
||||
pub fn store_file(&self, uid: &NodeUid, mtime: i64, size: u64, src: &Path) -> Result<()> {
|
||||
let blob = self.blob_path(uid);
|
||||
let tmp = blob.with_extension("tmp");
|
||||
// A stale tmp from an interrupted run would fail the link with EEXIST.
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
if std::fs::hard_link(src, &tmp).is_err() {
|
||||
std::fs::copy(src, &tmp)?;
|
||||
}
|
||||
let bytes = std::fs::metadata(&tmp)?.len();
|
||||
std::fs::rename(&tmp, &blob)?;
|
||||
let meta = serde_json::to_vec(&Meta { mtime, size })?;
|
||||
std::fs::write(self.meta_path(uid), meta)?;
|
||||
self.db
|
||||
.cache_touch(&Self::key(uid), KIND_BLOB, bytes, now_millis())?;
|
||||
self.blob_bytes.fetch_add(bytes, Ordering::Relaxed);
|
||||
self.enforce_budget();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Cache-index key for block `idx` of `uid` — the block file's name, which
|
||||
/// is also its `cache_entries.cache_key`.
|
||||
fn block_key(&self, uid: &NodeUid, idx: u64) -> String {
|
||||
@@ -626,6 +668,127 @@ impl ContentCache {
|
||||
Ok((file, path))
|
||||
}
|
||||
|
||||
/// The sidecar path for a scratch file, holding the [`StagedWrite`] that
|
||||
/// describes it. Written by `fsync` and removed when the write is released;
|
||||
/// its presence at open is what marks a scratch file as durable rather than
|
||||
/// as a crashed run's rubbish.
|
||||
///
|
||||
/// Safe as `with_extension` only because scratch names are dot-free (see
|
||||
/// [`Self::create_scratch`]), so this appends rather than replaces.
|
||||
pub fn scratch_sidecar(scratch: &Path) -> PathBuf {
|
||||
scratch.with_extension("json")
|
||||
}
|
||||
|
||||
/// Record that the write buffered in `scratch` is durable, so a crash before
|
||||
/// `close(2)` does not lose it.
|
||||
///
|
||||
/// Called from `fsync(2)`, whose contract is that the bytes survive a crash.
|
||||
/// The scratch file itself does survive — it is a real file — but nothing
|
||||
/// else knows it holds anything worth keeping, and open() clears the scratch
|
||||
/// directory. The sidecar is that knowledge: uid, logical length, and which
|
||||
/// ranges are authored rather than holes, which is exactly what recovery
|
||||
/// needs to hand the blob to the drain.
|
||||
///
|
||||
/// Written whole to a temp name and renamed, so a crash mid-write leaves the
|
||||
/// previous sidecar rather than a truncated one. The caller is expected to
|
||||
/// have synced `scratch` first: a sidecar promising bytes that never reached
|
||||
/// the disk is worse than no sidecar.
|
||||
pub fn mark_scratch_durable(&self, scratch: &Path, meta: &StagedWrite) -> Result<()> {
|
||||
let side = Self::scratch_sidecar(scratch);
|
||||
let tmp = side.with_extension("json.tmp");
|
||||
std::fs::write(&tmp, serde_json::to_vec(meta)?)?;
|
||||
std::fs::rename(&tmp, &side)?;
|
||||
// The rename is what publishes the sidecar; fsync the directory so the
|
||||
// rename itself survives, not just the bytes it points at.
|
||||
if let Some(dir) = side.parent()
|
||||
&& let Ok(d) = std::fs::File::open(dir)
|
||||
{
|
||||
let _ = d.sync_all();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop a scratch file's durability sidecar, once the write it described has
|
||||
/// been staged (or turned out not to be dirty at all). Best effort: a
|
||||
/// leftover sidecar whose blob is gone is ignored by recovery anyway.
|
||||
pub fn clear_scratch_durable(&self, scratch: &Path) {
|
||||
let _ = std::fs::remove_file(Self::scratch_sidecar(scratch));
|
||||
}
|
||||
|
||||
/// Move every scratch file that carries a readable sidecar into `recovery`,
|
||||
/// before the caller empties the scratch directory.
|
||||
///
|
||||
/// Blob first, then sidecar: a blob in recovery without its sidecar is
|
||||
/// dropped on the next open (indistinguishable from rubbish), whereas a
|
||||
/// sidecar whose blob never arrived would describe nothing. Neither order is
|
||||
/// lossless if we crash mid-rescue, but this one fails toward "unrecovered
|
||||
/// bytes on disk" rather than "op queued against missing content".
|
||||
fn rescue_scratch(scratch_dir: &Path, recovery_dir: &Path) {
|
||||
let Ok(entries) = std::fs::read_dir(scratch_dir) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let blob = entry.path();
|
||||
if blob.extension().is_some_and(|e| e == "json") {
|
||||
continue;
|
||||
}
|
||||
let side = Self::scratch_sidecar(&blob);
|
||||
// Parsed, not merely present: an unreadable sidecar cannot drive a
|
||||
// recovery, so the blob is rubbish like any other leftover.
|
||||
if std::fs::read(&side)
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice::<StagedWrite>(&b).ok())
|
||||
.is_none()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(name) = blob.file_name() else {
|
||||
continue;
|
||||
};
|
||||
if std::fs::rename(&blob, recovery_dir.join(name)).is_err() {
|
||||
continue;
|
||||
}
|
||||
let Some(side_name) = side.file_name() else {
|
||||
continue;
|
||||
};
|
||||
let _ = std::fs::rename(&side, recovery_dir.join(side_name));
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes rescued from an unclean shutdown, as `(blob, meta)` pairs for the
|
||||
/// daemon to hand to the upload queue. A blob whose sidecar did not survive
|
||||
/// the rescue is deleted here — nothing can address it.
|
||||
pub fn recovered_writes(&self) -> Vec<(PathBuf, StagedWrite)> {
|
||||
let Ok(entries) = std::fs::read_dir(&self.recovery_dir) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for entry in entries.flatten() {
|
||||
let blob = entry.path();
|
||||
if blob.extension().is_some_and(|e| e == "json") {
|
||||
continue;
|
||||
}
|
||||
match std::fs::read(Self::scratch_sidecar(&blob))
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice::<StagedWrite>(&b).ok())
|
||||
{
|
||||
Some(meta) => out.push((blob, meta)),
|
||||
None => {
|
||||
let _ = std::fs::remove_file(&blob);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Retire a recovered write. `stage_write` has already moved the blob out,
|
||||
/// so this is the sidecar left behind — and the blob too if the caller could
|
||||
/// not use it and said so by leaving it in place.
|
||||
pub fn discard_recovered(&self, blob: &Path) {
|
||||
let _ = std::fs::remove_file(Self::scratch_sidecar(blob));
|
||||
let _ = std::fs::remove_file(blob);
|
||||
}
|
||||
|
||||
/// Move a released scratch file into the staging directory with a
|
||||
/// [`StagedWrite`] sidecar, and return where it landed.
|
||||
///
|
||||
@@ -1072,6 +1235,47 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The drain promotes a staged upload into the cache by path and then
|
||||
/// unlinks the staging name. The cached copy has to survive that, and the
|
||||
/// promote has to charge the LRU index like [`ContentCache::store`] does.
|
||||
#[test]
|
||||
fn store_file_adopts_blob_and_survives_source_unlink() {
|
||||
let (c, d) = cache();
|
||||
let u = uid("a");
|
||||
let data = b"staged contents";
|
||||
let src = d.path().join("staged-blob");
|
||||
std::fs::write(&src, data).unwrap();
|
||||
|
||||
c.store_file(&u, 100, data.len() as u64, &src).unwrap();
|
||||
std::fs::remove_file(&src).unwrap();
|
||||
|
||||
assert!(c.is_cached(&u, 100, data.len() as u64));
|
||||
assert_eq!(
|
||||
c.read_range(&u, 100, data.len() as u64, 7, 8).unwrap(),
|
||||
b"contents"
|
||||
);
|
||||
assert_eq!(c.usage(), data.len() as u64);
|
||||
}
|
||||
|
||||
/// A cross-filesystem staging dir cannot be hardlinked from; the copy
|
||||
/// blob and its metadata must both be replaced wholesale — a stale `.meta`
|
||||
/// left beside new content would validate a read of the wrong bytes. The
|
||||
/// link goes to a temp name and is renamed over the blob for exactly this,
|
||||
/// so re-storing over an existing entry is the case worth pinning.
|
||||
#[test]
|
||||
fn store_file_replaces_an_existing_blob() {
|
||||
let (c, d) = cache();
|
||||
let u = uid("a");
|
||||
c.store(&u, 100, 3, b"old").unwrap();
|
||||
|
||||
let src = d.path().join("new-blob");
|
||||
std::fs::write(&src, b"newer").unwrap();
|
||||
c.store_file(&u, 200, 5, &src).unwrap();
|
||||
|
||||
assert!(!c.is_cached(&u, 100, 3), "stale meta must not validate");
|
||||
assert_eq!(c.read_range(&u, 200, 5, 0, 5).unwrap(), b"newer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconcile_rebuilds_index_from_disk() {
|
||||
let dir = TempDir::new();
|
||||
@@ -1210,13 +1414,12 @@ mod tests {
|
||||
c.store_block(&uid("a"), 1, 100, i, b"aaaa").unwrap();
|
||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||
}
|
||||
let indexed: u64 = c
|
||||
.db
|
||||
.cache_entries_by_kind("block")
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|(_, s)| s)
|
||||
.sum();
|
||||
let indexed: u64 =
|
||||
c.db.cache_entries_by_kind("block")
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|(_, s)| s)
|
||||
.sum();
|
||||
assert_eq!(
|
||||
c.block_bytes.load(Ordering::Relaxed),
|
||||
indexed,
|
||||
@@ -1351,7 +1554,10 @@ mod tests {
|
||||
if let Some(got) = c.read_thumbnail(&u, 2, 100) {
|
||||
assert_eq!(got, two, "type 2 served type 1's bytes");
|
||||
}
|
||||
assert_eq!(store_errors, 0, "a store lost its staging file to the other type");
|
||||
assert_eq!(
|
||||
store_errors, 0,
|
||||
"a store lost its staging file to the other type"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1577,6 +1783,89 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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,
|
||||
/// since that is a crashed run's rubbish.
|
||||
#[test]
|
||||
fn fsynced_scratch_survives_reopen_and_unmarked_scratch_does_not() {
|
||||
use std::io::Write as _;
|
||||
let dir = TempDir::new();
|
||||
let db = Arc::new(Db::open_in_memory().unwrap());
|
||||
let content = dir.path().join("content");
|
||||
let pins = dir.path().join("pins.json");
|
||||
let c = ContentCache::open(content.clone(), pins.clone(), 0, db.clone()).unwrap();
|
||||
|
||||
let (mut synced, synced_path) = c.create_scratch().unwrap();
|
||||
synced.write_all(b"fsynced bytes").unwrap();
|
||||
synced.sync_all().unwrap();
|
||||
let meta = StagedWrite {
|
||||
uid: uid("a").to_string(),
|
||||
len: 13,
|
||||
base_size: 0,
|
||||
base_mtime: 100,
|
||||
authored: vec![(0, 13)],
|
||||
complete: true,
|
||||
based_on: None,
|
||||
};
|
||||
c.mark_scratch_durable(&synced_path, &meta).unwrap();
|
||||
|
||||
// A second handle that was written but never fsynced: no promise was
|
||||
// made about it, and it is indistinguishable from a torn buffer.
|
||||
let (mut loose, loose_path) = c.create_scratch().unwrap();
|
||||
loose.write_all(b"never fsynced").unwrap();
|
||||
|
||||
// Crash: no release, no staging, just a restart.
|
||||
drop(c);
|
||||
let c2 = ContentCache::open(content, pins, 0, db).unwrap();
|
||||
|
||||
assert!(!synced_path.exists(), "scratch dir is still cleared");
|
||||
assert!(!loose_path.exists());
|
||||
|
||||
let recovered = c2.recovered_writes();
|
||||
assert_eq!(recovered.len(), 1, "only the fsynced write is recoverable");
|
||||
let (blob, got) = &recovered[0];
|
||||
assert_eq!(std::fs::read(blob).unwrap(), b"fsynced bytes");
|
||||
assert_eq!(got.uid, uid("a").to_string());
|
||||
assert_eq!(got.authored, vec![(0, 13)]);
|
||||
|
||||
// Staging it is what a restart does with it; the sidecar goes after.
|
||||
let staged = c2.stage_write(got, blob).unwrap();
|
||||
c2.discard_recovered(blob);
|
||||
assert_eq!(std::fs::read(&staged).unwrap(), b"fsynced bytes");
|
||||
assert!(c2.recovered_writes().is_empty(), "recovery does not repeat");
|
||||
}
|
||||
|
||||
/// Releasing a write hands the bytes to staging, so the sidecar an earlier
|
||||
/// `fsync` left must not offer recovery a second, stale copy of it.
|
||||
#[test]
|
||||
fn cleared_durability_marker_stops_recovery() {
|
||||
use std::io::Write as _;
|
||||
let dir = TempDir::new();
|
||||
let db = Arc::new(Db::open_in_memory().unwrap());
|
||||
let content = dir.path().join("content");
|
||||
let pins = dir.path().join("pins.json");
|
||||
let c = ContentCache::open(content.clone(), pins.clone(), 0, db.clone()).unwrap();
|
||||
|
||||
let (mut f, path) = c.create_scratch().unwrap();
|
||||
f.write_all(b"released").unwrap();
|
||||
let meta = StagedWrite {
|
||||
uid: uid("a").to_string(),
|
||||
len: 8,
|
||||
base_size: 0,
|
||||
base_mtime: 100,
|
||||
authored: vec![(0, 8)],
|
||||
complete: true,
|
||||
based_on: None,
|
||||
};
|
||||
c.mark_scratch_durable(&path, &meta).unwrap();
|
||||
c.clear_scratch_durable(&path);
|
||||
|
||||
drop(c);
|
||||
let c2 = ContentCache::open(content, pins, 0, db).unwrap();
|
||||
assert!(c2.recovered_writes().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scratch_file_is_writable_and_isolated() {
|
||||
use std::io::{Read as _, Seek as _, SeekFrom, Write as _};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Static client identity and per-user filesystem paths.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use directories::ProjectDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -165,18 +165,104 @@ impl AppDirs {
|
||||
.unwrap_or_else(|| self.default_mountpoint())
|
||||
}
|
||||
|
||||
/// Create state + cache dirs if missing.
|
||||
/// Create state + cache dirs if missing, owner-only.
|
||||
///
|
||||
/// The mode is set explicitly rather than left to the umask. These
|
||||
/// directories hold decrypted file content, a plaintext index of every node
|
||||
/// name in the Drive, and the control socket — and `create_dir_all` under a
|
||||
/// typical umask makes them `0755`. Until this was fixed, the only thing
|
||||
/// keeping another local user out was `~/.cache` and `~/.local/state`
|
||||
/// happening to be `0700`, which is a convention of the user's system and
|
||||
/// not something this client established (bugs.md B6).
|
||||
///
|
||||
/// Applied on every start, not just at creation: a directory that already
|
||||
/// exists with a permissive mode — restored from a backup that flattened
|
||||
/// modes, or created by an older build — is tightened here.
|
||||
pub fn ensure(&self) -> Result<()> {
|
||||
std::fs::create_dir_all(self.state_dir())?;
|
||||
std::fs::create_dir_all(self.cache_dir())?;
|
||||
let _ = std::fs::create_dir_all(self.dirs.config_dir());
|
||||
for dir in [self.state_dir(), self.cache_dir()] {
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
restrict_dir(&dir);
|
||||
}
|
||||
let config_dir = self.dirs.config_dir().to_path_buf();
|
||||
if std::fs::create_dir_all(&config_dir).is_ok() {
|
||||
restrict_dir(&config_dir);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Make `dir` owner-only (`0700`). Best effort: a mode we cannot set (an
|
||||
/// exotic filesystem, a directory we do not own) is not worth refusing to
|
||||
/// start over, and the caller has no better answer than continuing.
|
||||
fn restrict_dir(dir: &Path) {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if let Err(e) = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)) {
|
||||
tracing::warn!(path = %dir.display(), error = %e, "could not restrict directory to 0700");
|
||||
}
|
||||
}
|
||||
|
||||
/// Make a just-bound Unix socket owner-only (`0600`).
|
||||
///
|
||||
/// Connecting to the control socket drives the daemon with its authenticated
|
||||
/// session — enumerate, read, upload, trash, share — without presenting any
|
||||
/// credential, since the daemon already holds one. It is an authority boundary,
|
||||
/// not merely private data, and `UnixListener::bind` applies the umask like any
|
||||
/// other file (bugs.md B6).
|
||||
///
|
||||
/// There is a window between `bind` and this call during which the socket
|
||||
/// carries the umask's mode. It is closed in practice by the containing
|
||||
/// directory: [`Dirs::ensure`] has already made the state directory `0700`, so
|
||||
/// nothing else can reach the socket to exploit the window.
|
||||
pub fn restrict_socket(path: &Path) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
fn mode_of(p: &Path) -> u32 {
|
||||
std::fs::metadata(p).unwrap().permissions().mode() & 0o777
|
||||
}
|
||||
|
||||
/// bugs.md B6. A permissive mode here is not a cosmetic problem: these
|
||||
/// directories hold decrypted content, a plaintext index of every node name,
|
||||
/// and the control socket that commands the daemon's session.
|
||||
#[test]
|
||||
fn restrict_dir_makes_a_directory_owner_only() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfs-cfg-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
// Start deliberately world-readable, as `create_dir_all` under a typical
|
||||
// umask would leave it.
|
||||
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
|
||||
restrict_dir(&dir);
|
||||
|
||||
assert_eq!(mode_of(&dir), 0o700, "group and other must have no access");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Connecting to the control socket confers the daemon's authenticated
|
||||
/// session with no credential, so it is an authority boundary rather than
|
||||
/// merely private data.
|
||||
#[test]
|
||||
fn restrict_socket_makes_a_socket_owner_only() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfs-sock-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let sock = dir.join("control.sock");
|
||||
let listener = std::os::unix::net::UnixListener::bind(&sock).unwrap();
|
||||
|
||||
restrict_socket(&sock).unwrap();
|
||||
|
||||
assert_eq!(mode_of(&sock), 0o600, "no other local user may connect");
|
||||
drop(listener);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_app_config_serialization() {
|
||||
|
||||
@@ -76,11 +76,18 @@ pub enum Request {
|
||||
PhotoThumbs { uids: Vec<String> },
|
||||
/// Download a photo's full content into the cache; replies with its path.
|
||||
OpenPhoto { uid: String },
|
||||
/// Upload a photo with the given name, media type, and content bytes.
|
||||
/// Upload the photo at `source_path` under the given name and media type.
|
||||
///
|
||||
/// A path, not the bytes: this protocol is line-delimited JSON, and
|
||||
/// `serde_json` writes a `Vec<u8>` as an array of decimal integers — a ~5-6x
|
||||
/// inflation that both peers then hold in memory at once, which turned a
|
||||
/// large photo or a video into an OOM of the GUI, the daemon, or both. The
|
||||
/// daemon shares a filesystem with every front-end it serves (the socket is
|
||||
/// a Unix socket), so it opens and streams the file itself.
|
||||
UploadPhoto {
|
||||
name: String,
|
||||
media_type: String,
|
||||
bytes: Vec<u8>,
|
||||
source_path: String,
|
||||
capture_time: Option<i64>,
|
||||
},
|
||||
/// Download a Drive file's full content into the cache; replies with the
|
||||
@@ -106,13 +113,10 @@ pub enum Request {
|
||||
/// Create a new folder named `name` under the mountpoint-relative `parent`.
|
||||
/// Replies with [`Response::Ok`].
|
||||
CreateFolder { parent: String, name: String },
|
||||
/// Upload a file named `name` with content `bytes` into the
|
||||
/// mountpoint-relative `parent` folder. Replies with [`Response::Ok`].
|
||||
UploadFile {
|
||||
parent: String,
|
||||
name: String,
|
||||
bytes: Vec<u8>,
|
||||
},
|
||||
// There is deliberately no bytes-carrying single-file upload: it was
|
||||
// `UploadFile { parent, name, bytes }`, and it OOMed on anything large for
|
||||
// the reason described on `UploadPhoto`. `UploadPaths` covers the same case
|
||||
// by path, including the one-file batch.
|
||||
/// Bulk-upload local files and/or directory trees into the mountpoint-relative
|
||||
/// `parent` folder. `sources` are absolute paths on the daemon's own
|
||||
/// filesystem (the daemon is local): each file is uploaded, each directory is
|
||||
@@ -1036,10 +1040,11 @@ mod tests {
|
||||
parent: "a".into(),
|
||||
name: "new".into(),
|
||||
},
|
||||
Request::UploadFile {
|
||||
parent: "a".into(),
|
||||
name: "f.bin".into(),
|
||||
bytes: vec![0, 1, 2, 255],
|
||||
Request::UploadPhoto {
|
||||
name: "p.jpg".into(),
|
||||
media_type: "image/jpeg".into(),
|
||||
source_path: "/home/u/p.jpg".into(),
|
||||
capture_time: Some(1_700_000_000),
|
||||
},
|
||||
Request::UploadPaths {
|
||||
parent: "a".into(),
|
||||
|
||||
@@ -90,7 +90,11 @@ impl Db {
|
||||
/// pass over a large cache does not read every row to drop a few, and so the
|
||||
/// `(kind, last_accessed)` index can answer it as a range scan with an early
|
||||
/// stop.
|
||||
pub fn cache_eviction_candidates(&self, kind: &str, limit: usize) -> Result<Vec<(String, u64)>> {
|
||||
pub fn cache_eviction_candidates(
|
||||
&self,
|
||||
kind: &str,
|
||||
limit: usize,
|
||||
) -> Result<Vec<(String, u64)>> {
|
||||
let conn = self.conn.lock();
|
||||
let mut stmt = conn.prepare_cached(
|
||||
"SELECT cache_key, size_bytes FROM cache_entries
|
||||
|
||||
@@ -373,7 +373,8 @@ fn children_if_listed_gated_on_flag() {
|
||||
fn a_deleted_child_leaves_a_listed_parent_serving_a_stale_listing() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||||
db.upsert_node(&folder("dst", Some("root"), "dest")).unwrap();
|
||||
db.upsert_node(&folder("dst", Some("root"), "dest"))
|
||||
.unwrap();
|
||||
db.upsert_node(&file("f1", "root", "a.txt", 1)).unwrap();
|
||||
db.set_listed(&uid("dst"), true).unwrap();
|
||||
|
||||
@@ -1302,7 +1303,10 @@ fn next_due_op_respects_backoff() {
|
||||
let id = db.enqueue_op(&bulk_op(1, &root)).unwrap().0;
|
||||
db.record_op_failure(id, "boom", 5_000).unwrap();
|
||||
|
||||
assert!(db.next_due_op(4_999).unwrap().is_none(), "still backing off");
|
||||
assert!(
|
||||
db.next_due_op(4_999).unwrap().is_none(),
|
||||
"still backing off"
|
||||
);
|
||||
assert!(db.next_due_op(5_000).unwrap().is_some(), "due now");
|
||||
}
|
||||
|
||||
@@ -1362,7 +1366,9 @@ fn cache_total_bytes_agrees_with_summing_the_rows() {
|
||||
// query. The claim that matters — that the *common* path does not touch the
|
||||
// database at all — is pinned by `budget_check_is_free_when_under_budget`,
|
||||
// which compares against itself and so is load-independent.
|
||||
println!("B4: {rounds} budget checks over {N} entries — scan {scan:?}, aggregate {aggregate:?}");
|
||||
println!(
|
||||
"B4: {rounds} budget checks over {N} entries — scan {scan:?}, aggregate {aggregate:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Victims still come out least-recently-accessed first, now in bounded batches
|
||||
|
||||
Reference in new issue
Block a user