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:
2026-10-06 20:21:15 +09:00
parent c6528c2887
commit bf5fa54f44
5 changed files with 699 additions and 120 deletions
+184 -14
View File
@@ -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