feat: dynamic-scopeの実装
This commit is contained in:
@@ -13,17 +13,23 @@ use std::io::Write as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use manifest::Scope;
|
||||
use manifest::{Scope, SharedScope};
|
||||
|
||||
use crate::error::ToolsError;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ScopedFsInner {
|
||||
scope: Scope,
|
||||
scope: SharedScope,
|
||||
pwd: PathBuf,
|
||||
}
|
||||
|
||||
/// Scope-aware filesystem handle. Clone-cheap (`Arc` inside).
|
||||
///
|
||||
/// The wrapped [`SharedScope`] is shared with every clone of this
|
||||
/// `ScopedFs` and with whoever else holds the same `SharedScope`
|
||||
/// handle (typically the owning Pod). Mutations to that `SharedScope`
|
||||
/// propagate atomically; the next permission check inside any
|
||||
/// `ScopedFs` reads the new view.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScopedFs {
|
||||
inner: Arc<ScopedFsInner>,
|
||||
@@ -37,15 +43,34 @@ pub struct WriteOutcome {
|
||||
}
|
||||
|
||||
impl ScopedFs {
|
||||
/// Create a new [`ScopedFs`] wrapping the given [`Scope`] and pwd.
|
||||
/// Create a new [`ScopedFs`] wrapping `scope` and `pwd` in a fresh
|
||||
/// [`SharedScope`]. Use [`ScopedFs::with_shared_scope`] when you
|
||||
/// need the resulting `ScopedFs` to share scope state with another
|
||||
/// holder of the `SharedScope` (typically the Pod).
|
||||
pub fn new(scope: Scope, pwd: PathBuf) -> Self {
|
||||
Self::with_shared_scope(SharedScope::new(scope), pwd)
|
||||
}
|
||||
|
||||
/// Build a [`ScopedFs`] over an existing [`SharedScope`]. The
|
||||
/// resulting handle and any future updates the caller pushes to
|
||||
/// `scope` are observed by every clone of this `ScopedFs`.
|
||||
pub fn with_shared_scope(scope: SharedScope, pwd: PathBuf) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(ScopedFsInner { scope, pwd }),
|
||||
}
|
||||
}
|
||||
|
||||
/// The underlying [`Scope`].
|
||||
pub fn scope(&self) -> &Scope {
|
||||
/// Snapshot the current scope. Cheap; the returned `Arc<Scope>` is
|
||||
/// a coherent point-in-time view that subsequent mutations do not
|
||||
/// affect.
|
||||
pub fn scope(&self) -> Arc<Scope> {
|
||||
self.inner.scope.snapshot()
|
||||
}
|
||||
|
||||
/// Shared scope handle backing this `ScopedFs`. Cloning it lets a
|
||||
/// caller (usually the Pod) hold the same view and push updates
|
||||
/// that are immediately reflected in subsequent permission checks.
|
||||
pub fn shared_scope(&self) -> &SharedScope {
|
||||
&self.inner.scope
|
||||
}
|
||||
|
||||
@@ -67,7 +92,7 @@ impl ScopedFs {
|
||||
if !path.is_absolute() {
|
||||
return Err(ToolsError::RelativePath(path.to_path_buf()));
|
||||
}
|
||||
if !self.inner.scope.is_readable(path) {
|
||||
if !self.inner.scope.load().is_readable(path) {
|
||||
return Err(ToolsError::OutOfScope(path.to_path_buf()));
|
||||
}
|
||||
let meta = std::fs::metadata(path).map_err(|e| match e.kind() {
|
||||
@@ -100,13 +125,15 @@ impl ScopedFs {
|
||||
if !path.is_absolute() {
|
||||
return Err(ToolsError::RelativePath(path.to_path_buf()));
|
||||
}
|
||||
if !self.inner.scope.is_writable(path) {
|
||||
return Err(if self.inner.scope.is_readable(path) {
|
||||
let scope = self.inner.scope.load();
|
||||
if !scope.is_writable(path) {
|
||||
return Err(if scope.is_readable(path) {
|
||||
ToolsError::ReadOnly(path.to_path_buf())
|
||||
} else {
|
||||
ToolsError::OutOfScope(path.to_path_buf())
|
||||
});
|
||||
}
|
||||
drop(scope);
|
||||
|
||||
// Reject existing directory targets.
|
||||
match std::fs::metadata(path) {
|
||||
@@ -299,4 +326,118 @@ mod tests {
|
||||
let err = fs.write(dir.path(), b"x").unwrap_err();
|
||||
assert!(matches!(err, ToolsError::IsDirectory(_)));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Dynamic scope: SharedScope mutations propagate into ScopedFs decisions
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn add_allow_rule_through_shared_scope_grows_readable_set() {
|
||||
use manifest::SharedScope;
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
let extra = TempDir::new().unwrap();
|
||||
let extra_file = extra.path().join("x.txt");
|
||||
fs::write(&extra_file, b"hi").unwrap();
|
||||
|
||||
let shared = SharedScope::new(Scope::writable(dir.path()).unwrap());
|
||||
let fs = ScopedFs::with_shared_scope(shared.clone(), dir.path().to_path_buf());
|
||||
|
||||
// Before: extra is out of scope.
|
||||
let err = fs.read_bytes(&extra_file).unwrap_err();
|
||||
assert!(matches!(err, ToolsError::OutOfScope(_)));
|
||||
|
||||
// Push an allow(Read) rule.
|
||||
shared
|
||||
.update(|cur| {
|
||||
cur.with_added_allow_rules([ScopeRule {
|
||||
target: extra.path().to_path_buf(),
|
||||
permission: Permission::Read,
|
||||
recursive: true,
|
||||
}])
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// After: read goes through.
|
||||
assert_eq!(fs.read_bytes(&extra_file).unwrap(), b"hi");
|
||||
// But write still fails — allow only granted Read.
|
||||
let err = fs.write(&extra.path().join("y.txt"), b"x").unwrap_err();
|
||||
assert!(
|
||||
matches!(err, ToolsError::ReadOnly(_)),
|
||||
"expected ReadOnly, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revoke_write_through_shared_scope_blocks_subsequent_writes() {
|
||||
use manifest::SharedScope;
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
let sub = dir.path().join("sub");
|
||||
fs::create_dir(&sub).unwrap();
|
||||
let target = sub.join("a.txt");
|
||||
|
||||
let shared = SharedScope::new(Scope::writable(dir.path()).unwrap());
|
||||
let fs = ScopedFs::with_shared_scope(shared.clone(), dir.path().to_path_buf());
|
||||
|
||||
// Write succeeds initially.
|
||||
fs.write(&target, b"first").unwrap();
|
||||
|
||||
// Revoke Write on `sub` (push a deny(Write) rule).
|
||||
shared
|
||||
.update(|cur| {
|
||||
cur.with_added_deny_rules([ScopeRule {
|
||||
target: sub.clone(),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
}])
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// Subsequent write fails with ReadOnly — Read is preserved.
|
||||
let err = fs.write(&target, b"second").unwrap_err();
|
||||
assert!(
|
||||
matches!(err, ToolsError::ReadOnly(_)),
|
||||
"expected ReadOnly after revoke, got {err:?}"
|
||||
);
|
||||
// Read still works.
|
||||
assert_eq!(fs.read_bytes(&target).unwrap(), b"first");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_scope_changes_propagate_across_clones() {
|
||||
use manifest::SharedScope;
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
let target = dir.path().join("a.txt");
|
||||
|
||||
let shared = SharedScope::new(Scope::writable(dir.path()).unwrap());
|
||||
let fs1 = ScopedFs::with_shared_scope(shared.clone(), dir.path().to_path_buf());
|
||||
let fs2 = fs1.clone();
|
||||
|
||||
// fs1 writes; both clones see the file.
|
||||
fs1.write(&target, b"hi").unwrap();
|
||||
assert_eq!(fs2.read_bytes(&target).unwrap(), b"hi");
|
||||
|
||||
// Revoke write through the original handle.
|
||||
shared
|
||||
.update(|cur| {
|
||||
cur.with_added_deny_rules([ScopeRule {
|
||||
target: dir.path().to_path_buf(),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
}])
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// Both clones reject writes now — they share the same SharedScope.
|
||||
assert!(matches!(
|
||||
fs1.write(&target, b"x").unwrap_err(),
|
||||
ToolsError::ReadOnly(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
fs2.write(&target, b"x").unwrap_err(),
|
||||
ToolsError::ReadOnly(_)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user