feat(drive): add durable Workspace document storage and lifecycle

Use object_store 0.13.2 immutable local blobs and FeatureStorage SQLite trees, revision CAS, same-transaction receipts, bounded reads/search, staged/orphan GC and retryable deletion. Attach Server authority to serialize cached Drive operations with deletion reservation. Document fsync compensation, Linux guarantees and offline DB-plus-blob restore.

Validation: root cargo check; workspace-drive 29 unit + 12 integration tests; feature-storage 13 tests; workspace-server 649 library + 11 binary tests with test-threads=4; cargo fmt --all -- --check; git diff --check HEAD. Default-parallel Server run hit the existing ticket_checker_burst timeout assertion (648 passed); the isolated filter and bounded-parallel full suite passed. Fault tests are not physical power-cut evidence.
This commit is contained in:
2026-10-08 21:39:51 +09:00
parent 1a095315ee
commit 633522278b
14 changed files with 3558 additions and 10 deletions
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "workspace-drive"
version = "0.1.0"
edition.workspace = true
license.workspace = true
publish = false
[dependencies]
feature-storage.workspace = true
rusqlite.workspace = true
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
sha2.workspace = true
thiserror.workspace = true
uuid = { workspace = true, features = ["v7"] }
# Staging cleanup recognizes this version's local filename protocol. Reinspect
# local publication and durability before upgrading, even within a patch series.
object_store = { version = "=0.13.2", default-features = false, features = ["fs"] }
bytes.workspace = true
futures.workspace = true
tokio = { workspace = true, features = ["rt", "rt-multi-thread"] }
[dev-dependencies]
tempfile.workspace = true
+936
View File
@@ -0,0 +1,936 @@
//! Synchronous, immutable local blobs for the SQLite drive authority.
//!
//! Call these methods on blocking threads, while holding the authority's SQLite
//! IMMEDIATE transaction. That transaction, not this adapter, serializes reads,
//! uploads and GC across processes. The root and its ancestors must be owned by
//! trusted workspace code: path checks reject existing symlinks, but the
//! path-based LocalFileSystem API cannot prevent hostile concurrent replacement.
use std::collections::BTreeSet;
use std::fs::{self, File};
use std::io;
use std::ops::Range;
use std::path::{Component, Path, PathBuf};
use bytes::Bytes;
use futures::{TryStreamExt, executor::block_on};
use object_store::local::LocalFileSystem;
use object_store::path::Path as ObjectPath;
use object_store::{ObjectStore, ObjectStoreExt, PutMode, PutOptions};
use uuid::Uuid;
#[derive(Debug, thiserror::Error)]
pub enum BlobError {
#[error("blob key must be a canonical lowercase hyphenated UUID: {0:?}")]
InvalidKey(String),
#[error("blob root contains a parent traversal or is empty: {0}")]
InvalidRoot(PathBuf),
#[error("blob path is a symlink or has an unexpected file type: {0}")]
UnsafePath(PathBuf),
#[error("blob range {start}..{end} is invalid for size {size}")]
InvalidRange { start: usize, end: usize, size: u64 },
#[error("blob filesystem operation failed for {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: io::Error,
},
#[error(transparent)]
Store(#[from] object_store::Error),
}
#[derive(Debug)]
pub struct BlobStore {
root: PathBuf,
store: LocalFileSystem,
// Per-instance, test-only failures: no process-global state or production
// configuration can turn successful I/O into a simulated failure.
#[cfg(test)]
fault: Option<TestFault>,
}
#[cfg(test)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum TestFault {
PartialStagingWrite,
UploadNoSpace,
UploadPermissionDenied,
FileSync,
DirectorySync,
}
impl BlobStore {
/// Configure an instance-local simulated failure for crate authority tests.
/// This constructor is absent from production builds.
#[cfg(test)]
pub(super) fn with_fault(mut self, fault: TestFault) -> Self {
self.fault = Some(fault);
self
}
/// Create and durably link a flat blob directory, rejecting symlinks in
/// every existing component (including ancestors of an absolute root).
pub fn open(root: &Path) -> Result<Self, BlobError> {
if root.as_os_str().is_empty()
|| root.components().any(|c| matches!(c, Component::ParentDir))
{
return Err(BlobError::InvalidRoot(root.to_owned()));
}
let root = if root.is_absolute() {
root.to_owned()
} else {
io_at(root, std::env::current_dir())?.join(root)
};
check_directories(&root, true)?;
let store = LocalFileSystem::new_with_prefix(&root)?;
Ok(Self {
root,
store,
#[cfg(test)]
fault: None,
})
}
/// Atomically create a new blob; an existing key is never overwritten.
/// Success means the inode and its directory entry have been synced.
/// A sync error can leave an unreferenced blob for authority GC to collect.
pub fn put(&self, key: &str, data: &[u8]) -> Result<(), BlobError> {
let location = key_path(key)?;
self.check_root()?;
self.check_blob(key)?;
#[cfg(test)]
self.fail_at(
TestFault::UploadPermissionDenied,
&self.root.join(format!("{key}#1")),
)?;
#[cfg(test)]
if let Some(fault @ (TestFault::PartialStagingWrite | TestFault::UploadNoSpace)) =
self.fault
{
// Simulate the ObjectStore boundary failing after a real partial
// staging write, before publishing a final pathname. Leave the
// artifact as a crashed upload or failed best-effort cleanup would.
use std::io::Write;
let path = self.root.join(format!("{key}#1"));
let mut file = io_at(
&path,
fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path),
)?;
io_at(&path, file.write_all(&data[..data.len() / 2]))?;
self.fail_at(fault, &path)?;
}
block_on(self.store.put_opts(
&location,
Bytes::copy_from_slice(data).into(),
PutOptions {
mode: PutMode::Create,
..Default::default()
},
))?;
// object_store 0.13.2 has no with_fsync option. Its put_opts writes a
// create_new staging file, publishes with hard_link for Create, and
// best-effort unlinks staging, without syncing either inode or parent.
// Sync the published inode BEFORE its directory and BEFORE returning
// to the SQLite transaction. No cooperating reader sees it before then.
let path = self.root.join(key);
let file = io_at(&path, File::open(&path))?;
#[cfg(test)]
self.fail_at(TestFault::FileSync, &path)?;
io_at(&path, file.sync_all())?;
self.sync_root()
}
/// Return the actual blob length, not the length of a requested range.
/// The authority must compare this with its expected total length before
/// read/search, even for an empty range or zero-length metadata. Hold the
/// same SQLite IMMEDIATE transaction through size validation and reading.
pub fn size(&self, key: &str) -> Result<u64, BlobError> {
let location = key_path(key)?;
self.check_root()?;
self.check_blob(key)?;
Ok(block_on(self.store.head(&location))?.size)
}
/// Read an exact half-open range. Empty ranges at EOF are valid; reversed
/// ranges and ranges extending past EOF are rejected, not truncated.
pub fn read(&self, key: &str, range: Range<usize>) -> Result<Vec<u8>, BlobError> {
let location = key_path(key)?;
self.check_root()?;
self.check_blob(key)?;
let size = block_on(self.store.head(&location))?.size;
if range.start > range.end || range.end as u64 > size {
return Err(BlobError::InvalidRange {
start: range.start,
end: range.end,
size,
});
}
if range.is_empty() {
return Ok(Vec::new());
}
Ok(block_on(
self.store
.get_range(&location, range.start as u64..range.end as u64),
)?
.to_vec())
}
/// Durably remove a blob. Missing blobs are also synced before success,
/// allowing GC to safely retry after an earlier directory-sync failure.
pub fn delete(&self, key: &str) -> Result<(), BlobError> {
let location = key_path(key)?;
self.check_root()?;
self.check_blob(key)?;
match block_on(self.store.delete(&location)) {
Ok(()) | Err(object_store::Error::NotFound { .. }) => self.sync_root(),
Err(error) => Err(error.into()),
}
}
/// Reclaim at most `limit` crashed-upload staging files. The caller MUST
/// hold the SQLite IMMEDIATE lock: otherwise an in-flight put's staging
/// file could be removed. Foreign names and final blobs are never deleted.
/// A scan can visit the entire flat directory; memory usage is constant.
/// Success includes a directory sync even when there was nothing to remove,
/// so a retry after a previous sync failure still establishes durability.
pub fn cleanup_staging(&self, limit: usize) -> Result<usize, BlobError> {
self.check_root()?;
let mut removed = 0;
if limit > 0 {
for entry in io_at(&self.root, fs::read_dir(&self.root))? {
let entry = io_at(&self.root, entry)?;
let path = entry.path();
let metadata = io_at(&path, fs::symlink_metadata(&path))?;
if metadata.is_symlink() {
return Err(BlobError::UnsafePath(path));
}
let name = entry.file_name();
if !name.to_str().is_some_and(is_staging_name) {
continue;
}
if !metadata.is_file() {
return Err(BlobError::UnsafePath(path));
}
// ObjectStore refuses paths with '#digits'; reclaim its
// recognized staging artifacts directly, without following links.
io_at(&path, fs::remove_file(&path))?;
removed += 1;
if removed == limit {
break;
}
}
}
self.sync_root()?;
Ok(removed)
}
/// List canonical blob keys in ascending lexical order, strictly after the
/// cursor. Staging files and foreign regular files are never exposed.
/// The full local directory must still be traversed: ObjectStore does not
/// promise ordering. Retained candidates use O(limit) memory (plus the
/// LocalFileSystem stream's fixed-size, 1024-entry Tokio buffer).
pub fn list(&self, after: Option<&str>, limit: usize) -> Result<Vec<String>, BlobError> {
if let Some(after) = after {
key_path(after)?;
}
self.check_root()?;
if limit == 0 {
return Ok(Vec::new());
}
// LocalFileSystem recursively lists and follows even out-of-root
// symlinks. Validate this flat directory before giving it to the store.
for entry in io_at(&self.root, fs::read_dir(&self.root))? {
let entry = io_at(&self.root, entry)?;
let path = entry.path();
let metadata = io_at(&path, fs::symlink_metadata(&path))?;
if !metadata.is_file() {
return Err(BlobError::UnsafePath(path));
}
}
block_on(async {
let mut objects = self.store.list(None);
let mut keys = BTreeSet::new();
while let Some(object) = objects.try_next().await? {
let key = object.location.to_string();
if key_path(&key).is_err() || after.is_some_and(|after| key.as_str() <= after) {
continue;
}
if keys.len() == limit {
if keys.last().is_some_and(|last| &key >= last) {
continue;
}
keys.pop_last();
}
keys.insert(key);
}
Ok(keys.into_iter().collect())
})
}
fn sync_root(&self) -> Result<(), BlobError> {
#[cfg(test)]
self.fail_at(TestFault::DirectorySync, &self.root)?;
sync_directory(&self.root)
}
#[cfg(test)]
fn fail_at(&self, point: TestFault, path: &Path) -> Result<(), BlobError> {
if self.fault == Some(point) {
let kind = match point {
TestFault::UploadNoSpace => io::ErrorKind::StorageFull,
TestFault::UploadPermissionDenied => io::ErrorKind::PermissionDenied,
_ => io::ErrorKind::Other,
};
return io_at(
path,
Err(io::Error::new(kind, format!("injected {point:?} failure"))),
);
}
Ok(())
}
fn check_root(&self) -> Result<(), BlobError> {
check_directories(&self.root, false)
}
fn check_blob(&self, key: &str) -> Result<(), BlobError> {
let path = self.root.join(key);
match fs::symlink_metadata(&path) {
Ok(metadata) if metadata.is_file() => Ok(()),
Ok(_) => Err(BlobError::UnsafePath(path)),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
Err(source) => Err(BlobError::Io { path, source }),
}
}
}
fn key_path(key: &str) -> Result<ObjectPath, BlobError> {
let uuid = Uuid::parse_str(key).map_err(|_| BlobError::InvalidKey(key.to_owned()))?;
if uuid.hyphenated().to_string() != key {
return Err(BlobError::InvalidKey(key.to_owned()));
}
Ok(ObjectPath::from(key))
}
fn is_staging_name(name: &str) -> bool {
let Some((key, suffix)) = name.split_once('#') else {
return false;
};
// object_store 0.13.2 local.rs::new_staged_upload starts an inferred i32
// at 1 and uses counter.to_string(); staged_upload_path appends '#suffix'.
// Restrict to names this adapter's puts can generate, not the broader
// '#digits' pattern that LocalFileSystem merely hides during listing.
key_path(key).is_ok()
&& suffix
.parse::<i32>()
.is_ok_and(|counter| counter > 0 && counter.to_string() == suffix)
}
fn io_at<T>(path: &Path, result: io::Result<T>) -> Result<T, BlobError> {
result.map_err(|source| BlobError::Io {
path: path.to_owned(),
source,
})
}
fn check_directories(root: &Path, create: bool) -> Result<(), BlobError> {
let mut path = PathBuf::new();
for component in root.components() {
match component {
Component::Prefix(_) | Component::RootDir => path.push(component),
Component::CurDir => continue,
Component::ParentDir => return Err(BlobError::InvalidRoot(root.to_owned())),
Component::Normal(name) => path.push(name),
}
match fs::symlink_metadata(&path) {
Ok(metadata) if metadata.is_dir() => {}
Ok(_) => return Err(BlobError::UnsafePath(path)),
Err(error) if create && error.kind() == io::ErrorKind::NotFound => {
match fs::create_dir(&path) {
Ok(()) => {}
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
Err(source) => return Err(BlobError::Io { path, source }),
}
// A concurrent opener may have won create_dir. Never accept
// its entry without checking the type without following links.
let metadata = io_at(&path, fs::symlink_metadata(&path))?;
if !metadata.is_dir() {
return Err(BlobError::UnsafePath(path));
}
}
Err(source) => return Err(BlobError::Io { path, source }),
}
if create {
// Sync existing directories too: a previous failed open may have
// created one but failed before persisting its parent link.
sync_directory(&path)?;
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
sync_directory(parent)?;
}
}
}
Ok(())
}
fn sync_directory(path: &Path) -> Result<(), BlobError> {
// Do not silently weaken durability on platforms/filesystems that cannot
// open and sync directories: open/put/delete must propagate that failure.
let directory = io_at(path, File::open(path))?;
io_at(path, directory.sync_all())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn key() -> String {
Uuid::now_v7().to_string()
}
#[test]
fn put_existing_key_preserves_original_bytes_after_reopen() {
let dir = tempdir().unwrap();
let root = dir.path().join("nested/blobs");
let blob = key();
let store = BlobStore::open(&root).unwrap();
store.put(&blob, b"original").unwrap();
assert!(matches!(
store.put(&blob, b"replacement"),
Err(BlobError::Store(object_store::Error::AlreadyExists { .. }))
));
let reopened = BlobStore::open(&root).unwrap();
assert_eq!(reopened.read(&blob, 0..8).unwrap(), b"original");
}
#[test]
fn read_returns_exact_ranges_and_rejects_out_of_bounds() {
let dir = tempdir().unwrap();
let store = BlobStore::open(dir.path()).unwrap();
let blob = key();
store.put(&blob, b"abcdef").unwrap();
assert_eq!(store.read(&blob, 2..5).unwrap(), b"cde");
assert_eq!(store.read(&blob, 6..6).unwrap(), b"");
for (start, end) in [(4, 2), (0, 7), (7, 7)] {
assert!(matches!(
store.read(&blob, start..end),
Err(BlobError::InvalidRange { .. })
));
}
let empty = key();
store.put(&empty, b"").unwrap();
assert_eq!(store.read(&empty, 0..0).unwrap(), b"");
assert!(matches!(
store.read(&key(), 0..0),
Err(BlobError::Store(object_store::Error::NotFound { .. }))
));
}
#[test]
fn noncanonical_keys_are_rejected_by_every_boundary() {
let dir = tempdir().unwrap();
let store = BlobStore::open(dir.path()).unwrap();
let canonical = "019abcdef012-7000-8000-000000000001";
for invalid in [
"",
"../escape",
"/absolute",
"a/b",
"not-a-uuid",
canonical,
"019abcdef01270008000000000000001",
"019abcde-f012-7000-8000-000000000001/child",
"019ABCDE-F012-7000-8000-000000000001",
"urn:uuid:019abcde-f012-7000-8000-000000000001",
] {
assert!(
matches!(store.put(invalid, b"data"), Err(BlobError::InvalidKey(_))),
"{invalid}"
);
assert!(
matches!(store.read(invalid, 0..1), Err(BlobError::InvalidKey(_))),
"{invalid}"
);
assert!(
matches!(store.size(invalid), Err(BlobError::InvalidKey(_))),
"{invalid}"
);
assert!(
matches!(store.delete(invalid), Err(BlobError::InvalidKey(_))),
"{invalid}"
);
assert!(
matches!(store.list(Some(invalid), 0), Err(BlobError::InvalidKey(_))),
"{invalid}"
);
}
assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 0);
}
#[test]
fn list_is_sorted_exclusive_and_hides_staging_and_foreign_files() {
let dir = tempdir().unwrap();
let store = BlobStore::open(dir.path()).unwrap();
let keys = [
"019abcde-f012-7000-8000-000000000001",
"019abcde-f012-7000-8000-000000000002",
"019abcde-f012-7000-8000-000000000003",
];
for blob in keys.iter().rev() {
store.put(blob, b"data").unwrap();
}
fs::write(dir.path().join(format!("{}#1", keys[0])), b"partial").unwrap();
fs::write(dir.path().join("foreign"), b"data").unwrap();
assert_eq!(store.list(None, 2).unwrap(), keys[..2]);
assert_eq!(store.list(Some(keys[0]), 1).unwrap(), keys[1..2]);
assert_eq!(store.list(Some(keys[1]), 10).unwrap(), keys[2..]);
assert!(store.list(Some(keys[2]), 10).unwrap().is_empty());
assert!(store.list(None, 0).unwrap().is_empty());
}
#[test]
fn delete_is_idempotent_and_absent_after_reopen() {
let dir = tempdir().unwrap();
let blob = key();
let store = BlobStore::open(dir.path()).unwrap();
store.put(&blob, b"data").unwrap();
store.delete(&blob).unwrap();
store.delete(&blob).unwrap();
let reopened = BlobStore::open(dir.path()).unwrap();
assert!(reopened.list(None, 10).unwrap().is_empty());
assert!(matches!(
reopened.read(&blob, 0..1),
Err(BlobError::Store(object_store::Error::NotFound { .. }))
));
}
#[test]
fn root_rejects_files_empty_paths_and_parent_traversal() {
let dir = tempdir().unwrap();
let file = dir.path().join("file");
fs::write(&file, b"data").unwrap();
assert!(matches!(
BlobStore::open(&file),
Err(BlobError::UnsafePath(_))
));
assert!(matches!(
BlobStore::open(&file.join("blobs")),
Err(BlobError::UnsafePath(_))
));
assert!(matches!(
BlobStore::open(Path::new("")),
Err(BlobError::InvalidRoot(_))
));
assert!(matches!(
BlobStore::open(&dir.path().join("../blobs")),
Err(BlobError::InvalidRoot(_))
));
}
#[cfg(unix)]
#[test]
fn root_rejects_symlink_components_including_dangling_links() {
use std::os::unix::fs::symlink;
let dir = tempdir().unwrap();
let target = dir.path().join("target");
fs::create_dir(&target).unwrap();
let link = dir.path().join("link");
symlink(&target, &link).unwrap();
assert!(matches!(
BlobStore::open(&link),
Err(BlobError::UnsafePath(_))
));
assert!(matches!(
BlobStore::open(&link.join("blobs")),
Err(BlobError::UnsafePath(_))
));
let dangling = dir.path().join("dangling");
symlink(dir.path().join("missing"), &dangling).unwrap();
assert!(matches!(
BlobStore::open(&dangling),
Err(BlobError::UnsafePath(_))
));
assert!(!target.join("blobs").exists());
}
#[cfg(unix)]
#[test]
fn blob_symlink_is_rejected_without_reading_or_mutating_target() {
use std::os::unix::fs::symlink;
let dir = tempdir().unwrap();
let root = dir.path().join("blobs");
let store = BlobStore::open(&root).unwrap();
let target = dir.path().join("target");
fs::write(&target, b"secret").unwrap();
let blob = key();
symlink(&target, root.join(&blob)).unwrap();
assert!(matches!(
store.read(&blob, 0..6),
Err(BlobError::UnsafePath(_))
));
assert!(matches!(
store.put(&blob, b"changed"),
Err(BlobError::UnsafePath(_))
));
assert!(matches!(store.delete(&blob), Err(BlobError::UnsafePath(_))));
assert!(matches!(
store.list(None, 10),
Err(BlobError::UnsafePath(_))
));
assert!(matches!(store.size(&blob), Err(BlobError::UnsafePath(_))));
assert!(matches!(
store.cleanup_staging(10),
Err(BlobError::UnsafePath(_))
));
assert_eq!(fs::read(&target).unwrap(), b"secret");
}
#[cfg(unix)]
#[test]
fn replaced_root_symlink_is_rejected_by_existing_store() {
use std::os::unix::fs::symlink;
let dir = tempdir().unwrap();
let root = dir.path().join("blobs");
let store = BlobStore::open(&root).unwrap();
let moved = dir.path().join("moved");
fs::rename(&root, &moved).unwrap();
symlink(&moved, &root).unwrap();
let blob = key();
assert!(matches!(
store.put(&blob, b"data"),
Err(BlobError::UnsafePath(_))
));
assert!(matches!(
store.read(&blob, 0..0),
Err(BlobError::UnsafePath(_))
));
assert!(matches!(store.delete(&blob), Err(BlobError::UnsafePath(_))));
assert!(matches!(
store.list(None, 10),
Err(BlobError::UnsafePath(_))
));
assert!(matches!(store.size(&blob), Err(BlobError::UnsafePath(_))));
assert!(matches!(
store.cleanup_staging(10),
Err(BlobError::UnsafePath(_))
));
assert_eq!(fs::read_dir(&moved).unwrap().count(), 0);
}
#[test]
fn list_rejects_subdirectories_instead_of_traversing_them() {
let dir = tempdir().unwrap();
let store = BlobStore::open(dir.path()).unwrap();
fs::create_dir(dir.path().join("nested")).unwrap();
assert!(matches!(
store.list(None, 10),
Err(BlobError::UnsafePath(_))
));
}
#[test]
fn independent_store_creates_have_exactly_one_winner() {
use std::sync::Barrier;
let dir = tempdir().unwrap();
let first = BlobStore::open(dir.path()).unwrap();
let second = BlobStore::open(dir.path()).unwrap();
let blob = key();
let barrier = Barrier::new(2);
let results = std::thread::scope(|scope| {
let one = scope.spawn(|| {
barrier.wait();
first.put(&blob, b"first")
});
let two = scope.spawn(|| {
barrier.wait();
second.put(&blob, b"second")
});
[one.join().unwrap(), two.join().unwrap()]
});
assert_eq!(results.iter().filter(|r| r.is_ok()).count(), 1);
let loser = results.iter().find(|r| r.is_err()).unwrap();
assert!(matches!(
loser,
Err(BlobError::Store(object_store::Error::AlreadyExists { .. }))
));
let expected: &[u8] = if results[0].is_ok() {
b"first"
} else {
b"second"
};
assert_eq!(first.read(&blob, 0..expected.len()).unwrap(), expected);
}
#[test]
fn small_list_pages_choose_earliest_keys_across_full_directory() {
let dir = tempdir().unwrap();
let store = BlobStore::open(dir.path()).unwrap();
let keys: Vec<_> = (1..=37).map(|id| Uuid::from_u128(id).to_string()).collect();
for blob in keys.iter().rev() {
fs::write(dir.path().join(blob), b"data").unwrap();
}
assert_eq!(store.list(None, 3).unwrap(), keys[..3]);
assert_eq!(store.list(Some(&keys[17]), 3).unwrap(), keys[18..21]);
assert_eq!(store.list(Some(&keys[35]), 3).unwrap(), keys[36..]);
}
#[test]
fn staging_cleanup_is_bounded_and_preserves_final_blobs_and_foreign_names() {
let dir = tempdir().unwrap();
let store = BlobStore::open(dir.path()).unwrap();
let referenced = "019abcde-f012-7000-8000-000000000001".to_owned();
store.put(&referenced, b"referenced data").unwrap();
let linked_stage = format!("{referenced}#1");
fs::hard_link(dir.path().join(&referenced), dir.path().join(&linked_stage)).unwrap();
let stages = [
linked_stage,
format!("{}#2", key()),
format!("{}#2147483647", key()),
];
for stage in &stages[1..] {
fs::write(dir.path().join(stage), b"partial").unwrap();
}
let foreign = [
"foreign#1".to_owned(),
format!("{}#1", referenced.to_uppercase()),
format!("{referenced}#0"),
format!("{referenced}#01"),
format!("{referenced}#+1"),
format!("{referenced}#-1"),
format!("{referenced}#2147483648"),
format!("{referenced}#"),
format!("{referenced}#1.extra"),
format!("{referenced}#1#2"),
];
for name in &foreign {
fs::write(dir.path().join(name), b"foreign data").unwrap();
}
assert_eq!(store.cleanup_staging(0).unwrap(), 0);
assert!(stages.iter().all(|name| dir.path().join(name).exists()));
for remaining in (0..3).rev() {
assert_eq!(store.cleanup_staging(1).unwrap(), 1);
assert_eq!(
stages
.iter()
.filter(|name| dir.path().join(name).exists())
.count(),
remaining
);
}
assert_eq!(store.cleanup_staging(10).unwrap(), 0);
assert_eq!(store.read(&referenced, 0..15).unwrap(), b"referenced data");
assert_eq!(store.list(None, 10).unwrap(), vec![referenced.clone()]);
for name in &foreign {
assert_eq!(
fs::read(dir.path().join(name)).unwrap(),
b"foreign data",
"{name}"
);
}
let reopened = BlobStore::open(dir.path()).unwrap();
assert_eq!(reopened.size(&referenced).unwrap(), 15);
}
#[cfg(unix)]
#[test]
fn staging_cleanup_rejects_symlinks_without_unlinking_or_following_them() {
use std::os::unix::fs::symlink;
let dir = tempdir().unwrap();
let target = dir.path().join("target");
fs::write(&target, b"outside").unwrap();
for name in [format!("{}#1", key()), "foreign-link".to_owned()] {
let root = tempdir().unwrap();
let store = BlobStore::open(root.path()).unwrap();
let path = root.path().join(name);
symlink(&target, &path).unwrap();
assert!(matches!(
store.cleanup_staging(10),
Err(BlobError::UnsafePath(_))
));
assert!(fs::symlink_metadata(&path).unwrap().is_symlink());
}
let root = tempdir().unwrap();
let store = BlobStore::open(root.path()).unwrap();
symlink(
dir.path().join("missing"),
root.path().join(format!("{}#1", key())),
)
.unwrap();
assert!(matches!(
store.cleanup_staging(10),
Err(BlobError::UnsafePath(_))
));
assert_eq!(fs::read(target).unwrap(), b"outside");
}
#[test]
fn staging_cleanup_rejects_recognized_directories_and_preserves_foreign_directories() {
let dir = tempdir().unwrap();
let store = BlobStore::open(dir.path()).unwrap();
let foreign = dir.path().join("foreign-directory");
fs::create_dir(&foreign).unwrap();
assert_eq!(store.cleanup_staging(10).unwrap(), 0);
assert!(foreign.is_dir());
let recognized = dir.path().join(format!("{}#1", key()));
fs::create_dir(&recognized).unwrap();
assert!(matches!(
store.cleanup_staging(10),
Err(BlobError::UnsafePath(_))
));
assert!(recognized.is_dir());
}
#[test]
fn size_reports_total_length_even_for_empty_or_short_range_reads() {
let dir = tempdir().unwrap();
let store = BlobStore::open(dir.path()).unwrap();
let blob = key();
store.put(&blob, b"data").unwrap();
assert_eq!(store.read(&blob, 0..0).unwrap(), b"");
assert_eq!(store.read(&blob, 0..2).unwrap(), b"da");
assert_eq!(store.size(&blob).unwrap(), 4);
// External corruption: range output alone cannot reveal truncation if
// the selected range is empty. The authority can compare total sizes.
fs::write(dir.path().join(&blob), b"").unwrap();
assert_eq!(store.read(&blob, 0..0).unwrap(), b"");
assert_eq!(store.size(&blob).unwrap(), 0);
assert!(matches!(
store.size(&key()),
Err(BlobError::Store(object_store::Error::NotFound { .. }))
));
}
#[test]
fn partial_staging_write_failure_never_reports_success_or_publishes_blob() {
let dir = tempdir().unwrap();
let mut store = BlobStore::open(dir.path()).unwrap();
let blob = key();
store.fault = Some(TestFault::PartialStagingWrite);
assert!(matches!(
store.put(&blob, b"abcdef"),
Err(BlobError::Io { .. })
));
assert_eq!(
fs::read(dir.path().join(format!("{blob}#1"))).unwrap(),
b"abc"
);
assert!(!dir.path().join(&blob).exists());
let reopened = BlobStore::open(dir.path()).unwrap();
assert!(reopened.list(None, 10).unwrap().is_empty());
assert!(matches!(
reopened.size(&blob),
Err(BlobError::Store(object_store::Error::NotFound { .. }))
));
assert_eq!(reopened.cleanup_staging(1).unwrap(), 1);
reopened.put(&blob, b"abcdef").unwrap();
assert_eq!(reopened.read(&blob, 0..6).unwrap(), b"abcdef");
}
#[test]
fn simulated_upload_failures_preserve_error_kind_and_do_not_publish_blobs() {
for (fault, expected_kind, staging_count) in [
(TestFault::UploadNoSpace, io::ErrorKind::StorageFull, 1),
(
TestFault::UploadPermissionDenied,
io::ErrorKind::PermissionDenied,
0,
),
] {
let dir = tempdir().unwrap();
let store = BlobStore::open(dir.path()).unwrap().with_fault(fault);
let blob = key();
match store.put(&blob, b"abcdef").unwrap_err() {
BlobError::Io { source, .. } => assert_eq!(source.kind(), expected_kind),
error => panic!("unexpected {fault:?} error: {error:?}"),
}
assert!(!dir.path().join(&blob).exists());
assert!(store.list(None, 10).unwrap().is_empty());
if staging_count > 0 {
assert_eq!(
fs::read(dir.path().join(format!("{blob}#1"))).unwrap(),
b"abc"
);
}
assert_eq!(store.cleanup_staging(10).unwrap(), staging_count);
}
}
#[test]
fn put_sync_failures_return_errors_even_when_final_file_exists() {
for fault in [TestFault::FileSync, TestFault::DirectorySync] {
let dir = tempdir().unwrap();
let mut store = BlobStore::open(dir.path()).unwrap();
let blob = key();
store.fault = Some(fault);
assert!(
matches!(store.put(&blob, b"data"), Err(BlobError::Io { .. })),
"{fault:?}"
);
// Publication is not a successful durable upload. It may leave an
// unreferenced final file, which the authority's GC can delete.
assert_eq!(fs::read(dir.path().join(&blob)).unwrap(), b"data");
let reopened = BlobStore::open(dir.path()).unwrap();
reopened.delete(&blob).unwrap();
assert!(!dir.path().join(&blob).exists());
}
}
#[test]
fn delete_sync_failure_returns_error_and_missing_file_retry_syncs() {
let dir = tempdir().unwrap();
let mut store = BlobStore::open(dir.path()).unwrap();
let blob = key();
store.put(&blob, b"data").unwrap();
store.fault = Some(TestFault::DirectorySync);
assert!(matches!(store.delete(&blob), Err(BlobError::Io { .. })));
assert!(!dir.path().join(&blob).exists());
assert!(matches!(store.delete(&blob), Err(BlobError::Io { .. })));
store.fault = None;
store.delete(&blob).unwrap();
}
#[test]
fn staging_cleanup_sync_failure_returns_error_and_empty_retry_syncs() {
let dir = tempdir().unwrap();
let mut store = BlobStore::open(dir.path()).unwrap();
let stage = dir.path().join(format!("{}#1", key()));
fs::write(&stage, b"partial").unwrap();
store.fault = Some(TestFault::DirectorySync);
assert!(matches!(
store.cleanup_staging(1),
Err(BlobError::Io { .. })
));
assert!(!stage.exists());
assert!(matches!(
store.cleanup_staging(1),
Err(BlobError::Io { .. })
));
store.fault = None;
assert_eq!(store.cleanup_staging(1).unwrap(), 0);
}
#[test]
fn synchronous_methods_work_on_tokio_blocking_threads() {
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(1)
.build()
.unwrap();
runtime.block_on(async {
tokio::task::spawn_blocking(|| {
let dir = tempdir().unwrap();
let store = BlobStore::open(dir.path()).unwrap();
let blob = key();
store.put(&blob, b"data").unwrap();
assert_eq!(store.read(&blob, 0..4).unwrap(), b"data");
assert_eq!(store.list(None, 1).unwrap(), vec![blob.clone()]);
store.delete(&blob).unwrap();
})
.await
.unwrap();
});
}
}
+719
View File
@@ -0,0 +1,719 @@
//! Workspace-owned latest-version storage. Call these blocking operations on a
//! blocking thread, never on an async executor. SQL IMMEDIATE transactions are
//! the cross-process authority for uploads, tree changes, reads, and collection.
mod blob;
use feature_storage::{
FeatureDatabase, FeatureMigration, FeatureRegistration, FeatureStorageError, RegisteredFeature,
ScopedFeatureStorage,
};
use rusqlite::{Connection, OptionalExtension, Transaction, params};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{path::Path, sync::Arc};
use uuid::Uuid;
pub type Result<T> = std::result::Result<T, Error>;
pub const MAX_FILE_BYTES: usize = 16 * 1024 * 1024;
pub const MAX_READ_BYTES: usize = 1024 * 1024;
pub const MAX_PAGE: usize = 200;
pub const MAX_SEARCH_NODES: usize = 128;
pub const MAX_SEARCH_TEXT_BYTES: usize = 64 * 1024;
pub const MAX_NAME_BYTES: usize = 255;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("invalid Drive request: {0}")]
Invalid(String),
#[error("Drive node or revision no longer exists")]
NotFound,
#[error("Drive revision, name, or request identity conflict")]
Conflict,
#[error("Workspace Drive is fenced for deletion")]
Fenced,
#[error("Drive storage failure: {0}")]
Storage(#[from] FeatureStorageError),
#[error("Drive metadata failure: {0}")]
Sqlite(#[from] rusqlite::Error),
#[error("Drive blob failure: {0}")]
Blob(#[from] blob::BlobError),
#[error("Drive result encoding failure: {0}")]
Encoding(#[from] serde_json::Error),
}
// Decimal strings at the external boundary avoid JavaScript's 53-bit limit.
macro_rules! decimal_id {
($name:ident) => {
#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct $name(i64);
impl $name {
pub fn get(self) -> i64 {
self.0
}
}
impl TryFrom<String> for $name {
type Error = String;
fn try_from(value: String) -> std::result::Result<Self, String> {
let n: i64 = value.parse().map_err(|_| "invalid decimal identifier")?;
if n <= 0 || n.to_string() != value {
return Err("invalid decimal identifier".into());
}
Ok(Self(n))
}
}
impl From<$name> for String {
fn from(value: $name) -> Self {
value.0.to_string()
}
}
};
}
decimal_id!(NodeId);
decimal_id!(Revision);
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Kind {
File,
Directory,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct Node {
pub id: NodeId,
pub workspace_id: String,
pub parent_id: Option<NodeId>,
pub name: String,
pub kind: Kind,
pub revision: Revision,
pub size: u64,
pub content_type: Option<String>,
pub updated_by: String,
pub updated_at_ms: i64,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "operation", rename_all = "snake_case", deny_unknown_fields)]
pub enum Mutation {
CreateFolder {
parent: NodeId,
name: String,
},
CreateFile {
parent: NodeId,
name: String,
content_type: String,
bytes: Vec<u8>,
},
Update {
id: NodeId,
expected_revision: Revision,
content_type: String,
bytes: Vec<u8>,
},
Relocate {
id: NodeId,
expected_revision: Revision,
parent: NodeId,
name: String,
},
Delete {
id: NodeId,
expected_revision: Revision,
},
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct MutationResult {
pub node: Node,
pub deleted: bool,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "snake_case")]
pub enum RequestStatus {
/// No committed result in this authority snapshot. A concurrently running
/// request may still commit; retry with the SAME identity and fingerprint.
Uncommitted,
Committed {
result: MutationResult,
},
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Page {
pub nodes: Vec<Node>,
pub next_after: Option<NodeId>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ReadChunk {
pub id: NodeId,
pub revision: Revision,
pub offset: u64,
pub bytes: Vec<u8>,
pub eof: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct TextChunk {
pub id: NodeId,
pub revision: Revision,
pub offset: u64,
pub text: String,
pub eof: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SearchPage {
pub nodes: Vec<Node>,
pub next_after: Option<NodeId>,
pub examined: usize,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CollectionPage {
pub removed: usize,
pub next_after: Option<String>,
}
#[derive(Clone)]
pub struct Drive {
database: FeatureDatabase,
blobs: Arc<blob::BlobStore>,
workspace_id: String,
_storage: ScopedFeatureStorage,
}
static MIGRATIONS: &[FeatureMigration] =
&[FeatureMigration::new(1, "drive_tree_and_receipts", migrate)];
fn migrate(tx: &Transaction<'_>) -> feature_storage::Result<()> {
tx.execute_batch("\
CREATE TABLE drive_state (singleton INTEGER PRIMARY KEY CHECK(singleton=1),
workspace_id TEXT NOT NULL UNIQUE, deleting INTEGER NOT NULL DEFAULT 0 CHECK(deleting IN (0,1)));
CREATE TABLE drive_nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workspace_id TEXT NOT NULL REFERENCES drive_state(workspace_id),
parent_id INTEGER,
name TEXT NOT NULL CHECK(length(CAST(name AS BLOB)) <= 255),
kind TEXT NOT NULL CHECK(kind IN ('file','directory')),
revision INTEGER NOT NULL CHECK(revision > 0),
blob_key TEXT, size INTEGER NOT NULL CHECK(size >= 0 AND size <= 16777216),
content_type TEXT, updated_by TEXT NOT NULL, updated_at_ms INTEGER NOT NULL,
UNIQUE(workspace_id,id),
FOREIGN KEY(workspace_id,parent_id) REFERENCES drive_nodes(workspace_id,id),
CHECK((kind='directory' AND blob_key IS NULL AND size=0 AND content_type IS NULL)
OR (kind='file' AND blob_key IS NOT NULL AND content_type IS NOT NULL)));
CREATE UNIQUE INDEX drive_sibling_name ON drive_nodes(workspace_id,ifnull(parent_id,0),name);
CREATE INDEX drive_children ON drive_nodes(parent_id,id);
CREATE INDEX drive_blob_reference ON drive_nodes(blob_key) WHERE blob_key IS NOT NULL;
CREATE UNIQUE INDEX drive_single_root ON drive_nodes(workspace_id) WHERE parent_id IS NULL;
CREATE TABLE drive_receipts (request_id TEXT PRIMARY KEY, fingerprint TEXT NOT NULL,
result_json TEXT NOT NULL, committed_at_ms INTEGER NOT NULL);
")?;
Ok(())
}
impl Drive {
pub fn register(storage: &ScopedFeatureStorage) -> feature_storage::Result<RegisteredFeature> {
storage.register(FeatureRegistration::new("workspace-drive", MIGRATIONS))
}
pub fn open(
storage: &ScopedFeatureStorage,
registration: &RegisteredFeature,
root: &Path,
) -> Result<Self> {
let database = storage.open(registration)?;
let workspace_id = storage.workspace_id().to_owned();
let blobs = Arc::new(blob::BlobStore::open(root)?);
database.try_transaction::<_,Error>(|tx| {
tx.execute("INSERT OR IGNORE INTO drive_state(singleton,workspace_id) VALUES (1,?1)", [&workspace_id])?;
let stored: String = tx.query_row("SELECT workspace_id FROM drive_state WHERE singleton=1", [], |r| r.get(0))?;
if stored != workspace_id { return Err(Error::Invalid("metadata belongs to another Workspace".into())); }
tx.execute("INSERT OR IGNORE INTO drive_nodes(workspace_id,parent_id,name,kind,revision,size,updated_by,updated_at_ms)
VALUES (?1,NULL,'','directory',1,0,'server',?2)", params![workspace_id, now_ms()])?;
Ok(())
})?;
Ok(Self {
database,
blobs,
workspace_id,
_storage: storage.clone(),
})
}
/// Trusted Server attachment: SQLite IMMEDIATE also locks this authority,
/// so a cached Drive handle cannot race deletion reservation in server.db.
/// This is Host configuration, never a model-selected database path. The
/// attached authority is read-only by convention; Drive never writes it.
pub fn open_with_workspace_authority(
storage: &ScopedFeatureStorage,
registration: &RegisteredFeature,
root: &Path,
authority_database: &Path,
) -> Result<Self> {
let drive = Self::open(storage, registration, root)?;
let path = authority_database
.to_str()
.ok_or_else(|| Error::Invalid("authority database path must be UTF-8".into()))?;
if !authority_database.is_file() {
return Err(Error::Invalid(
"Workspace authority database is missing".into(),
));
}
drive.database.try_with_connection::<_, Error>(|c| {
let existing: Option<String> = c
.query_row(
"SELECT file FROM pragma_database_list WHERE name='yoi_workspace_authority'",
[],
|r| r.get(0),
)
.optional()?;
match existing {
Some(old) if Path::new(&old) != authority_database => Err(Error::Invalid(
"Drive already bound to a different Workspace authority".into(),
)),
Some(_) => Ok(()),
None => {
c.execute("ATTACH DATABASE ?1 AS yoi_workspace_authority", [path])?;
Ok(())
}
}
})?;
Ok(drive)
}
pub fn root(&self) -> Result<Node> {
self.database.try_with_connection(|c| {
let id = c.query_row(
"SELECT id FROM drive_nodes WHERE parent_id IS NULL",
[],
|r| r.get(0),
)?;
node(c, NodeId(id))
})
}
pub fn metadata(&self, id: NodeId) -> Result<Node> {
self.database.try_with_connection(|c| node(c, id))
}
/// Keyset pages are ordered by stable ID, not name. Concurrent mutations may
/// appear/disappear between pages. There is no snapshot promise across calls.
pub fn list(&self, parent: NodeId, after: Option<NodeId>, limit: usize) -> Result<Page> {
validate_limit(limit, MAX_PAGE)?;
self.database.try_transaction(|tx| {
directory(tx, parent)?;
let mut stmt = tx.prepare(
"SELECT id FROM drive_nodes WHERE parent_id=?1 AND id>?2 ORDER BY id LIMIT ?3",
)?;
let ids = stmt
.query_map(
params![parent.0, after.map_or(0, |v| v.0), limit + 1],
|r| r.get::<_, i64>(0),
)?
.collect::<std::result::Result<Vec<_>, _>>()?;
let has_more = ids.len() > limit;
let nodes = ids
.into_iter()
.take(limit)
.map(|id| node(tx, NodeId(id)))
.collect::<Result<Vec<_>>>()?;
let next_after = has_more.then(|| nodes.last().unwrap().id);
Ok(Page { nodes, next_after })
})
}
pub fn request_status(&self, request_id: &str) -> Result<RequestStatus> {
validate_identity(request_id, "request_id", 128)?;
self.database.try_with_connection(|c| {
let result = c
.query_row(
"SELECT result_json FROM drive_receipts WHERE request_id=?1",
[request_id],
|r| r.get::<_, String>(0),
)
.optional()?;
match result {
Some(json) => Ok(RequestStatus::Committed {
result: serde_json::from_str(&json)?,
}),
None => Ok(RequestStatus::Uncommitted),
}
})
}
/// DB commit is publication. The blob and receipt are never independently
/// treated as a successful mutation. Successful request IDs are retained for
/// the Workspace lifetime; reuse with a different actor/payload is conflict.
pub fn mutate(
&self,
request_id: &str,
actor: &str,
mutation: Mutation,
) -> Result<MutationResult> {
validate_identity(request_id, "request_id", 128)?;
validate_identity(actor, "actor", 256)?;
validate_mutation(&mutation)?;
let fingerprint: String = Sha256::digest(serde_json::to_vec(&(actor, &mutation))?)
.iter()
.map(|b| format!("{b:02x}"))
.collect();
self.database.try_transaction(|tx| {
if let Some((old_fingerprint,json)) = tx.query_row("SELECT fingerprint,result_json FROM drive_receipts WHERE request_id=?1",
[request_id], |r|Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?))).optional()? {
if old_fingerprint != fingerprint { return Err(Error::Conflict); }
return Ok(serde_json::from_str(&json)?);
}
accepting(tx)?;
let at = now_ms();
let result = match mutation {
Mutation::CreateFolder { parent,name } => {
directory(tx,parent)?;
insert(tx,&self.workspace_id,parent,&name,Kind::Directory,None,0,None,actor,at)?
}
Mutation::CreateFile { parent,name,content_type,bytes } => {
directory(tx,parent)?;
let key = Uuid::now_v7().to_string();
self.blobs.put(&key,&bytes)?;
insert(tx,&self.workspace_id,parent,&name,Kind::File,Some(&key),bytes.len(),Some(&content_type),actor,at)?
}
Mutation::Update { id,expected_revision,content_type,bytes } => {
let old = changeable(tx,id,expected_revision)?;
if old.kind != Kind::File { return Err(Error::Invalid("cannot write a directory".into())); }
let key = Uuid::now_v7().to_string();
self.blobs.put(&key,&bytes)?;
cas(tx.execute("UPDATE drive_nodes SET blob_key=?1,size=?2,content_type=?3,revision=revision+1,updated_by=?4,updated_at_ms=?5
WHERE id=?6 AND revision=?7", params![key,bytes.len(),content_type,actor,at,id.0,expected_revision.0])?)?;
MutationResult { node: node(tx,id)?, deleted:false }
}
Mutation::Relocate { id,expected_revision,parent,name } => {
changeable(tx,id,expected_revision)?;
directory(tx,parent)?;
let cyclic: bool = tx.query_row("WITH RECURSIVE ancestors(id,parent_id) AS (
SELECT id,parent_id FROM drive_nodes WHERE id=?1 UNION ALL
SELECT n.id,n.parent_id FROM drive_nodes n JOIN ancestors a ON n.id=a.parent_id)
SELECT EXISTS(SELECT 1 FROM ancestors WHERE id=?2)",params![parent.0,id.0],|r|r.get(0))?;
if cyclic { return Err(Error::Invalid("move would create a cycle".into())); }
unique(tx.execute("UPDATE drive_nodes SET parent_id=?1,name=?2,revision=revision+1,updated_by=?3,updated_at_ms=?4
WHERE id=?5 AND revision=?6",params![parent.0,name,actor,at,id.0,expected_revision.0]))?;
MutationResult { node:node(tx,id)?,deleted:false }
}
Mutation::Delete { id,expected_revision } => {
let old = changeable(tx,id,expected_revision)?;
let children: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM drive_nodes WHERE parent_id=?1)",[id.0],|r|r.get(0))?;
if children { return Err(Error::Conflict); }
cas(tx.execute("DELETE FROM drive_nodes WHERE id=?1 AND revision=?2",params![id.0,expected_revision.0])?)?;
MutationResult { node:old,deleted:true }
}
};
tx.execute("INSERT INTO drive_receipts(request_id,fingerprint,result_json,committed_at_ms) VALUES (?1,?2,?3,?4)",
params![request_id,fingerprint,serde_json::to_string(&result)?,at])?;
Ok(result)
})
}
/// Each chunk requires the originally selected revision. Old revisions are
/// not retained: after update/delete return conflict/not-found, never switch
/// silently to the latest blob. GC cannot run while this read holds SQL lock.
pub fn read(
&self,
id: NodeId,
revision: Revision,
offset: u64,
length: usize,
) -> Result<ReadChunk> {
validate_limit(length, MAX_READ_BYTES)?;
self.database.try_transaction(|tx| {
accepting(tx)?;
let selected = node(tx, id)?;
if selected.revision != revision {
return Err(Error::Conflict);
}
if selected.kind != Kind::File {
return Err(Error::Invalid("cannot read a directory".into()));
}
if offset > selected.size {
return Err(Error::Invalid("offset beyond end of file".into()));
}
let end = offset.saturating_add(length as u64).min(selected.size);
let key: String = tx.query_row(
"SELECT blob_key FROM drive_nodes WHERE id=?1",
[id.0],
|r| r.get(0),
)?;
if self.blobs.size(&key)? != selected.size {
return Err(Error::Invalid("referenced blob length mismatch".into()));
}
let bytes = self.blobs.read(&key, offset as usize..end as usize)?;
if bytes.len() != (end - offset) as usize {
return Err(Error::Invalid("referenced blob length mismatch".into()));
}
Ok(ReadChunk {
id,
revision,
offset,
bytes,
eof: end == selected.size,
})
})
}
/// UTF-8 text over byte offsets. Callers must select character-aligned
/// ranges; invalid UTF-8 (including a split character) is an explicit error.
pub fn read_text(
&self,
id: NodeId,
revision: Revision,
offset: u64,
length: usize,
) -> Result<TextChunk> {
let chunk = self.read(id, revision, offset, length)?;
let text = String::from_utf8(chunk.bytes).map_err(|_| {
Error::Invalid("range is not valid UTF-8; select character-aligned offsets".into())
})?;
Ok(TextChunk {
id,
revision,
offset,
text,
eof: chunk.eof,
})
}
/// Search examines at most `budget` nodes after the cursor. Text inspection
/// is limited to UTF-8 text/* files <=64KiB, and matches are bounded by budget.
/// An empty matching page may still carry a continuation cursor.
pub fn search(
&self,
query: &str,
text: bool,
after: Option<NodeId>,
budget: usize,
) -> Result<SearchPage> {
validate_identity(query, "query", 256)?;
validate_limit(budget, MAX_SEARCH_NODES)?;
self.database.try_transaction(|tx| {
accepting(tx)?;
let mut stmt = tx.prepare("SELECT id FROM drive_nodes WHERE parent_id IS NOT NULL AND id>?1 ORDER BY id LIMIT ?2")?;
let ids = stmt.query_map(params![after.map_or(0,|v|v.0),budget+1],|r|r.get::<_,i64>(0))?.collect::<std::result::Result<Vec<_>,_>>()?;
let more = ids.len()>budget;
let mut nodes = Vec::new();
let mut last = None;
let mut examined = 0;
for id in ids.into_iter().take(budget) {
let n = node(tx,NodeId(id))?;
last = Some(n.id); examined += 1;
let mut matched = n.name.contains(query);
if !matched && text && n.kind==Kind::File && n.size<=MAX_SEARCH_TEXT_BYTES as u64 && n.content_type.as_deref().is_some_and(|t|t.starts_with("text/")) {
let key: String = tx.query_row("SELECT blob_key FROM drive_nodes WHERE id=?1",[id],|r|r.get(0))?;
if self.blobs.size(&key)? != n.size { return Err(Error::Invalid("referenced blob length mismatch".into())); }
let bytes = self.blobs.read(&key,0..n.size as usize)?;
if bytes.len()!=n.size as usize { return Err(Error::Invalid("referenced blob length mismatch".into())); }
matched = std::str::from_utf8(&bytes).is_ok_and(|s|s.contains(query));
}
if matched { nodes.push(n); }
}
Ok(SearchPage { nodes, next_after:if more {last} else {None}, examined })
})
}
/// Collection shares the exact database lock with uploads/readers. No grace
/// period or process-local mutex is used as a safety proof. Repeat full sweeps
/// from None, since new random keys can sort before a previous cursor.
pub fn collect(&self, after: Option<&str>, limit: usize) -> Result<CollectionPage> {
validate_limit(limit, MAX_PAGE)?;
self.database.try_transaction(|tx| {
accepting(tx)?;
let keys = self.blobs.list(after, limit + 1)?;
let more = keys.len() > limit;
let mut removed = self.blobs.cleanup_staging(limit)?;
let mut last = None;
for key in keys.into_iter().take(limit) {
let referenced: bool = tx.query_row(
"SELECT EXISTS(SELECT 1 FROM drive_nodes WHERE blob_key=?1)",
[&key],
|r| r.get(0),
)?;
if !referenced {
self.blobs.delete(&key)?;
removed += 1;
}
last = Some(key);
}
Ok(CollectionPage {
removed,
next_after: if more { last } else { None },
})
})
}
pub fn fence(&self) -> Result<()> {
self.database.try_transaction(|tx| {
tx.execute("UPDATE drive_state SET deleting=1 WHERE singleton=1", [])?;
Ok(())
})
}
/// Retryable purge. Fence is committed first. Root and deletion tombstone
/// remain until Host FeatureStorage deletes the metadata scope.
pub fn purge(&self) -> Result<()> {
self.fence()?;
self.database.try_transaction(|tx| {
while self.blobs.cleanup_staging(MAX_PAGE)? > 0 {}
loop {
let keys = self.blobs.list(None, MAX_PAGE)?;
if keys.is_empty() {
break;
}
for key in keys {
self.blobs.delete(&key)?;
}
}
tx.execute("DELETE FROM drive_nodes WHERE parent_id IS NOT NULL", [])?;
tx.execute("DELETE FROM drive_receipts", [])?;
Ok(())
})
}
}
fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
.min(i64::MAX as u128) as i64
}
fn validate_limit(n: usize, max: usize) -> Result<()> {
if n == 0 || n > max {
return Err(Error::Invalid(format!("limit must be 1..={max}")));
}
Ok(())
}
fn validate_identity(s: &str, what: &str, max: usize) -> Result<()> {
if s.is_empty() || s.len() > max || s.chars().any(char::is_control) {
return Err(Error::Invalid(format!("invalid {what}")));
}
Ok(())
}
fn validate_name(s: &str) -> Result<()> {
validate_identity(s, "name", MAX_NAME_BYTES)?;
if s == "." || s == ".." || s.contains(['/', '\\']) {
return Err(Error::Invalid("name is not a path".into()));
}
Ok(())
}
fn validate_content(content_type: &str, bytes: &[u8]) -> Result<()> {
validate_identity(content_type, "content_type", 128)?;
if !content_type.is_ascii() || !content_type.contains('/') {
return Err(Error::Invalid("invalid content_type".into()));
}
if bytes.len() > MAX_FILE_BYTES {
return Err(Error::Invalid(format!(
"file exceeds {MAX_FILE_BYTES} bytes"
)));
}
Ok(())
}
fn validate_mutation(m: &Mutation) -> Result<()> {
match m {
Mutation::CreateFolder { name, .. } | Mutation::Relocate { name, .. } => {
validate_name(name)
}
Mutation::CreateFile {
name,
content_type,
bytes,
..
} => {
validate_name(name)?;
validate_content(content_type, bytes)
}
Mutation::Update {
content_type,
bytes,
..
} => validate_content(content_type, bytes),
Mutation::Delete { .. } => Ok(()),
}
}
fn accepting(c: &Connection) -> Result<()> {
if c.query_row(
"SELECT deleting FROM drive_state WHERE singleton=1",
[],
|r| r.get::<_, bool>(0),
)? {
return Err(Error::Fenced);
}
let attached: bool = c.query_row(
"SELECT EXISTS(SELECT 1 FROM pragma_database_list WHERE name='yoi_workspace_authority')",
[],
|r| r.get(0),
)?;
if attached {
let state:Option<String>=c.query_row("SELECT state FROM yoi_workspace_authority.workspaces WHERE workspace_id=(SELECT workspace_id FROM drive_state WHERE singleton=1)",[],|r|r.get(0)).optional()?;
if state.as_deref() != Some("active") {
return Err(Error::Fenced);
}
}
Ok(())
}
fn node(c: &Connection, id: NodeId) -> Result<Node> {
c.query_row("SELECT id,workspace_id,parent_id,name,kind,revision,size,content_type,updated_by,updated_at_ms FROM drive_nodes WHERE id=?1",[id.0],|r| {
Ok(Node {id:NodeId(r.get(0)?),workspace_id:r.get(1)?,parent_id:r.get::<_,Option<i64>>(2)?.map(NodeId),name:r.get(3)?,
kind:if r.get::<_,String>(4)?=="file" {Kind::File} else {Kind::Directory},revision:Revision(r.get(5)?),
size:r.get::<_,i64>(6)? as u64,content_type:r.get(7)?,updated_by:r.get(8)?,updated_at_ms:r.get(9)?})
}).optional()?.ok_or(Error::NotFound)
}
fn directory(c: &Connection, id: NodeId) -> Result<()> {
if node(c, id)?.kind != Kind::Directory {
Err(Error::Invalid("parent must be a directory".into()))
} else {
Ok(())
}
}
fn changeable(c: &Connection, id: NodeId, revision: Revision) -> Result<Node> {
let n = node(c, id)?;
if n.parent_id.is_none() {
return Err(Error::Invalid("root is immutable".into()));
}
if n.revision != revision {
return Err(Error::Conflict);
}
if revision.0 == i64::MAX {
return Err(Error::Invalid("revision exhausted".into()));
}
Ok(n)
}
fn unique(result: rusqlite::Result<usize>) -> Result<usize> {
match result {
Err(rusqlite::Error::SqliteFailure(e, _))
if e.extended_code == rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE =>
{
Err(Error::Conflict)
}
other => Ok(other?),
}
}
fn cas(changed: usize) -> Result<()> {
if changed == 1 {
Ok(())
} else {
Err(Error::Conflict)
}
}
#[allow(clippy::too_many_arguments)]
fn insert(
tx: &Transaction<'_>,
workspace: &str,
parent: NodeId,
name: &str,
kind: Kind,
key: Option<&str>,
size: usize,
content_type: Option<&str>,
actor: &str,
at: i64,
) -> Result<MutationResult> {
unique(tx.execute("INSERT INTO drive_nodes(workspace_id,parent_id,name,kind,revision,blob_key,size,content_type,updated_by,updated_at_ms)
VALUES (?1,?2,?3,?4,1,?5,?6,?7,?8,?9)",params![workspace,parent.0,name,if kind==Kind::File {"file"} else {"directory"},key,size,content_type,actor,at]))?;
Ok(MutationResult {
node: node(tx, NodeId(tx.last_insert_rowid()))?,
deleted: false,
})
}
#[cfg(test)]
mod tests;
+301
View File
@@ -0,0 +1,301 @@
use super::*;
fn fixture(t: &tempfile::TempDir) -> Drive {
let s = feature_storage::FeatureStorage::new(t.path().join("metadata"))
.workspace("ws")
.unwrap();
let r = Drive::register(&s).unwrap();
Drive::open(&s, &r, &t.path().join("blobs")).unwrap()
}
fn create(parent: NodeId, name: &str) -> Mutation {
Mutation::CreateFile {
parent,
name: name.into(),
content_type: "text/plain".into(),
bytes: b"persisted".to_vec(),
}
}
#[test]
fn migration_enforces_workspace_parent_and_unique_names() {
let t = tempfile::tempdir().unwrap();
let d = fixture(&t);
assert_eq!(d.database.schema_version().unwrap(), 1);
let root = d.root().unwrap();
d.mutate("a", "actor", create(root.id, "a")).unwrap();
assert!(matches!(
d.mutate("duplicate", "actor", create(root.id, "a")),
Err(Error::Conflict)
));
let result=d.database.try_transaction::<_,Error>(|tx| {
tx.execute("INSERT INTO drive_nodes(workspace_id,parent_id,name,kind,revision,size,updated_by,updated_at_ms)
VALUES ('other',?1,'bad','directory',1,0,'actor',0)",[root.id.0])?;
Ok(())
});
assert!(result.is_err());
assert_eq!(d.list(root.id, None, 10).unwrap().nodes.len(), 1);
}
#[test]
fn saved_blob_before_metadata_failure_is_not_published_and_restart_collects_it() {
let t = tempfile::tempdir().unwrap();
let d = fixture(&t);
let root = d.root().unwrap();
d.database.transaction(|tx| {tx.execute_batch("CREATE TRIGGER fail_node BEFORE INSERT ON drive_nodes WHEN NEW.parent_id IS NOT NULL
BEGIN SELECT RAISE(ABORT,'injected metadata failure'); END;")?;Ok(())}).unwrap();
assert!(
d.mutate("failed", "actor", create(root.id, "failed"))
.is_err()
);
assert_eq!(d.blobs.list(None, 20).unwrap().len(), 1);
assert!(d.list(root.id, None, 10).unwrap().nodes.is_empty());
assert_eq!(
d.request_status("failed").unwrap(),
RequestStatus::Uncommitted
);
d.database
.transaction(|tx| {
tx.execute_batch("DROP TRIGGER fail_node")?;
Ok(())
})
.unwrap();
drop(d);
let d = fixture(&t);
assert_eq!(d.collect(None, 20).unwrap().removed, 1);
let result = d
.mutate("failed", "actor", create(root.id, "failed"))
.unwrap();
assert_eq!(
d.read(result.node.id, result.node.revision, 0, 100)
.unwrap()
.bytes,
b"persisted"
);
}
#[test]
fn commit_failure_rolls_back_metadata_and_receipt_together() {
let t = tempfile::tempdir().unwrap();
let d = fixture(&t);
let root = d.root().unwrap();
d.database.transaction(|tx| {tx.execute_batch("CREATE TABLE deferred_commit_failure (node INTEGER REFERENCES drive_nodes(id) DEFERRABLE INITIALLY DEFERRED);
CREATE TRIGGER fail_commit AFTER INSERT ON drive_receipts BEGIN INSERT INTO deferred_commit_failure VALUES (-1); END;")?;Ok(())}).unwrap();
assert!(
d.mutate("failed", "actor", create(root.id, "failed"))
.is_err()
);
assert!(d.list(root.id, None, 10).unwrap().nodes.is_empty());
assert_eq!(
d.request_status("failed").unwrap(),
RequestStatus::Uncommitted
);
d.database
.transaction(|tx| {
tx.execute_batch("DROP TRIGGER fail_commit; DROP TABLE deferred_commit_failure")?;
Ok(())
})
.unwrap();
assert_eq!(d.collect(None, 10).unwrap().removed, 1);
assert!(
!d.mutate("failed", "actor", create(root.id, "failed"))
.unwrap()
.deleted
);
}
#[test]
fn collection_cannot_remove_in_flight_upload_or_selected_read() {
// Independent managers/SQLite connections simulate multiple server processes,
// not clones sharing a process-local lock. All calls use the same DB authority.
let t = tempfile::tempdir().unwrap();
let d = fixture(&t);
let other = fixture(&t);
let root = d.root().unwrap();
let first = d
.mutate("first", "actor", create(root.id, "file"))
.unwrap()
.node;
let go = Arc::new(std::sync::Barrier::new(2));
let g = go.clone();
let reader = std::thread::spawn(move || {
g.wait();
for _ in 0..20 {
match other.read(first.id, first.revision, 0, 100) {
Ok(chunk) => assert_eq!(chunk.bytes, b"persisted"),
Err(Error::Conflict) => {}
e => panic!("unexpected selected read outcome: {e:?}"),
}
other.collect(None, 200).unwrap();
}
});
go.wait();
let mut current = first;
for i in 0..20 {
current = d
.mutate(
&format!("update-{i}"),
"actor",
Mutation::Update {
id: current.id,
expected_revision: current.revision,
content_type: "text/plain".into(),
bytes: vec![i; 1024],
},
)
.unwrap()
.node;
}
reader.join().unwrap();
d.collect(None, 200).unwrap();
assert_eq!(d.blobs.list(None, 200).unwrap().len(), 1);
assert_eq!(
d.read(current.id, current.revision, 0, 1024).unwrap().bytes,
vec![19; 1024]
);
}
#[test]
fn attached_server_authority_serializes_deletion_with_cached_drive_mutations() {
let t = tempfile::tempdir().unwrap();
let authority = t.path().join("server.db");
let mut server = Connection::open(&authority).unwrap();
feature_storage::configure_connection(&server).unwrap();
server.execute_batch("CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY,state TEXT NOT NULL); INSERT INTO workspaces VALUES ('ws','active');").unwrap();
let s = feature_storage::FeatureStorage::new(t.path().join("metadata"))
.workspace("ws")
.unwrap();
let reg = Drive::register(&s).unwrap();
let d = Drive::open_with_workspace_authority(&s, &reg, &t.path().join("blobs"), &authority)
.unwrap();
let root = d.root().unwrap();
let child = d
.mutate("before", "actor", create(root.id, "before"))
.unwrap()
.node;
// An IMMEDIATE Drive transaction locks both databases. Reserve deletion
// cannot commit while a previously admitted mutation is publishing a blob.
server.busy_timeout(std::time::Duration::ZERO).unwrap();
d.database.transaction(|_| {
assert!(matches!(server.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate),Err(rusqlite::Error::SqliteFailure(e,_)) if e.code==rusqlite::ErrorCode::DatabaseBusy));
Ok(())
}).unwrap();
server
.execute(
"UPDATE workspaces SET state='deleting' WHERE workspace_id='ws'",
[],
)
.unwrap();
// No separate Drive::fence call needed, including for already issued handles.
assert!(matches!(
d.mutate("after", "actor", create(root.id, "after")),
Err(Error::Fenced)
));
assert!(matches!(
d.read(child.id, child.revision, 0, 100),
Err(Error::Fenced)
));
assert!(matches!(d.collect(None, 10), Err(Error::Fenced)));
d.purge().unwrap();
}
#[test]
fn restarted_collection_removes_crashed_staging_without_removing_referenced_hardlink() {
let t = tempfile::tempdir().unwrap();
let d = fixture(&t);
let root = d.root().unwrap();
let n = d
.mutate("published", "actor", create(root.id, "file"))
.unwrap()
.node;
let key: String = d
.database
.with_connection(|c| {
Ok(c.query_row(
"SELECT blob_key FROM drive_nodes WHERE id=?1",
[n.id.0],
|r| r.get(0),
)?)
})
.unwrap();
let blobs = t.path().join("blobs");
std::fs::hard_link(blobs.join(&key), blobs.join(format!("{key}#1"))).unwrap();
std::fs::write(
blobs.join(format!("{}#1", Uuid::now_v7())),
b"partial upload",
)
.unwrap();
drop(d);
let d = fixture(&t);
assert_eq!(d.collect(None, 200).unwrap().removed, 2);
assert_eq!(
d.read(n.id, n.revision, 0, 100).unwrap().bytes,
b"persisted"
);
assert_eq!(std::fs::read_dir(blobs).unwrap().count(), 1);
}
#[test]
fn interrupted_full_permission_and_sync_failed_uploads_do_not_publish_metadata_or_receipt() {
for fault in [
blob::TestFault::PartialStagingWrite,
blob::TestFault::UploadNoSpace,
blob::TestFault::UploadPermissionDenied,
blob::TestFault::FileSync,
blob::TestFault::DirectorySync,
] {
let t = tempfile::tempdir().unwrap();
let mut d = fixture(&t);
let root = d.root().unwrap();
let original = d
.mutate("original", "actor", create(root.id, "file"))
.unwrap()
.node;
d.blobs = Arc::new(
blob::BlobStore::open(&t.path().join("blobs"))
.unwrap()
.with_fault(fault),
);
let mutation = Mutation::Update {
id: original.id,
expected_revision: original.revision,
content_type: "text/plain".into(),
bytes: b"failed replacement".to_vec(),
};
assert!(
matches!(
d.mutate("failed", "actor", mutation.clone()),
Err(Error::Blob(_))
),
"fault {fault:?}"
);
assert_eq!(
d.metadata(original.id).unwrap(),
original,
"fault {fault:?}"
);
assert_eq!(
d.request_status("failed").unwrap(),
RequestStatus::Uncommitted,
"fault {fault:?}"
);
d.blobs = Arc::new(blob::BlobStore::open(&t.path().join("blobs")).unwrap());
d.collect(None, 200).unwrap();
assert_eq!(
d.read(original.id, original.revision, 0, 100)
.unwrap()
.bytes,
b"persisted"
);
assert_eq!(
std::fs::read_dir(t.path().join("blobs")).unwrap().count(),
1
);
let replaced = d.mutate("failed", "actor", mutation).unwrap().node;
assert_eq!(
d.read(replaced.id, replaced.revision, 0, 100)
.unwrap()
.bytes,
b"failed replacement"
);
}
}
+618
View File
@@ -0,0 +1,618 @@
use feature_storage::FeatureStorage;
use std::{
path::Path,
sync::{Arc, Barrier},
};
use workspace_drive::{
Drive, Error, Kind, MAX_FILE_BYTES, MAX_READ_BYTES, Mutation, Node, RequestStatus,
};
fn open(metadata: &Path, blobs: &Path, workspace: &str) -> Drive {
let storage = FeatureStorage::new(metadata).workspace(workspace).unwrap();
let registered = Drive::register(&storage).unwrap();
Drive::open(&storage, &registered, blobs).unwrap()
}
fn folder(d: &Drive, parent: &Node, name: &str) -> Node {
d.mutate(
name,
"account",
Mutation::CreateFolder {
parent: parent.id,
name: name.into(),
},
)
.unwrap()
.node
}
fn file(
d: &Drive,
parent: &Node,
request: &str,
name: &str,
content_type: &str,
bytes: &[u8],
) -> Node {
d.mutate(
request,
"account",
Mutation::CreateFile {
parent: parent.id,
name: name.into(),
content_type: content_type.into(),
bytes: bytes.to_vec(),
},
)
.unwrap()
.node
}
fn read(d: &Drive, n: &Node) -> Vec<u8> {
d.read(n.id, n.revision, 0, MAX_READ_BYTES).unwrap().bytes
}
fn update(n: &Node, bytes: &[u8]) -> Mutation {
Mutation::Update {
id: n.id,
expected_revision: n.revision,
content_type: "text/markdown".into(),
bytes: bytes.to_vec(),
}
}
#[test]
fn markdown_image_empty_and_folders_survive_restart_with_stable_identity() {
let t = tempfile::tempdir().unwrap();
let db = t.path().join("metadata");
let blobs = t.path().join("drives/ws/blobs");
let d = open(&db, &blobs, "ws");
let root = d.root().unwrap();
let dir = folder(&d, &root, "日本語");
let markdown = file(
&d,
&dir,
"markdown",
"資料.md",
"text/markdown",
"# 日本語\n".as_bytes(),
);
let image = file(
&d,
&dir,
"image",
"画像.png",
"image/png",
b"\x89PNG\r\n\x1a\n",
);
let empty = file(&d, &dir, "empty", "空", "text/plain", b"");
assert_eq!(dir.kind, Kind::Directory);
assert!(read(&d, &empty).is_empty());
let latest = d
.mutate("update", "account", update(&markdown, b"# latest"))
.unwrap()
.node;
drop(d);
let d = open(&db, &blobs, "ws");
assert_eq!(d.root().unwrap(), root);
assert_eq!(d.metadata(dir.id).unwrap(), dir);
assert_eq!(d.metadata(latest.id).unwrap(), latest);
assert_eq!(read(&d, &latest), b"# latest");
assert_eq!(read(&d, &image), b"\x89PNG\r\n\x1a\n");
assert_eq!(d.list(dir.id, None, 200).unwrap().nodes.len(), 3);
}
#[test]
fn relocate_and_paged_list_only_use_metadata_even_when_blob_is_missing() {
let t = tempfile::tempdir().unwrap();
let blobs = t.path().join("blobs");
let d = open(&t.path().join("db"), &blobs, "ws");
let root = d.root().unwrap();
let a = folder(&d, &root, "a");
let b = folder(&d, &root, "b");
let f = file(&d, &a, "f", "f", "text/plain", b"data");
let before = std::fs::read_dir(&blobs)
.unwrap()
.map(|e| e.unwrap().file_name())
.collect::<Vec<_>>();
let moved = d
.mutate(
"move",
"account",
Mutation::Relocate {
id: f.id,
expected_revision: f.revision,
parent: b.id,
name: "改名".into(),
},
)
.unwrap()
.node;
let after = std::fs::read_dir(&blobs)
.unwrap()
.map(|e| e.unwrap().file_name())
.collect::<Vec<_>>();
assert_eq!(before, after);
assert_eq!(read(&d, &moved), b"data");
std::fs::remove_file(blobs.join(&before[0])).unwrap();
assert_eq!(d.list(b.id, None, 1).unwrap().nodes, vec![moved.clone()]);
assert!(matches!(
d.read(moved.id, moved.revision, 0, 1),
Err(Error::Blob(_))
));
let first = d.list(root.id, None, 1).unwrap();
assert_eq!(first.nodes, vec![a]);
let second = d.list(root.id, first.next_after, 1).unwrap();
assert_eq!(second.nodes, vec![b]);
assert!(second.next_after.is_none());
}
#[test]
fn parallel_revision_and_name_competitors_use_database_authority() {
let t = tempfile::tempdir().unwrap();
let db = t.path().join("db");
let blobs = t.path().join("blobs");
let d = open(&db, &blobs, "ws");
let other = open(&db, &blobs, "ws");
let root = d.root().unwrap();
let f = file(&d, &root, "original", "f", "text/plain", b"old");
let barrier = Arc::new(Barrier::new(2));
let b = barrier.clone();
let f2 = f.clone();
let c = other.clone();
let worker = std::thread::spawn(move || {
b.wait();
c.mutate("update-two", "account", update(&f2, b"two"))
});
barrier.wait();
let one = d.mutate("update-one", "account", update(&f, b"one"));
let two = worker.join().unwrap();
assert_eq!(usize::from(one.is_ok()) + usize::from(two.is_ok()), 1);
assert!(matches!(one, Err(Error::Conflict)) || matches!(two, Err(Error::Conflict)));
let b = Arc::new(Barrier::new(2));
let bb = b.clone();
let r = root.clone();
let worker = std::thread::spawn(move || {
bb.wait();
other.mutate(
"same-two",
"account",
Mutation::CreateFolder {
parent: r.id,
name: "same".into(),
},
)
});
b.wait();
let one = d.mutate(
"same-one",
"account",
Mutation::CreateFolder {
parent: root.id,
name: "same".into(),
},
);
let two = worker.join().unwrap();
assert_eq!(usize::from(one.is_ok()) + usize::from(two.is_ok()), 1);
assert!(matches!(one, Err(Error::Conflict)) || matches!(two, Err(Error::Conflict)));
}
#[test]
fn concurrent_cycle_moves_and_parent_delete_child_create_preserve_tree() {
let t = tempfile::tempdir().unwrap();
let db = t.path().join("db");
let blobs = t.path().join("blobs");
let d = open(&db, &blobs, "ws");
let other = open(&db, &blobs, "ws");
let root = d.root().unwrap();
let a = folder(&d, &root, "a");
let b = folder(&d, &root, "b");
let barrier = Arc::new(Barrier::new(2));
let bb = barrier.clone();
let aa = a.clone();
let b2 = b.clone();
let c = other.clone();
let worker = std::thread::spawn(move || {
bb.wait();
c.mutate(
"b-to-a",
"account",
Mutation::Relocate {
id: b2.id,
expected_revision: b2.revision,
parent: aa.id,
name: "b".into(),
},
)
});
barrier.wait();
let one = d.mutate(
"a-to-b",
"account",
Mutation::Relocate {
id: a.id,
expected_revision: a.revision,
parent: b.id,
name: "a".into(),
},
);
let two = worker.join().unwrap();
assert_eq!(usize::from(one.is_ok()) + usize::from(two.is_ok()), 1);
let p = folder(&d, &root, "parent");
let p2 = p.clone();
let barrier = Arc::new(Barrier::new(2));
let bb = barrier.clone();
let worker = std::thread::spawn(move || {
bb.wait();
other.mutate(
"create-child",
"account",
Mutation::CreateFolder {
parent: p2.id,
name: "child".into(),
},
)
});
barrier.wait();
let deleted = d.mutate(
"delete-parent",
"account",
Mutation::Delete {
id: p.id,
expected_revision: p.revision,
},
);
let created = worker.join().unwrap();
assert_eq!(
usize::from(deleted.is_ok()) + usize::from(created.is_ok()),
1
);
if created.is_ok() {
assert_eq!(d.list(p.id, None, 10).unwrap().nodes.len(), 1);
} else {
assert!(matches!(d.metadata(p.id), Err(Error::NotFound)));
}
}
#[test]
fn receipts_survive_response_loss_and_restart_without_double_mutation() {
let t = tempfile::tempdir().unwrap();
let db = t.path().join("db");
let blobs = t.path().join("blobs");
let d = open(&db, &blobs, "ws");
let root = d.root().unwrap();
let m = Mutation::CreateFile {
parent: root.id,
name: "lost-response".into(),
content_type: "text/plain".into(),
bytes: b"first".to_vec(),
};
assert!(matches!(
d.request_status("create").unwrap(),
RequestStatus::Uncommitted
));
let committed = d.mutate("create", "account", m.clone()).unwrap();
drop(d);
let d = open(&db, &blobs, "ws");
assert_eq!(
d.request_status("create").unwrap(),
RequestStatus::Committed {
result: committed.clone()
}
);
assert_eq!(d.mutate("create", "account", m.clone()).unwrap(), committed);
assert!(matches!(
d.mutate("create", "someone-else", m),
Err(Error::Conflict)
));
let update = update(&committed.node, b"second");
let updated = d.mutate("update", "account", update.clone()).unwrap();
assert_eq!(d.mutate("update", "account", update).unwrap(), updated);
assert_eq!(d.list(root.id, None, 20).unwrap().nodes.len(), 1);
}
#[test]
fn deletion_recreation_does_not_reuse_id_or_silently_switch_read_revision() {
let t = tempfile::tempdir().unwrap();
let d = open(&t.path().join("db"), &t.path().join("blobs"), "ws");
let root = d.root().unwrap();
let old = file(&d, &root, "old", "file", "text/plain", b"old");
let next = d
.mutate("update", "account", update(&old, b"new"))
.unwrap()
.node;
assert!(matches!(
d.read(old.id, old.revision, 0, 1),
Err(Error::Conflict)
));
d.mutate(
"delete",
"account",
Mutation::Delete {
id: next.id,
expected_revision: next.revision,
},
)
.unwrap();
let recreated = file(&d, &root, "recreated", "file", "text/plain", b"recreated");
assert!(recreated.id > old.id);
assert!(matches!(d.metadata(old.id), Err(Error::NotFound)));
assert_eq!(read(&d, &recreated), b"recreated");
assert!(d.collect(None, 200).unwrap().removed >= 2);
assert_eq!(read(&d, &recreated), b"recreated");
}
#[test]
fn bounded_search_and_read_reject_paths_oversize_and_invalid_parents() {
let t = tempfile::tempdir().unwrap();
let d = open(&t.path().join("db"), &t.path().join("blobs"), "ws");
let root = d.root().unwrap();
for name in ["../escape", "a/b", "a\\b", ".", "..", "", "bad\0name"] {
assert!(matches!(
d.mutate(
&uuid::Uuid::now_v7().to_string(),
"account",
Mutation::CreateFolder {
parent: root.id,
name: name.into()
}
),
Err(Error::Invalid(_))
));
}
let f = file(
&d,
&root,
"f",
"f",
"text/markdown",
"日本語 needle".as_bytes(),
);
let g = file(&d, &root, "g", "needle.png", "image/png", b"needle");
assert!(matches!(
d.read(f.id, f.revision, 0, MAX_READ_BYTES + 1),
Err(Error::Invalid(_))
));
assert!(matches!(
d.read(f.id, f.revision, f.size + 1, 1),
Err(Error::Invalid(_))
));
assert!(matches!(
d.mutate("huge", "account", update(&f, &vec![0; MAX_FILE_BYTES + 1])),
Err(Error::Invalid(_))
));
assert!(matches!(d.list(f.id, None, 10), Err(Error::Invalid(_))));
assert!(matches!(
d.mutate(
"invalid-parent",
"account",
Mutation::CreateFolder {
parent: f.id,
name: "no".into()
}
),
Err(Error::Invalid(_))
));
let first = d.search("needle", true, None, 1).unwrap();
assert_eq!(first.nodes, vec![f]);
assert_eq!(first.examined, 1);
let second = d.search("needle", true, first.next_after, 1).unwrap();
assert_eq!(second.nodes, vec![g]);
assert!(second.next_after.is_none());
assert!(
d.search("missing", true, None, 1)
.unwrap()
.next_after
.is_some()
);
}
#[test]
fn fence_purge_restart_and_workspace_boundaries_are_persistent() {
let t = tempfile::tempdir().unwrap();
let db = t.path().join("db");
let blobs = t.path().join("drives/a/blobs");
let a = open(&db, &blobs, "a");
let b = open(&db, &t.path().join("drives/b/blobs"), "b");
let ar = a.root().unwrap();
let br = b.root().unwrap();
let af = file(&a, &ar, "af", "f", "text/plain", b"a");
let bf = file(&b, &br, "bf", "f", "text/plain", b"b");
let ad = folder(&a, &ar, "folder");
file(&a, &ad, "child", "child", "text/plain", b"child");
a.fence().unwrap();
drop(a);
let a = open(&db, &blobs, "a");
assert!(matches!(
a.mutate("after-fence", "account", update(&af, b"bad")),
Err(Error::Fenced)
));
assert!(matches!(a.collect(None, 1), Err(Error::Fenced)));
a.purge().unwrap();
a.purge().unwrap();
assert!(a.list(ar.id, None, 10).unwrap().nodes.is_empty());
assert_eq!(std::fs::read_dir(&blobs).unwrap().count(), 0);
assert_eq!(read(&b, &bf), b"b");
assert_eq!(a.root().unwrap().workspace_id, "a");
assert_eq!(b.root().unwrap().workspace_id, "b");
}
#[test]
fn stopped_writer_backup_restores_ids_revisions_receipts_and_sequence_with_blobs() {
let t = tempfile::tempdir().unwrap();
let metadata = t.path().join("metadata");
let blobs = t.path().join("blobs");
let manager = FeatureStorage::new(&metadata);
let scope = manager.workspace("ws").unwrap();
let registered = Drive::register(&scope).unwrap();
let d = Drive::open(&scope, &registered, &blobs).unwrap();
let root = d.root().unwrap();
let f = file(&d, &root, "f", "file", "text/plain", b"one");
let latest = d
.mutate("update", "account", update(&f, b"two"))
.unwrap()
.node;
let deleted = folder(&d, &root, "deleted");
d.mutate(
"delete",
"account",
Mutation::Delete {
id: deleted.id,
expected_revision: deleted.revision,
},
)
.unwrap();
// All writers/GC stopped at this boundary. FeatureStorage's DB snapshot alone
// is not a Drive backup; copy the same stopped generation's blobs as well.
drop(d);
let snapshot = t.path().join("snapshot");
scope.backup(&snapshot).unwrap();
manager.shutdown().unwrap();
let restored_blobs = t.path().join("restored/blobs");
std::fs::create_dir_all(&restored_blobs).unwrap();
for file in std::fs::read_dir(&blobs).unwrap() {
let file = file.unwrap();
std::fs::copy(file.path(), restored_blobs.join(file.file_name())).unwrap();
}
let restored = FeatureStorage::new(t.path().join("restored/metadata"));
let scope = restored.workspace("ws").unwrap();
scope.restore(&snapshot).unwrap();
let registered = Drive::register(&scope).unwrap();
let d = Drive::open(&scope, &registered, &restored_blobs).unwrap();
assert_eq!(d.metadata(latest.id).unwrap(), latest);
assert_eq!(read(&d, &latest), b"two");
assert!(matches!(
d.request_status("update").unwrap(),
RequestStatus::Committed { .. }
));
let new = folder(&d, &root, "new");
assert!(new.id > deleted.id);
}
#[test]
fn external_ids_and_revisions_keep_full_integer_precision() {
let max = "9223372036854775807";
let id: workspace_drive::NodeId = serde_json::from_str(&format!("\"{max}\"")).unwrap();
let revision: workspace_drive::Revision = serde_json::from_str(&format!("\"{max}\"")).unwrap();
assert_eq!(serde_json::to_string(&id).unwrap(), format!("\"{max}\""));
assert_eq!(
serde_json::to_string(&revision).unwrap(),
format!("\"{max}\"")
);
for bad in ["0", "-1", "01", "9223372036854775808"] {
assert!(serde_json::from_str::<workspace_drive::NodeId>(&format!("\"{bad}\"")).is_err());
}
assert!(serde_json::from_str::<workspace_drive::NodeId>("9007199254740993").is_err());
}
#[test]
fn text_read_is_bounded_utf8_and_referenced_size_corruption_is_diagnosed() {
let t = tempfile::tempdir().unwrap();
let blobs = t.path().join("blobs");
let d = open(&t.path().join("db"), &blobs, "ws");
let root = d.root().unwrap();
let f = file(&d, &root, "f", "f", "text/plain", "日本語".as_bytes());
assert_eq!(d.read_text(f.id, f.revision, 0, 3).unwrap().text, "日");
assert!(matches!(
d.read_text(f.id, f.revision, 1, 2),
Err(Error::Invalid(_))
));
let path = std::fs::read_dir(&blobs)
.unwrap()
.next()
.unwrap()
.unwrap()
.path();
std::fs::write(&path, b"corrupt length").unwrap();
assert!(matches!(
d.read(f.id, f.revision, 0, 1),
Err(Error::Invalid(_))
));
assert!(matches!(
d.search("not-the-name", true, None, 10),
Err(Error::Invalid(_))
));
// Missing zero-sized references are not silently successful empty reads.
let empty = file(&d, &root, "empty", "empty", "text/plain", b"");
for e in std::fs::read_dir(&blobs).unwrap() {
let e = e.unwrap();
if e.path() != path {
std::fs::remove_file(e.path()).unwrap();
}
}
assert!(matches!(
d.read(empty.id, empty.revision, 0, 1),
Err(Error::Blob(_))
));
}
#[test]
fn simultaneous_same_request_replays_one_create_and_folder_deletion_is_empty_only() {
let t = tempfile::tempdir().unwrap();
let db = t.path().join("db");
let blobs = t.path().join("blobs");
let d = open(&db, &blobs, "ws");
let other = open(&db, &blobs, "ws");
let root = d.root().unwrap();
let m = Mutation::CreateFolder {
parent: root.id,
name: "same".into(),
};
let mm = m.clone();
let barrier = Arc::new(Barrier::new(2));
let bb = barrier.clone();
let worker = std::thread::spawn(move || {
bb.wait();
other.mutate("same-request", "account", mm).unwrap()
});
barrier.wait();
let one = d.mutate("same-request", "account", m).unwrap();
let two = worker.join().unwrap();
assert_eq!(one, two);
assert_eq!(d.list(root.id, None, 10).unwrap().nodes.len(), 1);
let child = folder(&d, &one.node, "child");
assert!(matches!(
d.mutate(
"nonempty",
"account",
Mutation::Delete {
id: one.node.id,
expected_revision: one.node.revision
}
),
Err(Error::Conflict)
));
assert_eq!(d.metadata(child.id).unwrap(), child);
assert!(matches!(
d.mutate(
"root-delete",
"account",
Mutation::Delete {
id: root.id,
expected_revision: root.revision
}
),
Err(Error::Invalid(_))
));
let changed = d
.mutate(
"change",
"account",
Mutation::Relocate {
id: child.id,
expected_revision: child.revision,
parent: root.id,
name: "child".into(),
},
)
.unwrap()
.node;
assert!(matches!(
d.mutate(
"stale-delete",
"account",
Mutation::Delete {
id: child.id,
expected_revision: child.revision
}
),
Err(Error::Conflict)
));
assert_eq!(d.metadata(changed.id).unwrap(), changed);
}