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:
@@ -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
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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, ®, &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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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, ®istered, 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, ®istered, &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, ®istered, &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);
|
||||
}
|
||||
Reference in New Issue
Block a user