fix: validate resolved ancestry inside atomic projection dispatch
Resolve one connected lineage and retain its root-to-parent capabilities. Backends validate ancestor identity/membership and refresh all policy metadata inside the same boundary as target validators and typed dispatch; hidden/replaced ancestors reject as NotFound without invocation or effects. Preserve pinned leaf and configured-root capabilities. Covers MR ANCESTOR_VISIBILITY_TOCTOU with HTTP memory and Linux replacement/budget/guard regressions; all T-19 workspace acceptance commands pass (Clippy via Nix shell).
This commit is contained in:
@@ -5,7 +5,7 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use wip_fs_server::{
|
||||
BackendEntry, Composition, DefaultInterfaces, EntryKind, FileRead, FilesystemBackend,
|
||||
HttpAdapter, Projection, ProjectionPolicy, ResourceLimits,
|
||||
HttpAdapter, Projection, ProjectionPolicy, ResolvedEntry, ResourceLimits,
|
||||
};
|
||||
use wip_host::{
|
||||
CancellationToken, EffectGuarantee, ExecutionContext, HostError, InterfaceRef,
|
||||
@@ -77,15 +77,20 @@ struct Entry {
|
||||
path: String,
|
||||
node: Arc<Node>,
|
||||
validator: Validator,
|
||||
hidden: bool,
|
||||
}
|
||||
impl Entry {
|
||||
fn snapshot(path: String, node: Arc<Node>) -> Self {
|
||||
let revision = node.data.lock().unwrap().revision;
|
||||
let data = node.data.lock().unwrap();
|
||||
let revision = data.revision;
|
||||
let hidden = data.hidden;
|
||||
drop(data);
|
||||
let validator = Validator::new(format!("{}:{revision}", node.id).into_bytes());
|
||||
Self {
|
||||
path,
|
||||
node,
|
||||
validator,
|
||||
hidden,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -118,6 +123,8 @@ struct Seen {
|
||||
struct Controls {
|
||||
bump_before_execute: bool,
|
||||
hide_before_execute: bool,
|
||||
hide_ancestor: Option<String>,
|
||||
replace_ancestor: Option<String>,
|
||||
replace_mapping: bool,
|
||||
rollback: bool,
|
||||
cancel_on_execute: bool,
|
||||
@@ -168,7 +175,11 @@ fn check_context(context: &ExecutionContext) -> Result<(), HostError> {
|
||||
}
|
||||
impl FilesystemBackend for MemoryBackend {
|
||||
type Entry = Entry;
|
||||
fn resolve(&self, context: &ExecutionContext, path: &str) -> ProviderResult<Entry> {
|
||||
fn resolve(
|
||||
&self,
|
||||
context: &ExecutionContext,
|
||||
path: &str,
|
||||
) -> ProviderResult<ResolvedEntry<Entry>> {
|
||||
self.0.record("resolve", path, context);
|
||||
if let Err(error) = check_context(context) {
|
||||
return ProviderResult::Failure(error);
|
||||
@@ -179,11 +190,33 @@ impl FilesystemBackend for MemoryBackend {
|
||||
EffectGuarantee::NotCommitted,
|
||||
));
|
||||
}
|
||||
// Map lookup only: there is no host path to reopen or escape.
|
||||
match self.0.nodes.lock().unwrap().get(path).cloned() {
|
||||
Some(node) => ProviderResult::Found(Entry::snapshot(path.into(), node)),
|
||||
None => ProviderResult::NotFound,
|
||||
// One connected map snapshot, serialized with participating mutations.
|
||||
let _transaction = self.0.transaction.lock().unwrap();
|
||||
let nodes = self.0.nodes.lock().unwrap();
|
||||
let Some(node) = nodes.get(path) else {
|
||||
return ProviderResult::NotFound;
|
||||
};
|
||||
let mut ancestors = Vec::new();
|
||||
if path != "/" {
|
||||
let prefixes = std::iter::once("/").chain(
|
||||
path.match_indices('/')
|
||||
.skip(1)
|
||||
.map(|(index, _)| &path[..index]),
|
||||
);
|
||||
for prefix in prefixes {
|
||||
let Some(parent) = nodes.get(prefix) else {
|
||||
return ProviderResult::NotFound;
|
||||
};
|
||||
if parent.kind != EntryKind::Directory {
|
||||
return ProviderResult::NotFound;
|
||||
}
|
||||
ancestors.push(Entry::snapshot(prefix.into(), parent.clone()));
|
||||
}
|
||||
}
|
||||
ProviderResult::Found(ResolvedEntry::new(
|
||||
ancestors,
|
||||
Entry::snapshot(path.into(), node.clone()),
|
||||
))
|
||||
}
|
||||
fn children(
|
||||
&self,
|
||||
@@ -224,10 +257,11 @@ impl FilesystemBackend for MemoryBackend {
|
||||
fn execute(
|
||||
&self,
|
||||
context: &ExecutionContext,
|
||||
entry: Entry,
|
||||
operation: impl FnOnce(Entry) -> OperationAttempt,
|
||||
resolved: ResolvedEntry<Entry>,
|
||||
operation: impl FnOnce(ResolvedEntry<Entry>) -> OperationAttempt,
|
||||
) -> ProviderResult<OperationAttempt> {
|
||||
let _lock = self.0.transaction.lock().unwrap();
|
||||
let (ancestors, entry) = resolved.into_parts();
|
||||
self.0.record("execute", &entry.path, context);
|
||||
if let Err(error) = check_context(context) {
|
||||
return ProviderResult::Failure(error);
|
||||
@@ -243,6 +277,24 @@ impl FilesystemBackend for MemoryBackend {
|
||||
if controls.hide_before_execute {
|
||||
entry.node.data.lock().unwrap().hidden = true;
|
||||
}
|
||||
if let Some(path) = controls.hide_ancestor.take() {
|
||||
self.0.node(&path).data.lock().unwrap().hidden = true;
|
||||
}
|
||||
if let Some(path) = controls.replace_ancestor.take() {
|
||||
self.0.nodes.lock().unwrap().insert(
|
||||
path,
|
||||
Arc::new(Node {
|
||||
id: 1000,
|
||||
kind: EntryKind::Directory,
|
||||
data: Mutex::new(Data {
|
||||
bytes: b"replacement ancestor".to_vec(),
|
||||
revision: 1,
|
||||
hidden: false,
|
||||
}),
|
||||
calls: Mutex::new(Vec::new()),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if controls.replace_mapping {
|
||||
// The callback must still receive the original capability, not this decoy.
|
||||
self.0.nodes.lock().unwrap().insert(
|
||||
@@ -265,9 +317,25 @@ impl FilesystemBackend for MemoryBackend {
|
||||
}
|
||||
let rollback = controls.rollback;
|
||||
drop(controls);
|
||||
// Verify lineage identity/membership within the same transaction. The
|
||||
// callback cannot see a substituted ancestor, nor a redirected target.
|
||||
let nodes = self.0.nodes.lock().unwrap();
|
||||
for ancestor in &ancestors {
|
||||
if !nodes
|
||||
.get(&ancestor.path)
|
||||
.is_some_and(|node| Arc::ptr_eq(node, &ancestor.node))
|
||||
{
|
||||
return ProviderResult::NotFound;
|
||||
}
|
||||
}
|
||||
drop(nodes);
|
||||
let refreshed_ancestors = ancestors
|
||||
.into_iter()
|
||||
.map(|ancestor| Entry::snapshot(ancestor.path, ancestor.node))
|
||||
.collect();
|
||||
let before = entry.node.data.lock().unwrap().clone();
|
||||
let refreshed = Entry::snapshot(entry.path.clone(), entry.node.clone());
|
||||
let attempt = operation(refreshed);
|
||||
let attempt = operation(ResolvedEntry::new(refreshed_ancestors, refreshed));
|
||||
let attempt = match attempt {
|
||||
OperationAttempt::Started(Ok(mut result)) => {
|
||||
result.validator = Some(
|
||||
@@ -437,8 +505,7 @@ struct Policy {
|
||||
impl ProjectionPolicy<Entry, Object> for Policy {
|
||||
type Principal = Principal;
|
||||
fn object_visible(&self, principal: &Principal, entry: &Entry) -> bool {
|
||||
!entry.node.data.lock().unwrap().hidden
|
||||
&& (!matches!(principal, Principal::Limited) || entry.path != "/secret")
|
||||
!entry.hidden && (!matches!(principal, Principal::Limited) || entry.path != "/secret")
|
||||
}
|
||||
fn interface_visible(&self, principal: &Principal, interface: &InterfaceRef) -> bool {
|
||||
!(matches!(principal, Principal::Limited) && self.hide_read && interface == &self.read)
|
||||
@@ -690,6 +757,109 @@ impl Fixture {
|
||||
(response, decoded)
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn ancestor_visibility_refresh_inside_boundary_prevents_descendant_invocation_and_effects() {
|
||||
for (target, ancestor) in [
|
||||
("/added", "/"),
|
||||
("/secret/nested/data", "/"),
|
||||
("/secret/nested/data", "/secret"),
|
||||
("/secret/nested/data", "/secret/nested"),
|
||||
] {
|
||||
for operation in ["denied", "read", "change"] {
|
||||
let fixture = Fixture::new();
|
||||
let observation = fixture.observe(&Principal::Admin, target, 0);
|
||||
let descriptor = fixture.descriptor(&Principal::Admin, &fixture.custom);
|
||||
let mut request = fixture.request(
|
||||
target,
|
||||
&fixture.custom,
|
||||
operation,
|
||||
observation.object.validator,
|
||||
);
|
||||
if operation == "change" {
|
||||
request
|
||||
.arguments
|
||||
.insert("contents".into(), Value::Bytes(b"must not commit".to_vec()));
|
||||
}
|
||||
let leaf = fixture.shared.node(target);
|
||||
let before = leaf.data.lock().unwrap().clone();
|
||||
fixture.shared.controls.lock().unwrap().hide_ancestor = Some(ancestor.into());
|
||||
let (wire, response) = fixture.http_call(
|
||||
&Principal::Admin,
|
||||
&request,
|
||||
&descriptor,
|
||||
&ExecutionContext::new(),
|
||||
);
|
||||
wire_error(response, ProtocolErrorCode::NotFound);
|
||||
assert!(leaf.calls.lock().unwrap().is_empty());
|
||||
assert_eq!(*leaf.data.lock().unwrap(), before);
|
||||
assert!(!String::from_utf8_lossy(wire.body()).contains(BACKEND_SECRET));
|
||||
let error = fixture
|
||||
.engine
|
||||
.view(&Principal::Admin)
|
||||
.observe(&ExecutionContext::new(), &observe_request(target, 0))
|
||||
.unwrap_err();
|
||||
assert_eq!(error.code, ProtocolErrorCode::NotFound);
|
||||
// The entire target resolves once, not as unrelated path prefixes.
|
||||
let seen = fixture.shared.seen.lock().unwrap();
|
||||
assert!(
|
||||
seen.iter()
|
||||
.filter(|event| event.stage == "resolve")
|
||||
.all(|event| event.path == target)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaced_visible_ancestors_reject_without_redirecting_or_dispatching_the_pinned_target() {
|
||||
for (target, ancestor) in [
|
||||
("/added", "/"),
|
||||
("/secret/nested/data", "/secret"),
|
||||
("/secret/nested/data", "/secret/nested"),
|
||||
] {
|
||||
let fixture = Fixture::new();
|
||||
let observation = fixture.observe(&Principal::Admin, target, 0);
|
||||
let descriptor = fixture.descriptor(&Principal::Admin, &fixture.custom);
|
||||
let mut request = fixture.request(
|
||||
target,
|
||||
&fixture.custom,
|
||||
"change",
|
||||
observation.object.validator,
|
||||
);
|
||||
request
|
||||
.arguments
|
||||
.insert("contents".into(), Value::Bytes(b"must not commit".to_vec()));
|
||||
let leaf = fixture.shared.node(target);
|
||||
let original_ancestor = fixture.shared.node(ancestor);
|
||||
let before = leaf.data.lock().unwrap().clone();
|
||||
fixture.shared.controls.lock().unwrap().replace_ancestor = Some(ancestor.into());
|
||||
let (_, response) = fixture.http_call(
|
||||
&Principal::Admin,
|
||||
&request,
|
||||
&descriptor,
|
||||
&ExecutionContext::new(),
|
||||
);
|
||||
wire_error(response, ProtocolErrorCode::NotFound);
|
||||
assert!(leaf.calls.lock().unwrap().is_empty());
|
||||
assert_eq!(*leaf.data.lock().unwrap(), before);
|
||||
assert!(!Arc::ptr_eq(
|
||||
&original_ancestor,
|
||||
&fixture.shared.node(ancestor)
|
||||
));
|
||||
assert!(
|
||||
fixture
|
||||
.shared
|
||||
.node(ancestor)
|
||||
.calls
|
||||
.lock()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
// A replacement is visible but cannot authorize a previously resolved lineage.
|
||||
assert!(!fixture.shared.node(ancestor).data.lock().unwrap().hidden);
|
||||
}
|
||||
}
|
||||
|
||||
fn observe_request(path: &str, depth: u32) -> ObserveRequest {
|
||||
ObserveRequest {
|
||||
path: path.into(),
|
||||
@@ -868,8 +1038,8 @@ fn principal_views_hide_children_and_guessed_descendants_for_observation_and_cal
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|seen| !seen.path.starts_with("/secret/")),
|
||||
"hidden descendants were resolved"
|
||||
.all(|seen| seen.stage == "resolve"),
|
||||
"hidden lineage was published, enumerated, or dispatched"
|
||||
);
|
||||
assert!(
|
||||
fixture
|
||||
|
||||
Reference in New Issue
Block a user