fix: bind capability changes to Object freshness and update contract consumers

This commit is contained in:
2026-10-20 02:53:21 +09:00
parent 32a409c15d
commit 47b32ef6ab
12 changed files with 711 additions and 20 deletions
+13 -2
View File
@@ -122,6 +122,7 @@ impl Provider {
generation: u64,
workdir: &str,
target: &WorkdirPath,
capabilities: workdir::WorkdirSessionCapabilities,
raw: &[u8],
) -> Vec<u8> {
let mut hash = Sha256::new();
@@ -131,6 +132,7 @@ impl Provider {
&generation.to_be_bytes(),
workdir.as_bytes(),
target.as_str().as_bytes(),
format!("{capabilities:?}").as_bytes(),
raw,
] {
hash.update(value.len().to_be_bytes());
@@ -295,8 +297,14 @@ impl Provider {
"directory"
};
let primary = *contracts.first()?;
let validator =
self.wrap_validator(alias, generation, workdir, &target, &observation.validator);
let validator = self.wrap_validator(
alias,
generation,
workdir,
&target,
observation.capabilities,
&observation.validator,
);
let handler = Arc::new(FileHandler {
provider: self.clone(),
alias: alias.into(),
@@ -525,6 +533,7 @@ impl FileHandler {
self.generation,
&self.workdir,
&self.target,
self.observation.capabilities,
&raw,
),
))
@@ -583,6 +592,7 @@ impl WipOperationHandler for FileHandler {
self.generation,
&self.workdir,
&self.target,
self.observation.capabilities,
&raw,
),
));
@@ -644,6 +654,7 @@ impl WipOperationHandler for FileHandler {
self.generation,
&self.workdir,
&self.target,
self.observation.capabilities,
&raw,
),
),
+41
View File
@@ -800,3 +800,44 @@ async fn remote_checkout_diagnostic_context_keeps_wip_refusal_classification_wit
"refusal was retried"
);
}
#[tokio::test]
async fn remote_checked_byte_read_uses_common_contract_and_keeps_binary_boundaries() {
use base64::Engine as _;
let fixture = HttpFixture::new(WorkdirSessionCapabilities::READ_ONLY).await;
let remote = Arc::new(fixture.open(TOKEN).await.unwrap());
let (runtime, _) = native_runtime(remote);
let path = "/checkouts/main/src/deep/a.txt";
observe_interface(&runtime, path).await;
let output = runtime
.invoke(
path.into(),
crate::file_operation::Contract::ReadBytes.reference(),
"read".into(),
json!({"offset":1,"limit":3}),
Default::default(),
)
.await
.unwrap();
let json: Json = serde_json::from_str(output.content.as_deref().unwrap()).unwrap();
assert_eq!(json["encoding"], "base64");
assert_eq!(json["offset"], 1);
assert_eq!(json["next_offset"], 4);
let source = fixture
.state
.session
.read_bytes(workdir::ReadBytesRequest {
path: WorkdirPath::new("src/deep/a.txt").unwrap(),
offset: 1,
max_bytes: 3,
expected_hash: None,
})
.await
.unwrap();
assert_eq!(
base64::engine::general_purpose::STANDARD
.decode(json["data"].as_str().unwrap())
.unwrap(),
source.bytes
);
}
+296
View File
@@ -1240,3 +1240,299 @@ async fn shared_readonly_contract_rejects_wrong_membership_operation_and_interfa
"雪.*\r\nother\n"
);
}
// The fixture substitutes only a binary source. Real Host/Client/model tools, fixed common
// descriptors, membership, validators and output decoding stay on their production path.
struct ReadonlyBytes {
bytes: Arc<Vec<u8>>,
version: Arc<std::sync::atomic::AtomicU64>,
captured: u64,
}
impl ReadonlyBytes {
fn projection(&self) -> WipProjection {
let contract = crate::file_operation::Contract::ReadBytes;
WipProjection {
route: "/virtual".into(),
capability: "virtual:bytes".into(),
kind: WipProjectionKind::Native,
object: Object {
name: "virtual".into(),
description: None,
interfaces: vec![contract.reference()],
r#ref: None,
validator: Some(self.captured.to_be_bytes().to_vec()),
},
interface: contract.reference(),
descriptor: contract.descriptor(),
interface_validator: Some(contract.validator()),
handler: Arc::new(Self {
bytes: self.bytes.clone(),
version: self.version.clone(),
captured: self.captured,
}),
}
}
}
#[async_trait]
impl WipOperationHandler for ReadonlyBytes {
async fn call(
&self,
op: &str,
args: &BTreeMap<String, Value>,
_: WipCallContext,
) -> Result<WipOperationOutput, WipOperationError> {
if op != "read_bytes" {
return Err(WipOperationError::Protocol(protocol_error(
ProtocolErrorCode::OperationNotFound,
"readonly bytes",
)));
}
if self.captured != self.version.load(Ordering::SeqCst) {
return Err(WipOperationError::Protocol(protocol_error(
ProtocolErrorCode::ValidatorMismatch,
"virtual source replaced",
)));
}
let (offset, limit) = crate::file_operation::byte_range(args)?;
if offset > self.bytes.len() as u64 {
return Err(WipOperationError::Protocol(protocol_error(
ProtocolErrorCode::InvalidArguments,
"past EOF",
)));
}
let end = (offset as usize + limit).min(self.bytes.len());
Ok(WipOperationOutput::native(
crate::file_operation::bytes_result(
args,
&self.bytes[offset as usize..end],
end == self.bytes.len(),
)?,
))
}
}
#[async_trait]
impl WipSubtreeProvider for ReadonlyBytes {
async fn publication(&self, path: &str) -> Result<Option<WipPublication>, ProtocolError> {
Ok((path == "/virtual").then(|| {
let current = Self {
bytes: self.bytes.clone(),
version: self.version.clone(),
captured: self.version.load(Ordering::SeqCst),
};
WipPublication {
projection: current.projection(),
scope: None,
}
}))
}
async fn children(&self, _: &str) -> Result<Vec<String>, ProtocolError> {
Ok(vec![])
}
}
#[tokio::test]
async fn common_bytes_read_long_lines_invalid_utf8_arbitrary_boundaries_and_eof_without_write_authority()
{
use base64::Engine as _;
let mut bytes = "雪".as_bytes().to_vec();
bytes.extend_from_slice(&[0xff, 0, 0xc0]);
bytes.extend(vec![b'x'; 100_000]);
let bytes = Arc::new(bytes);
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("raw"), bytes.as_ref()).unwrap();
let router = Arc::new(WorkdirSessionRouter::new());
router
.attach(
WorkdirAttachmentAlias::new("main").unwrap(),
session(&dir, true),
)
.unwrap();
let source = Arc::new(ReadonlyBytes {
bytes: bytes.clone(),
version: Arc::new(std::sync::atomic::AtomicU64::new(1)),
captured: 1,
});
let mut registry = WipMountRegistry::new();
crate::checkout::mount_checkouts(&mut registry, router, tools::Tracker::new(), None).unwrap();
registry.allocate_namespace("virtual", "virtual").unwrap();
registry.mount(source.projection()).unwrap();
registry
.mount_subtree(WipSubtreeMount {
root: "/virtual".into(),
provider: source.clone(),
})
.unwrap();
let r = Arc::new(WipRuntime::from_mounts(registry, "bytes-test".into()).unwrap());
let virtual_inspect = model_inspect(r.clone(), "/virtual").await;
let reference = virtual_inspect["interfaces"][0]["reference"].clone();
let checkout = model_inspect(r.clone(), "/checkouts/main/raw").await;
assert!(
checkout["interfaces"]
.as_array()
.unwrap()
.iter()
.any(|i| i["reference"] == reference)
);
for path in ["/virtual", "/checkouts/main/raw"] {
for offset in [0, 1, 2, 3, 5, 80_000, bytes.len()] {
let output=json_content(model_operation(r.clone(),"Invoke",json!({"path":path,"interface":reference,"operation":"read","arguments":{"offset":offset,"limit":17}})).await.unwrap());
let end = (offset + 17).min(bytes.len());
assert_eq!(output["encoding"], "base64");
assert_eq!(output["offset"], offset);
assert_eq!(output["next_offset"], end);
assert_eq!(output["eof"], end == bytes.len());
assert_eq!(
base64::engine::general_purpose::STANDARD
.decode(output["data"].as_str().unwrap())
.unwrap(),
bytes[offset..end]
);
}
for arguments in [
json!({"limit":0}),
json!({"limit":32769}),
json!({"offset":-1}),
json!({"offset":9_007_199_254_740_991i64}),
json!({"offset":bytes.len()+1}),
json!({"file_path":"other"}),
] {
assert!(model_operation(r.clone(),"Invoke",json!({"path":path,"interface":reference,"operation":"read","arguments":arguments})).await.is_err());
}
assert!(model_operation(r.clone(),"Invoke",json!({"path":path,"interface":{"scope":"/","name":"yoi.file/write-text/v1"},"operation":"write","arguments":{"content":"lost"}})).await.is_err());
}
let fetched = r
.host
.fetch_interface_live(&crate::file_operation::Contract::ReadBytes.reference())
.await
.unwrap();
let mut request = observed_common_request(
"/virtual",
Some(1u64.to_be_bytes().to_vec()),
&fetched,
"read",
);
source.version.store(2, Ordering::SeqCst);
assert!(
matches!(r.host.call(request.clone(),file_context()).await,Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::ValidatorMismatch)
);
request.target.validator = Some(2u64.to_be_bytes().to_vec());
assert!(r.host.call(request, file_context()).await.is_ok());
assert_eq!(
std::fs::read(dir.path().join("raw")).unwrap(),
bytes.as_ref().clone()
);
}
#[tokio::test]
async fn scope_downgrade_changes_object_membership_not_common_contract_and_read_remains_available()
{
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("a"), "same").unwrap();
let scope = manifest::SharedScope::new(manifest::Scope::writable(dir.path()).unwrap());
let session = Arc::new(LocalWorkdirSession::materialized(
dir.path().into(),
dir.path().into(),
scope.clone(),
WorkdirSessionCapabilities::READ_WRITE,
));
let router = Arc::new(WorkdirSessionRouter::new());
router
.attach(WorkdirAttachmentAlias::new("main").unwrap(), session)
.unwrap();
let r = Arc::new(runtime(router, None));
let path = "/checkouts/main/a";
let old = model_inspect(r.clone(), path).await;
let current = r.host.projection_live(path).await.unwrap().unwrap();
let definition = r
.host
.fetch_interface_live(&contract("read").reference())
.await
.unwrap();
scope
.update(|_| {
manifest::Scope::from_config(&manifest::ScopeConfig {
allow: vec![manifest::ScopeRule {
target: dir.path().into(),
permission: manifest::Permission::Read,
recursive: true,
symlink_policy: manifest::SymlinkPolicy::Resolved,
}],
deny: vec![],
})
})
.unwrap();
assert!(model_operation(r.clone(),"Invoke",json!({"path":path,"interface":contract_json("write"),"operation":"write","arguments":{"content":"lost"}})).await.is_err());
let new = json_content(
model_operation(r.clone(), "Inspect", json!({"path":path,"refresh":true}))
.await
.unwrap(),
);
assert_ne!(old["object_signature"], new["object_signature"]);
assert!(
!new["interfaces"]
.as_array()
.unwrap()
.iter()
.any(|i| i["reference"]["name"] == "yoi.file/write-text/v1")
);
assert_eq!(
r.host
.fetch_interface_live(&contract("read").reference())
.await
.unwrap()
.validator,
definition.validator
);
assert!(
matches!(r.host.call(observed_common_request(path,current.object.validator,&definition,"read"),file_context()).await,Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::ValidatorMismatch)
);
assert_eq!(
json_content(
model_invoke(r.clone(), &new, "read", json!({}))
.await
.unwrap()
)["content"],
" 1\tsame\n"
);
}
fn contract_json(operation: &str) -> Json {
let r = contract(operation).reference();
json!({"scope":r.scope,"name":r.name})
}
#[test]
fn common_contract_registration_rejects_descriptor_redefinition_and_domain_operation_injection() {
let source = ReadonlyBytes {
bytes: Arc::new(vec![]),
version: Arc::new(std::sync::atomic::AtomicU64::new(1)),
captured: 1,
};
let mut registry = WipMountRegistry::new();
registry.allocate_namespace("virtual", "virtual").unwrap();
let mut invalid = source.projection();
invalid.descriptor.operations[0]
.parameters
.push(ParameterDeclaration {
name: "target_override".into(),
required: false,
documentation: None,
r#type: TypeExpr::String,
});
assert!(matches!(
registry.mount(invalid),
Err(WipMountError::InvalidProjection { .. })
));
let projection = source.projection();
registry.mount(projection.clone()).unwrap();
let mut extension = projection.descriptor.clone();
extension.operations[0].name = "erase".into();
assert!(matches!(
registry.contribute_operations(WipOperationContribution {
route: "/virtual".into(),
contributor: "other".into(),
interface: projection.interface,
descriptor: extension,
handler: projection.handler
}),
Err(WipMountError::InterfaceCollision { .. })
));
}
+8 -1
View File
@@ -4139,7 +4139,14 @@ mod tests {
request: crate::worker::WorkspaceRequest,
) -> Result<crate::worker::WorkspaceResponse, crate::worker::WorkspaceClientError>
{
// Native discovery must not enumerate or perform any mutation.
// Native discovery reads metadata and the current caller's ceiling, never inventory or mutations.
assert_eq!(request.method, crate::worker::WorkspaceRequestMethod::Get);
if request.path == "/api/w/workspace-1/drive/capabilities" {
return Ok(crate::worker::WorkspaceResponse {
status: 200,
body: serde_json::json!({"write":true}).to_string(),
});
}
assert_eq!(request.path, "/api/w/workspace-1/drive/root");
Ok(crate::worker::WorkspaceResponse { status: 200, body: serde_json::json!({
"entry": {"workspace_id": "workspace-1", "node_id": "1"},
+61 -8
View File
@@ -1,5 +1,15 @@
//! WIP presentation of the provider-neutral file argument contracts. Providers
//! choose which read surface to publish; routing and preconditions are not args.
//! Stable root file contracts, independent of provider and target lifetimes.
//!
//! Each capability has one versioned reference and complete descriptor. Providers publish only
//! supported combinations. Root scope is a namespace, never a file grant. Current Object
//! membership selects the provider handler; target observations, checked operations, prior Read,
//! CAS and grants remain provider-owned. Contract validators contain no target or connection state.
//!
//! Line windows, byte ranges and whole-text domain reads are different meanings, not aliases.
//! Settings atomic commits and Drive management/cursor pagination remain domain extensions.
//! Common mutations return one metadata envelope while providers retain their commit details.
//! Old contextual references in history are neither rewritten nor accepted as aliases; callers
//! reobserve Objects and common definitions after migration or restore.
use fs_operation::text::TextOperation;
use wip_protocol::{ParameterDeclaration, TypeExpr};
@@ -185,12 +195,37 @@ impl Contract {
mutation_type(),
),
};
InterfaceDescriptor {format:INTERFACE_FORMAT_V1.into(),types:vec![],documentation:Some(Documentation {summary:summary.into(),details:Some(match self {
Self::ReadBytes=>"Binary-safe byte range, independent of UTF-8 and line boundaries. Offset defaults to 0; limit defaults to 16384 and must be 1..=32768 bytes. Offset must be <=9007199254708223. Returns encoding=base64 with RFC4648 standard padded data, offset and next_offset in bytes, eof when no later bytes remain. At EOF returns empty data, eof=true and next_offset=offset; offsets beyond EOF are InvalidArguments. Encoded pretty JSON result is bounded to 64 KiB. Does not establish text prior-Read for mutation. InvalidArguments rejects ranges/unknown fields, ResourceLimitExceeded bounds, ValidatorMismatch stale source, PermissionDenied authority. Provider source bounds apply; no automatic retry.",
Self::ReadLines|Self::SearchText=>"UTF-8 text (invalid source bytes decoded lossily); Rust lines semantics (CRLF stripped, final newline is not an extra line). Zero-based offset defaults to 0. Limit defaults to 2000, must be 1..=2000. Source/result windows exceeding 64 KiB are refused, never silently skipped. Read returns one-based numbered content. Search is case-sensitive literal matching, pattern nonempty and at most 4096 bytes, returning one-based line/text records. next_offset advances by scanned lines including nonmatches; truncated means more lines remain. Provider source bounds may refuse retrieval. InvalidArguments rejects bad ranges/patterns; ResourceLimitExceeded rejects bounds; ValidatorMismatch rejects stale state; PermissionDenied rejects current authority. No automatic retry.",
Self::Edit|Self::Write|Self::CreateFile=>"UTF-8 text with provider size bounds. Checked save, prior Read and CAS requirements remain provider-owned. Edit rejects empty or identical old/new strings, missing or ambiguous matches unless replace_all. Success returns metadata with provider commit information. InvalidArguments rejects invalid edits; ResourceLimitExceeded rejects bounds; ValidatorMismatch rejects stale state; PermissionDenied rejects current authority. Post-dispatch failure may be OutcomeUnknown: reconcile, never automatically retry.",
_=>"Paths and cursors are Worldspace entries, not host paths. Provider scope, permission, visibility, bounds and cancellation remain authoritative. Discovery does not acquire every result or add indexable children. InvalidArguments, ResourceLimitExceeded, PermissionDenied and ValidatorMismatch preserve provider failures. No automatic retry.",
}.into())}),operations:vec![OperationDeclaration {name:self.operation().into(),documentation:None,parameters,returns:ReturnDeclaration {documentation:None,r#type:result}}]}
let details = match self {
Self::ReadBytes => {
"Binary-safe byte range, independent of UTF-8 and line boundaries. Offset defaults to 0; limit defaults to 16384 and must be 1..=32768 bytes. Offset must be <=9007199254708223. Returns encoding=base64 with RFC4648 standard padded data, offset and next_offset in bytes, eof when no later bytes remain. At EOF returns empty data, eof=true and next_offset=offset; offsets beyond EOF are InvalidArguments. Encoded pretty JSON result is bounded to 64 KiB. Does not establish text prior-Read for mutation. InvalidArguments rejects ranges/unknown fields, ResourceLimitExceeded bounds, ValidatorMismatch stale source, PermissionDenied authority. Provider source bounds apply; no automatic retry."
}
Self::ReadLines | Self::SearchText => {
"UTF-8 text (invalid source bytes decoded lossily); Rust lines semantics (CRLF stripped, final newline is not an extra line). Zero-based offset defaults to 0. Limit defaults to 2000, must be 1..=2000. Source/result windows exceeding 64 KiB are refused, never silently skipped. Read returns one-based numbered content. Search is case-sensitive literal matching, pattern nonempty and at most 4096 bytes, returning one-based line/text records. next_offset advances by scanned lines including nonmatches; truncated means more lines remain. Provider source bounds may refuse retrieval. InvalidArguments rejects bad ranges/patterns; ResourceLimitExceeded rejects bounds; ValidatorMismatch rejects stale state; PermissionDenied rejects current authority. No automatic retry."
}
Self::Edit | Self::Write | Self::CreateFile => {
"UTF-8 text with provider size bounds. Checked save, prior Read and CAS requirements remain provider-owned. Edit rejects empty or identical old/new strings, missing or ambiguous matches unless replace_all. Success returns metadata with provider commit information. InvalidArguments rejects invalid edits; ResourceLimitExceeded rejects bounds; ValidatorMismatch rejects stale state; PermissionDenied rejects current authority. Post-dispatch failure may be OutcomeUnknown: reconcile, never automatically retry."
}
_ => {
"Paths and cursors are Worldspace entries, not host paths. Provider scope, permission, visibility, bounds and cancellation remain authoritative. Discovery does not acquire every result or add indexable children. InvalidArguments, ResourceLimitExceeded, PermissionDenied and ValidatorMismatch preserve provider failures. No automatic retry."
}
};
InterfaceDescriptor {
format: INTERFACE_FORMAT_V1.into(),
types: vec![],
documentation: Some(Documentation {
summary: summary.into(),
details: Some(details.into()),
}),
operations: vec![OperationDeclaration {
name: self.operation().into(),
documentation: None,
parameters,
returns: ReturnDeclaration {
documentation: None,
r#type: result,
},
}],
}
}
pub(crate) fn validator(self) -> Vec<u8> {
Sha256::digest(format!("{:?}", self.descriptor())).to_vec()
@@ -646,3 +681,21 @@ pub(crate) fn bytes_result(
}
Ok(value)
}
#[cfg(test)]
mod byte_tests {
use super::*;
#[test]
fn maximum_raw_byte_page_reserves_base64_and_complete_json_budget() {
let args = BTreeMap::from([("limit".into(), Value::Integer(32768))]);
let result = bytes_result(&args, &vec![0xff; 32768], false)
.unwrap_or_else(|_| panic!("valid bounded bytes"));
let json = crate::wip::wip_to_json(&result).unwrap();
assert!(serde_json::to_vec_pretty(&json).unwrap().len() <= MAX_BYTES);
assert_eq!(json["next_offset"], 32768);
assert_eq!(json["eof"], false);
assert!(
matches!(bytes_result(&args,&vec![0;32769],false),Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::Internal)
);
}
}