refactor: bind checkout target lifetimes to Object observations

This commit is contained in:
2026-10-20 02:01:22 +09:00
parent bfb51b7ca9
commit 434ce34da7
4 changed files with 315 additions and 46 deletions
+35 -14
View File
@@ -116,13 +116,21 @@ impl Provider {
}
Some((decode_identity(slug)?, WorkdirPath::new(tail).ok()?))
}
fn wrap_validator(&self, alias: &str, generation: u64, workdir: &str, raw: &[u8]) -> Vec<u8> {
fn wrap_validator(
&self,
alias: &str,
generation: u64,
workdir: &str,
target: &WorkdirPath,
raw: &[u8],
) -> Vec<u8> {
let mut hash = Sha256::new();
for value in [
self.incarnation.as_bytes(),
alias.as_bytes(),
&generation.to_be_bytes(),
workdir.as_bytes(),
target.as_str().as_bytes(),
raw,
] {
hash.update(value.len().to_be_bytes());
@@ -287,7 +295,8 @@ impl Provider {
"directory"
};
let primary = *contracts.first()?;
let validator = self.wrap_validator(alias, generation, workdir, &observation.validator);
let validator =
self.wrap_validator(alias, generation, workdir, &target, &observation.validator);
let handler = Arc::new(FileHandler {
provider: self.clone(),
alias: alias.into(),
@@ -511,8 +520,13 @@ impl FileHandler {
if let Some(raw) = result.validator {
Ok(WipOperationOutput::native_with_validator(
value,
self.provider
.wrap_validator(&self.alias, self.generation, &self.workdir, &raw),
self.provider.wrap_validator(
&self.alias,
self.generation,
&self.workdir,
&self.target,
&raw,
),
))
} else {
Ok(WipOperationOutput::native(value))
@@ -533,6 +547,16 @@ impl WipOperationHandler for FileHandler {
if !self.provider.allowed("Read",&json!({"target_workdir":self.alias,"file_path":self.target.as_str(),"offset":offset,"limit":limit})) {
return Err(protocol(ProtocolErrorCode::PermissionDenied,"text read denied"));
}
let _permit =
tokio::time::timeout(self.provider.deadline, self.provider.permits.acquire())
.await
.map_err(|_| {
protocol(
ProtocolErrorCode::ResourceLimitExceeded,
"text read capacity deadline",
)
})?
.map_err(|_| protocol(ProtocolErrorCode::Internal, "text read unavailable"))?;
let result = tokio::time::timeout(
self.provider.deadline,
tools::checkout::read_checkout_text(
@@ -569,8 +593,13 @@ impl WipOperationHandler for FileHandler {
return Ok(match validator {
Some(raw) => WipOperationOutput::native_with_validator(
value,
self.provider
.wrap_validator(&self.alias, self.generation, &self.workdir, &raw),
self.provider.wrap_validator(
&self.alias,
self.generation,
&self.workdir,
&self.target,
&raw,
),
),
None => WipOperationOutput::native(value),
});
@@ -723,14 +752,6 @@ fn join_under(parent: &WorkdirPath, relative: &str) -> Result<String, WipOperati
format!("{}/{}", parent.as_str(), child.as_str())
})
}
fn parameter(name: &str, required: bool, r#type: TypeExpr) -> ParameterDeclaration {
ParameterDeclaration {
name: name.into(),
required,
documentation: None,
r#type,
}
}
fn operation(
name: &str,
summary: &str,
+4 -2
View File
@@ -358,7 +358,8 @@ async fn observe_interface(runtime: &WipRuntime, path: &str) -> wip_protocol::In
}
async fn native_call(runtime: &WipRuntime, path: &str, operation: &str, args: Json) -> Value {
let interface = observe_interface(runtime, path).await;
observe_interface(runtime, path).await;
let interface = super::checkout_wip_tests::contract(operation).reference();
runtime
.call(
path.into(),
@@ -620,7 +621,8 @@ async fn committed_write_unknown(fault: WriteResponseFault) {
let (runtime, router) = native_runtime(remote);
let file = "/checkouts/main/src/deep/a.txt";
native_call(&runtime, file, "read", json!({})).await;
let interface = observe_interface(&runtime, file).await;
observe_interface(&runtime, file).await;
let interface = super::checkout_wip_tests::contract("write").reference();
*fixture.state.write_fault.lock().unwrap() = Some(fault);
let error = runtime
.call(
+6 -1
View File
@@ -683,7 +683,12 @@ async fn committed_checkout_mutation_losing_connection_is_unknown_not_rejected_o
let calls = Arc::new(AtomicUsize::new(0));
let route = object_path("main", &file.target);
let mut projection = p.node(&route).await.unwrap().unwrap();
let interface = projection.interface.clone();
let interface = match operation {
"write" => crate::file_operation::Contract::Write,
"edit" => crate::file_operation::Contract::Edit,
_ => crate::file_operation::Contract::CreateFile,
}
.reference();
projection.handler = Arc::new(DetachBeforePublication {
file,
calls: calls.clone(),
+268 -27
View File
@@ -41,13 +41,28 @@ async fn interface(r: &WipRuntime, path: &str) -> wip_protocol::InterfaceReferen
.unwrap();
p.interface
}
pub(super) fn contract(operation: &str) -> crate::file_operation::Contract {
use crate::file_operation::Contract as C;
match operation {
"read" => C::ReadLines,
"search" => C::SearchText,
"edit" => C::Edit,
"write" => C::Write,
"list" => C::List,
"glob" => C::Glob,
"grep" => C::Grep,
"create_file" => C::CreateFile,
_ => panic!("unknown file operation {operation}"),
}
}
async fn call(
r: &WipRuntime,
path: &str,
operation: &str,
args: Json,
) -> Result<ToolOutput, ToolError> {
let i = interface(r, path).await;
interface(r, path).await;
let i = contract(operation).reference();
r.call(path.into(), i, operation.into(), args, Default::default())
.await
}
@@ -336,12 +351,26 @@ async fn checkout_wip_detach_alias_reuse_and_restore_fence_old_references() {
let old = r.host.projection_live(path).await.unwrap().unwrap();
router.detach(&alias).await.unwrap();
assert!(r.host.projection_live(path).await.unwrap().is_none());
assert!(r.host.fetch_interface_live(&old.interface).await.is_err());
assert_eq!(
r.host
.fetch_interface_live(&old.interface)
.await
.unwrap()
.validator,
old.interface_validator
);
router.attach(alias, session(&b, false)).unwrap();
let new = r.host.projection_live(path).await.unwrap().unwrap();
assert_ne!(old.interface, new.interface);
assert_eq!(old.interface, new.interface);
assert_ne!(old.object.validator, new.object.validator);
assert!(r.host.fetch_interface_live(&old.interface).await.is_err());
assert_eq!(
r.host
.fetch_interface_live(&old.interface)
.await
.unwrap()
.validator,
old.interface_validator
);
assert!(
call(
&r,
@@ -354,14 +383,16 @@ async fn checkout_wip_detach_alias_reuse_and_restore_fence_old_references() {
);
let restored = runtime(router, None);
let next = restored.host.projection_live(path).await.unwrap().unwrap();
assert_ne!(new.interface, next.interface);
assert_eq!(new.interface, next.interface);
assert_ne!(new.object.validator, next.object.validator);
assert!(
assert_eq!(
restored
.host
.fetch_interface_live(&new.interface)
.await
.is_err()
.unwrap()
.validator,
new.interface_validator
);
assert_eq!(
std::fs::read_to_string(b.path().join("file.txt")).unwrap(),
@@ -497,7 +528,7 @@ async fn checkout_wip_post_operation_validators_support_cached_chains() {
.unwrap();
let r = runtime(router, None);
let path = "/checkouts/main/file.txt";
let i = interface(&r, path).await;
interface(&r, path).await;
// No rediscovery or Interface refresh between any of these operations.
for (op, args) in [
("read", json!({})),
@@ -505,7 +536,13 @@ async fn checkout_wip_post_operation_validators_support_cached_chains() {
("write", json!({"content":"saved\n"})),
("read", json!({})),
] {
r.call(path.into(), i.clone(), op.into(), args, Default::default())
r.call(
path.into(),
contract(op).reference(),
op.into(),
args,
Default::default(),
)
.await
.unwrap();
}
@@ -514,7 +551,8 @@ async fn checkout_wip_post_operation_validators_support_cached_chains() {
"saved\n"
);
let parent = "/checkouts/main";
let i = interface(&r, parent).await;
interface(&r, parent).await;
let i = contract("create_file").reference();
for name in ["one.txt", "nested/two.txt"] {
r.call(
parent.into(),
@@ -617,11 +655,12 @@ async fn checkout_wip_scoped_child_search_and_revoke_cannot_expose_siblings() {
.is_none()
);
let file = "/checkouts/child/a.txt";
let i = interface(&child, file).await;
interface(&child, file).await;
let i = contract("write").reference();
child
.call(
file.into(),
i.clone(),
contract("read").reference(),
"read".into(),
json!({}),
Default::default(),
@@ -686,7 +725,21 @@ pub(super) async fn model_invoke(
operation: &str,
arguments: Json,
) -> Result<ToolOutput, ToolError> {
model_operation(r, "Invoke", json!({"path":inspected["path"], "interface":inspected["interfaces"][0]["reference"], "operation":operation, "arguments":arguments})).await
let reference = inspected["interfaces"]
.as_array()
.unwrap()
.iter()
.map(|item| &item["reference"])
.find(|r| {
Some(wip_protocol::InterfaceReference {
scope: r["scope"].as_str().unwrap().into(),
name: r["name"].as_str().unwrap().into(),
})
.and_then(|r| crate::file_operation::Contract::from_reference(&r))
.is_some_and(|c| c.operation() == operation)
})
.unwrap_or(&inspected["interfaces"][0]["reference"]);
model_operation(r, "Invoke", json!({"path":inspected["path"], "interface":reference, "operation":operation, "arguments":arguments})).await
}
#[tokio::test]
@@ -752,10 +805,7 @@ async fn checkout_model_list_glob_grep_entries_are_lazy_and_never_become_tree_ed
let r = Arc::new(runtime(router, None));
let entrance = model_inspect(r.clone(), "/checkouts/main").await;
assert_eq!(entrance["path"], "/checkouts/main");
assert_eq!(
entrance["interfaces"][0]["reference"]["scope"],
"/checkouts/main"
);
assert_eq!(entrance["interfaces"][0]["reference"]["scope"], "/");
let projection = r
.host
.projection_live("/checkouts/main")
@@ -829,10 +879,7 @@ async fn checkout_model_list_glob_grep_entries_are_lazy_and_never_become_tree_ed
json!([])
);
let deep = model_inspect(r.clone(), "/checkouts/main/nested/deep").await;
assert_eq!(
deep["interfaces"][0]["reference"]["scope"],
"/checkouts/main"
);
assert_eq!(deep["interfaces"][0]["reference"]["scope"], "/");
for (operation, args) in [
("list", json!({})),
("glob", json!({"pattern":"*.txt"})),
@@ -926,7 +973,7 @@ async fn checkout_list_permission_cannot_be_bypassed_with_glob_or_command_identi
}
#[tokio::test]
async fn checkout_ancestor_scope_rejects_siblings_prefixes_refs_and_old_connection_before_read() {
async fn common_scope_rejects_wrong_namespace_scope_ref_and_old_target_authority() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("a.txt"), "same\n").unwrap();
let router = Arc::new(WorkdirSessionRouter::new());
@@ -943,8 +990,8 @@ async fn checkout_ancestor_scope_rejects_siblings_prefixes_refs_and_old_connecti
let p = r.host.projection_live(path).await.unwrap().unwrap();
let fetched = r.host.fetch_interface_live(&p.interface).await.unwrap();
assert!(
fetched.scope_ref.is_none(),
"ref-less publication is legitimate"
fetched.scope_ref.is_some(),
"common namespace uses the Host root scope"
);
// Ordinary content changes do not terminate the entrance's Interface lifetime.
std::fs::write(dir.path().join("new.txt"), "fixture").unwrap();
@@ -959,12 +1006,12 @@ async fn checkout_ancestor_scope_rejects_siblings_prefixes_refs_and_old_connecti
let request = |reference, scope_ref| CallOperationRequest {
target: wip_protocol::Target {
path: path.into(),
validator: None,
validator: p.object.validator.clone(),
},
interface: wip_protocol::InterfaceTarget {
reference,
scope_ref,
validator: None,
validator: fetched.validator.clone(),
},
operation: "read".into(),
arguments: BTreeMap::new(),
@@ -996,6 +1043,200 @@ async fn checkout_ancestor_scope_rejects_siblings_prefixes_refs_and_old_connecti
router.attach(main, session(&dir, true)).unwrap();
let forged = request(p.interface.clone(), None);
assert!(
matches!(r.host.call(forged, WipCallContext { execution:Default::default(),security_context:"fixture".into() }).await, Err(WipOperationError::Protocol(e)) if e.code == ProtocolErrorCode::InterfaceMismatch)
matches!(r.host.call(forged, WipCallContext { execution:Default::default(),security_context:"fixture".into() }).await, Err(WipOperationError::Protocol(e)) if e.code == ProtocolErrorCode::ValidatorMismatch)
);
}
fn observed_common_request(
path: &str,
validator: Option<Vec<u8>>,
fetched: &FetchInterfaceResponse,
operation: &str,
) -> CallOperationRequest {
CallOperationRequest {
target: wip_protocol::Target {
path: path.into(),
validator,
},
interface: wip_protocol::InterfaceTarget {
reference: fetched.interface.clone(),
scope_ref: fetched.scope_ref.clone(),
validator: fetched.validator.clone(),
},
operation: operation.into(),
arguments: BTreeMap::new(),
}
}
fn file_context() -> WipCallContext {
WipCallContext {
execution: Default::default(),
security_context: "file-contract-test".into(),
}
}
#[tokio::test]
async fn shared_contract_survives_one_attachment_update_but_old_object_authority_never_transfers() {
let left = TempDir::new().unwrap();
let right = TempDir::new().unwrap();
for root in [&left, &right] {
std::fs::write(root.path().join("a.txt"), "same\n").unwrap();
}
std::fs::write(left.path().join("b.txt"), "same\n").unwrap();
let router = Arc::new(WorkdirSessionRouter::new());
let alias = WorkdirAttachmentAlias::new("left").unwrap();
router.attach(alias.clone(), session(&left, false)).unwrap();
router
.attach(
WorkdirAttachmentAlias::new("right").unwrap(),
session(&right, true),
)
.unwrap();
let r = runtime(router.clone(), None);
let path = "/checkouts/left/a.txt";
let other = "/checkouts/right/a.txt";
let first = r.host.projection_live(path).await.unwrap().unwrap();
let same_bytes = r
.host
.projection_live("/checkouts/left/b.txt")
.await
.unwrap()
.unwrap();
let independent = r.host.projection_live(other).await.unwrap().unwrap();
assert_eq!(first.interface, independent.interface);
assert_eq!(first.descriptor, independent.descriptor);
assert_ne!(first.object.validator, same_bytes.object.validator);
let definition = r.host.fetch_interface_live(&first.interface).await.unwrap();
let request =
observed_common_request(path, first.object.validator.clone(), &definition, "read");
assert!(r.host.call(request.clone(), file_context()).await.is_ok());
// Replace the same path with a different inode containing exactly the same bytes.
std::fs::write(left.path().join("replacement"), "same\n").unwrap();
std::fs::rename(left.path().join("replacement"), left.path().join("a.txt")).unwrap();
assert!(
matches!(r.host.call(request.clone(),file_context()).await,Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::ValidatorMismatch)
);
assert_eq!(
r.host
.fetch_interface_live(&first.interface)
.await
.unwrap()
.validator,
definition.validator
);
assert!(
r.host
.call(
observed_common_request(
other,
independent.object.validator.clone(),
&definition,
"read"
),
file_context()
)
.await
.is_ok()
);
call(&r, path, "read", json!({})).await.unwrap();
router.detach(&alias).await.unwrap();
router.attach(alias, session(&left, false)).unwrap();
assert!(
matches!(r.host.call(request,file_context()).await,Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::ValidatorMismatch)
);
assert!(
r.host
.call(
observed_common_request(other, independent.object.validator, &definition, "read"),
file_context()
)
.await
.is_ok()
);
call(&r, path, "read", json!({})).await.unwrap();
let before_restore = r.host.projection_live(path).await.unwrap().unwrap();
let restored = runtime(router, None);
let restored_definition = restored
.host
.fetch_interface_live(&first.interface)
.await
.unwrap();
assert_eq!(definition.descriptor, restored_definition.descriptor);
assert_eq!(definition.validator, restored_definition.validator);
assert!(
matches!(restored.host.call(observed_common_request(path,before_restore.object.validator,&restored_definition,"read"),file_context()).await,Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::ValidatorMismatch)
);
call(&restored, path, "read", json!({})).await.unwrap();
}
#[tokio::test]
async fn shared_readonly_contract_rejects_wrong_membership_operation_and_interface_validator() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("a.txt"), "雪.*\r\nother\n").unwrap();
let router = Arc::new(WorkdirSessionRouter::new());
router
.attach(
WorkdirAttachmentAlias::new("main").unwrap(),
session(&dir, true),
)
.unwrap();
let r = Arc::new(runtime(router, None));
let path = "/checkouts/main/a.txt";
let observed = model_inspect(r.clone(), path).await;
let read = json_content(
model_invoke(r.clone(), &observed, "read", json!({"limit":1}))
.await
.unwrap(),
);
assert_eq!(
read,
json!({"content":" 1\t雪.*\n","next_offset":1,"truncated":true})
);
let searched = json_content(
model_invoke(
r.clone(),
&observed,
"search",
json!({"pattern":".*","limit":1}),
)
.await
.unwrap(),
);
assert_eq!(
searched,
json!({"matches":[{"line":1,"text":"雪.*"}],"next_offset":1,"truncated":true})
);
let p = r.host.projection_live(path).await.unwrap().unwrap();
let read = r
.host
.fetch_interface_live(&contract("read").reference())
.await
.unwrap();
let write = r
.host
.fetch_interface_live(&contract("write").reference())
.await
.unwrap();
assert!(
matches!(r.host.call(observed_common_request(path,p.object.validator.clone(),&write,"write"),file_context()).await,Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::InterfaceMismatch)
);
assert!(
matches!(r.host.call(observed_common_request(path,p.object.validator.clone(),&read,"missing"),file_context()).await,Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::OperationNotFound)
);
let mut stale = observed_common_request(path, p.object.validator.clone(), &read, "read");
stale.interface.validator = Some(vec![0]);
assert!(
matches!(r.host.call(stale,file_context()).await,Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::InterfaceValidatorMismatch)
);
let mut legacy = observed_common_request(path, p.object.validator, &read, "read");
legacy.interface.reference = InterfaceReference {
scope: "/checkouts/main".into(),
name: "yoi.checkout/old/file/v1#target/old".into(),
};
assert!(
matches!(r.host.call(legacy,file_context()).await,Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::InterfaceMismatch)
);
assert_eq!(
std::fs::read_to_string(dir.path().join("a.txt")).unwrap(),
"雪.*\r\nother\n"
);
}