From 434ce34da79bf51ac02e9bac60d2cbbd5662f9c1 Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 20 Oct 2026 02:01:22 +0900 Subject: [PATCH] refactor: bind checkout target lifetimes to Object observations --- crates/worker/src/checkout.rs | 49 ++-- crates/worker/src/checkout_http_tests.rs | 6 +- crates/worker/src/checkout_race_tests.rs | 7 +- crates/worker/src/checkout_wip_tests.rs | 299 ++++++++++++++++++++--- 4 files changed, 315 insertions(+), 46 deletions(-) diff --git a/crates/worker/src/checkout.rs b/crates/worker/src/checkout.rs index 6bf7e797e..4071d38e6 100644 --- a/crates/worker/src/checkout.rs +++ b/crates/worker/src/checkout.rs @@ -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 { + fn wrap_validator( + &self, + alias: &str, + generation: u64, + workdir: &str, + target: &WorkdirPath, + raw: &[u8], + ) -> Vec { 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 ParameterDeclaration { - ParameterDeclaration { - name: name.into(), - required, - documentation: None, - r#type, - } -} fn operation( name: &str, summary: &str, diff --git a/crates/worker/src/checkout_http_tests.rs b/crates/worker/src/checkout_http_tests.rs index f7df26e26..b3d85bfc6 100644 --- a/crates/worker/src/checkout_http_tests.rs +++ b/crates/worker/src/checkout_http_tests.rs @@ -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( diff --git a/crates/worker/src/checkout_race_tests.rs b/crates/worker/src/checkout_race_tests.rs index da58fc64c..3e585db69 100644 --- a/crates/worker/src/checkout_race_tests.rs +++ b/crates/worker/src/checkout_race_tests.rs @@ -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(), diff --git a/crates/worker/src/checkout_wip_tests.rs b/crates/worker/src/checkout_wip_tests.rs index c4ca3698f..dd3da478c 100644 --- a/crates/worker/src/checkout_wip_tests.rs +++ b/crates/worker/src/checkout_wip_tests.rs @@ -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 { - 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,16 +536,23 @@ 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()) - .await - .unwrap(); + r.call( + path.into(), + contract(op).reference(), + op.into(), + args, + Default::default(), + ) + .await + .unwrap(); } assert_eq!( std::fs::read_to_string(dir.path().join("file.txt")).unwrap(), "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 { - 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>, + 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" ); }