From c92d7d548302f28bc072d15564c954b656049cac Mon Sep 17 00:00:00 2001 From: Edvane Cristina Santos Daltro Date: Sun, 1 Nov 2026 21:31:35 -0400 Subject: [PATCH 1/2] Add owner binary draft APIs and explicit recovery --- README.md | 2 +- ROADMAP.md | 2 +- crates/cyber-app/src/host.rs | 37 + crates/cyber-app/tests/files.rs | 336 ++ crates/cyber-core/src/binary/draft.rs | 51 + crates/cyber-core/src/binary/editor.rs | 224 +- .../cyber-core/src/binary/editor/catalog.rs | 118 +- .../cyber-core/src/binary/editor/restore.rs | 2 +- crates/cyber-core/src/binary/mod.rs | 15 +- crates/cyber-core/src/binary/native.rs | 77 + crates/cyber-core/src/binary/receipts.rs | 45 + crates/cyber-core/src/binary/search.rs | 64 + crates/cyber-core/src/binary/source.rs | 91 +- crates/cyber-core/src/binary/storage.rs | 17 + crates/cyber-server/src/http/binary_files.rs | 114 +- .../src/http/binary_files/editing.rs | 465 +++ .../src/http/binary_files/recovery.rs | 188 + crates/cyber-server/src/http/files.rs | 2 +- crates/cyber-server/src/http/idempotency.rs | 14 +- crates/cyber-server/src/http/openapi.rs | 18 +- crates/cyber-server/src/runtime/activity.rs | 50 +- .../src/runtime/binary_documents.rs | 267 +- .../src/runtime/binary_documents_tests.rs | 215 ++ crates/cyber-server/src/runtime/deletion.rs | 50 +- crates/cyber-server/src/runtime/files.rs | 46 + crates/cyber-server/src/runtime/host.rs | 43 + crates/cyber-server/src/runtime/mod.rs | 3 + crates/cyber-tools/src/host.rs | 53 + crates/cyber-tools/src/host/files.rs | 52 +- crates/cyber-tools/src/sandboxing.rs | 4 +- crates/cyber-tools/tests/workspace_edits.rs | 75 + crates/cyber-tui/src/hex.rs | 37 +- crates/cyber-tui/src/runner.rs | 7 +- docs/binary-editor.md | 54 +- docs/features.md | 2 +- .../add-binary-hex-editor/implementation.md | 61 +- sdk/openapi.json | 3429 +++++++++++------ sdk/typescript/README.md | 26 +- sdk/typescript/src/binary.ts | 113 +- sdk/typescript/src/client.ts | 6 +- sdk/typescript/src/generated/operations.ts | 178 +- sdk/typescript/src/generated/types.ts | 160 +- sdk/typescript/src/transport.ts | 7 +- sdk/typescript/test/binary.test.ts | 61 + 44 files changed, 5540 insertions(+), 1341 deletions(-) create mode 100644 crates/cyber-core/src/binary/receipts.rs create mode 100644 crates/cyber-core/src/binary/search.rs create mode 100644 crates/cyber-server/src/http/binary_files/editing.rs create mode 100644 crates/cyber-server/src/http/binary_files/recovery.rs create mode 100644 crates/cyber-server/src/runtime/binary_documents_tests.rs diff --git a/README.md b/README.md index 68e5ef563..2cf25e346 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ For independent patch attempts, host-recorded tests and a separate read-only jud Experimental [native computer use](docs/computer-use.md) adds macOS 14+ application/display screenshots and input with expiring owner grants, CLI/browser owner controls, private evidence and emergency Stop. Off by default; controlled macOS capture, input and Stop acceptance passed. Other layouts and signed distribution remain release checks. -The [binary workspace](docs/binary-editor.md) provides read-only, paged hex/ASCII viewing, literal hex/UTF-8 search, exact goto, bookmarks, byte copying and an LE/BE inspector. Open it with `cyber hex `, `/hex `, or `x` on an explorer file, without starting inference. Session read permissions apply. Staged editing, draft undo/redo, recoverable saves, model binary operations and snapshot/hooks integration remain tracked in [#94](https://github.com/CyberdyneCorp/cybercode-cli/issues/94). +The [binary workspace](docs/binary-editor.md) provides read-only, paged hex/ASCII viewing, literal hex/UTF-8 search, exact goto, bookmarks, byte copying and an LE/BE inspector. Open it with `cyber hex `, `/hex `, or `x` on an explorer file, without starting inference. Session read permissions apply. The owner API/SDK also supports private staged edits, draft undo/redo, byte review and recovery on admitted Linux/macOS workspaces. Terminal editing, production saves, model binary operations and snapshot/hooks integration remain tracked in [#94](https://github.com/CyberdyneCorp/cybercode-cli/issues/94). ![Binary hex workspace with a shared hex/ASCII cursor and little-endian byte inspector](docs/screenshots/binary-hex.png) diff --git a/ROADMAP.md b/ROADMAP.md index cddbdd71a..5b3d973f6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -97,7 +97,7 @@ The [current inventory](docs/implementation/spec-doc-reconciliation.md) contains **Usage limit waits (`session-runtime`, `configuration`, `exec-mode`, `tui`, `web-client`, P1; `goals`, `loops-scheduling`, `workflows`, P2): Status: implemented** (change [`add-usage-limit-wait`](openspec/changes/add-usage-limit-wait/proposal.md)). An unattended run (`cyber exec`, a Goal, a loop iteration, a workflow agent, a teammate, or their subagents) that hits a subscription usage limit with a known reset waits for it and retries the Turn instead of failing: within `usage_limit.max_wait` (default 6 h), 10 s after the reset plus per-Session jitter, as one new attempt from the same history. The wait is durable (`session.usage_limit.waiting|resumed|abandoned.1`), cancelled by the normal interrupt, resumed after a restart for top-level Sessions and ended cleanly for owned ones; wall deadlines (Session, run, Goal and loop `max_wall_seconds`, `exec --timeout`) win over waiting. `exec` reports `usage_limit` stream-json lines, the TUI offers `/resume-at-reset` and a footer badge, and the dashboard shows the wait. The model fallback chain, once implemented, runs first. -**Binary hex editor (P1), in progress:** [`add-binary-hex-editor`](openspec/changes/add-binary-hex-editor/proposal.md) now provides the [live binary workspace](docs/binary-editor.md): paged hex/ASCII inspection, literal search, exact goto, bookmarks, LE/BE interpretation and scoped owner API/SDK access. The user prioritized issue #94 on 2026-11-01 ahead of the unfinished plugins/hooks and daily-coding work. Native save proof, staged editing, review/undo/recovery, model operations and snapshot/hook integration remain open; this is not full editor acceptance. +**Binary hex editor (P1), in progress:** [`add-binary-hex-editor`](openspec/changes/add-binary-hex-editor/proposal.md) now provides the [live binary workspace](docs/binary-editor.md): paged hex/ASCII inspection, literal search, exact goto, bookmarks, LE/BE interpretation and scoped owner API/SDK access. The user prioritized issue #94 on 2026-11-01 ahead of the unfinished plugins/hooks and daily-coding work. The native core and owner API/SDK now provide durable staged patches, draft undo/redo, byte review and explicit recovery on admitted Linux/macOS workspaces. Terminal editing, production save/restore, model operations, shared snapshot/hook integration and remaining acceptance remain open; this is not full editor acceptance. **Untrusted-content defense (`untrusted-content`, `permissions-modes`, `system-context`, P1): Status: implemented** (change [`add-untrusted-content-defense`](openspec/changes/add-untrusted-content-defense/proposal.md)). It carries the `workspace-trust` provenance rule through every tool that returns third-party content. `webfetch`, `websearch`, MCP tool results, browser pages and MCP resources are returned in one escape-safe `` frame that replaces the MCP-resource frame. Hidden characters become visible markers, and a deterministic detector flags instruction-like text with a notice in the TUI, `exec` and the dashboard, without dropping content. After a flagged result, risky actions (network egress, writes outside the Location, classifier-allowed commands, secret reads) ask until the next user prompt, or are denied with no user attached; `bypass` shows the notice or refuses when configured. The base prompts cover the frame (version 3), and a labeled fixture set gates recall and false positives, with scripted end-to-end scenarios for secret reads, permission widening and provider redirection. The TUI and dashboard state each notice's consequence from the Location's effective `taint`/`bypass` settings (task 5.3). What remains: acceptance by hand on the real binary. diff --git a/crates/cyber-app/src/host.rs b/crates/cyber-app/src/host.rs index bac91f247..7175c8ac7 100644 --- a/crates/cyber-app/src/host.rs +++ b/crates/cyber-app/src/host.rs @@ -373,6 +373,43 @@ impl ToolHost for AppHost { .authorize_file(turn, action, path, asker, cancel) } + fn binary_file_decision<'a>( + &'a self, + turn: &'a TurnContext, + action: &'a str, + resource: &'a cyber_core::binary::Resource, + ) -> BoxFuture<'a, cyber_server::runtime::FileDecision> { + self.builtin.binary_file_decision(turn, action, resource) + } + fn authorize_binary_read<'a>( + &'a self, + turn: &'a TurnContext, + resource: &'a cyber_core::binary::Resource, + asker: cyber_server::runtime::Asker, + cancel: CancellationToken, + ) -> BoxFuture<'a, Result<(), String>> { + self.builtin + .authorize_binary_read(turn, resource, asker, cancel) + } + fn authorize_binary_edit<'a>( + &'a self, + turn: &'a TurnContext, + resource: &'a cyber_core::binary::Resource, + review: serde_json::Value, + asker: cyber_server::runtime::Asker, + cancel: CancellationToken, + ) -> BoxFuture<'a, Result<(), String>> { + self.builtin + .authorize_binary_edit(turn, resource, review, asker, cancel) + } + fn file_mutation_guard<'a>( + &'a self, + turn: &'a TurnContext, + resource: &'a cyber_core::binary::Resource, + ) -> BoxFuture<'a, Result, String>> { + self.builtin.file_mutation_guard(turn, resource) + } + fn resolve_prompt_resources<'a>( &'a self, turn: &'a TurnContext, diff --git a/crates/cyber-app/tests/files.rs b/crates/cyber-app/tests/files.rs index b8ab5c8bb..f4c368a4f 100644 --- a/crates/cyber-app/tests/files.rs +++ b/crates/cyber-app/tests/files.rs @@ -991,3 +991,339 @@ async fn owner_binary_edit_permission_remains_manual_in_auto_mode_without_infere [0, 159, 146, 150] ); } + +#[cfg(any(target_os = "macos", target_os = "linux"))] +#[tokio::test] +async fn binary_owner_drafts_patch_history_review_and_permission_revocation() { + use base64::Engine; + let f = Fixture::with(json!({"read":"allow", "edit":"allow"})).await; + let session = f.session().await; + let opened = f.binary(&session, "blob.bin").await; + let id = opened["document_id"].as_str().unwrap(); + let path = format!("/files/binary/documents/{id}"); + let (code, prepared) = f + .send( + "POST", + &format!("{path}/prepare"), + Some(json!({ + "session_id": session, "revision": opened["revision"] + })), + ) + .await; + assert_eq!(code, 200, "{prepared}"); + assert_eq!(prepared["data"]["source"], "draft"); + assert_eq!(prepared["data"]["prepared"], true); + let revision = prepared["data"]["revision"].as_str().unwrap(); + let batch = json!({"session_id":session, "operation_id":"patch_one", "revision":revision, + "splices":[{"offset":"1", "delete_length":"2", "expected_hex":"9f92", "insert_hex":"ff0041"}]}); + let (code, edited) = f + .send("POST", &format!("{path}/patches"), Some(batch.clone())) + .await; + assert_eq!(code, 200, "{edited}"); + assert_eq!(edited["data"]["status"]["length"], "5"); + assert_eq!(edited["data"]["operation"]["file_effect"], "none"); + let (code, replay) = f + .send("POST", &format!("{path}/patches"), Some(batch.clone())) + .await; + assert_eq!(code, 200, "{replay}"); + assert_eq!(replay["data"]["operation"], edited["data"]["operation"]); + let mut wrong_retry = batch; + wrong_retry["splices"][0]["insert_hex"] = json!("aa"); + assert_eq!( + f.send("POST", &format!("{path}/patches"), Some(wrong_retry)) + .await + .0, + 400 + ); + let edited_revision = edited["data"]["status"]["revision"].as_str().unwrap(); + let (code, page) = f + .get(&format!( + "{path}/bytes?session_id={session}&revision={edited_revision}" + )) + .await; + assert_eq!(code, 200, "{page}"); + assert_eq!(page["data"]["source"], "draft"); + assert_eq!( + base64::engine::general_purpose::STANDARD + .decode(page["data"]["base64"].as_str().unwrap()) + .unwrap(), + [0, 255, 0, 65, 150] + ); + let (code, review) = f + .send( + "POST", + &format!("{path}/review"), + Some(json!({"session_id":session,"revision":edited_revision})), + ) + .await; + assert_eq!(code, 200, "{review}"); + assert_eq!(review["data"]["draft"]["proposed_length"], "5"); + assert_eq!(review["data"]["draft"]["ranges"][0]["before_hex"], "9f92"); + let (code, undone) = f.send("POST", &format!("{path}/history"), Some(json!({"session_id":session,"revision":edited_revision,"operation_id":"undo_one","action":"undo"}))).await; + assert_eq!(code, 200, "{undone}"); + assert_eq!(undone["data"]["status"]["can_redo"], true); + assert_eq!(undone["data"]["status"]["length"], "4"); + let (code, redone) = f.send("POST", &format!("{path}/history"), Some(json!({"session_id":session,"revision":undone["data"]["status"]["revision"],"operation_id":"redo_one","action":"redo"}))).await; + assert_eq!(code, 200, "{redone}"); + assert_eq!(redone["data"]["status"]["length"], "5"); + let config_path = f.root.join("config/cyber.json"); + let mut config: Value = serde_json::from_slice(&std::fs::read(&config_path).unwrap()).unwrap(); + config["permissions"] = json!({"read":"allow","edit":"deny"}); + std::fs::write(&config_path, config.to_string()).unwrap(); + let discard = json!({"session_id":session,"revision":redone["data"]["status"]["revision"],"operation_id":"discard_one","action":"discard"}); + assert_eq!( + f.send("POST", &format!("{path}/history"), Some(discard.clone())) + .await + .0, + 403 + ); + config["permissions"] = json!({"read":"allow","edit":"allow"}); + std::fs::write(config_path, config.to_string()).unwrap(); + let (code, discarded) = f + .send("POST", &format!("{path}/history"), Some(discard)) + .await; + assert_eq!(code, 200, "{discarded}"); + assert_eq!(discarded["data"]["status"]["dirty"], false); + assert_eq!( + std::fs::read(f.project.join("blob.bin")).unwrap(), + [0, 159, 146, 150] + ); + assert!( + f._model.requests().is_empty(), + "manual draft editing started inference" + ); +} + +#[tokio::test] +async fn binary_http_idempotency_keys_never_replay_private_data_after_read_revocation() { + let f = Fixture::with(json!({"read":"allow"})).await; + let session = f.session().await; + let opened = f.binary(&session, "blob.bin").await; + let path = format!( + "{}/api/v1/files/binary/documents/{}/search", + f.base, + opened["document_id"].as_str().unwrap() + ); + let body = + json!({"session_id":session,"revision":opened["revision"],"hex":"9f92","offset":"0"}); + let request = || { + f.http + .post(&path) + .basic_auth("cyber", Some(PASSWORD)) + .header("x-cyber-directory", f.project.display().to_string()) + .header("idempotency-key", "binary_search_retry") + .json(&body) + }; + let response = request().send().await.unwrap(); + assert_eq!(response.status(), 200); + assert_eq!( + response.json::().await.unwrap()["data"]["offsets"], + json!(["1"]) + ); + let config_path = f.root.join("config/cyber.json"); + let mut config: Value = serde_json::from_slice(&std::fs::read(&config_path).unwrap()).unwrap(); + config["permissions"] = json!({"read":"deny"}); + std::fs::write(config_path, config.to_string()).unwrap(); + let response = request().send().await.unwrap(); + assert_eq!( + response.status(), + 403, + "keyed search replay bypassed read revocation" + ); + assert!(f._model.requests().is_empty()); +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +#[tokio::test] +async fn binary_immutable_read_only_open_refuses_preparation_even_with_edit_allow() { + let f = Fixture::with(json!({"read":"allow","edit":"allow"})).await; + let session = f.session().await; + let (code, opened) = f + .send( + "POST", + "/files/binary/documents", + Some(json!({"session_id":session,"path":"blob.bin","read_only":true})), + ) + .await; + assert_eq!(code, 200, "{opened}"); + let (code, result) = f + .send( + "POST", + &format!( + "/files/binary/documents/{}/prepare", + opened["data"]["document_id"].as_str().unwrap() + ), + Some(json!({"session_id":session,"revision":opened["data"]["revision"]})), + ) + .await; + assert_eq!(code, 403, "{result}"); + assert_eq!(result["reason"], "binary_read_only"); + assert_eq!( + std::fs::read(f.project.join("blob.bin")).unwrap(), + [0, 159, 146, 150] + ); +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +#[tokio::test] +async fn binary_closed_dirty_draft_is_recoverable_and_prevents_session_deletion() { + let f = Fixture::with(json!({"read":"allow", "edit":"allow"})).await; + let session = f.session().await; + let opened = f.binary(&session, "blob.bin").await; + let id = opened["document_id"].as_str().unwrap(); + let path = format!("/files/binary/documents/{id}"); + let (code, prepared) = f + .send( + "POST", + &format!("{path}/prepare"), + Some(json!({"session_id":session,"revision":opened["revision"]})), + ) + .await; + assert_eq!(code, 200, "{prepared}"); + let (code, edited) = f.send("POST", &format!("{path}/patches"), Some(json!({"session_id":session,"revision":prepared["data"]["revision"],"operation_id":"retained_patch","splices":[{"offset":"0","delete_length":"1","expected_hex":"00","insert_hex":"ff"}]}))).await; + assert_eq!(code, 200, "{edited}"); + let revision = edited["data"]["status"]["revision"].clone(); + assert_eq!( + f.send("DELETE", &format!("{path}?session_id={session}"), None) + .await + .0, + 200 + ); + let (code, refused) = f + .send("DELETE", &format!("/sessions/{session}"), None) + .await; + assert_eq!(code, 409, "{refused}"); + let (code, listing) = f + .get(&format!("/files/binary/recovery?session_id={session}")) + .await; + assert_eq!(code, 200, "{listing}"); + assert_eq!(listing["data"]["documents"][0]["document_id"], id); + let other = f.session().await; + let (code, hidden) = f + .get(&format!("/files/binary/recovery?session_id={other}")) + .await; + assert_eq!(code, 200, "{hidden}"); + assert_eq!(hidden["data"]["documents"], json!([])); + assert_ne!( + f.send( + "POST", + &format!("{path}/recover"), + Some(json!({"session_id":other})) + ) + .await + .0, + 200 + ); + let (code, recovered) = f + .send( + "POST", + &format!("{path}/recover"), + Some(json!({"session_id":session})), + ) + .await; + assert_eq!(code, 200, "{recovered}"); + assert_eq!(recovered["data"]["revision"], revision); + assert_eq!(recovered["data"]["dirty"], true); + let (code, receipt) = f + .get(&format!( + "{path}/operations/retained_patch?session_id={session}" + )) + .await; + assert_eq!(code, 200, "{receipt}"); + assert_eq!(receipt["data"], edited["data"]["operation"]); + // Recovery grants reading, never a fresh mutation admission implicitly. + let discard = json!({"session_id":session,"revision":revision,"operation_id":"retained_discard","action":"discard"}); + assert_eq!( + f.send("POST", &format!("{path}/history"), Some(discard.clone())) + .await + .0, + 403 + ); + let (code, prepared) = f + .send( + "POST", + &format!("{path}/prepare"), + Some(json!({"session_id":session,"revision":revision})), + ) + .await; + assert_eq!(code, 200, "{prepared}"); + let (code, discarded) = f + .send("POST", &format!("{path}/history"), Some(discard)) + .await; + assert_eq!(code, 200, "{discarded}"); + let (code, result) = f + .send("DELETE", &format!("/sessions/{session}"), None) + .await; + assert_eq!(code, 204, "{result}"); + assert_eq!( + std::fs::read(f.project.join("blob.bin")).unwrap(), + [0, 159, 146, 150] + ); + assert!(f._model.requests().is_empty()); +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +#[tokio::test] +async fn binary_prepare_rechecks_plan_ceiling_after_manual_permission_wait() { + let f = Fixture::with(json!({"read":"allow", "edit":"ask"})).await; + let (code, created) = f + .send( + "POST", + "/sessions", + Some(json!({"model":"test/main","mode":"auto"})), + ) + .await; + assert_eq!(code, 201, "{created}"); + let session = created["data"]["id"].as_str().unwrap().to_owned(); + let opened = f.binary(&session, "blob.bin").await; + let path = format!( + "/files/binary/documents/{}", + opened["document_id"].as_str().unwrap() + ); + let request = f.background( + "POST", + format!("{path}/prepare"), + Some(json!({"session_id":session,"revision":opened["revision"]})), + ); + let mut asked = vec![]; + for _ in 0..500 { + asked = pending(&f, &session).await; + if !asked.is_empty() || request.is_finished() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert_eq!(asked.len(), 1, "{asked:?}"); + assert_eq!(asked[0]["action"], "edit"); + assert_eq!(asked[0]["metadata"]["requires_confirmation"], true); + assert!(f._model.requests().is_empty()); + let (code, result) = f + .send( + "POST", + &format!("/sessions/{session}/mode"), + Some(json!({"mode":"plan"})), + ) + .await; + assert_eq!(code, 200, "{result}"); + f.send( + "POST", + &format!( + "/sessions/{session}/permissions/{}/reply", + asked[0]["id"].as_str().unwrap() + ), + Some(json!({"reply":"once"})), + ) + .await; + let (code, refused) = tokio::time::timeout(Duration::from_secs(5), request) + .await + .unwrap() + .unwrap(); + assert!(code == 403 || code == 409, "{code}: {refused}"); + let (_, status) = f.get(&format!("{path}?session_id={session}")).await; + assert_eq!(status["data"]["prepared"], false, "{status}"); + assert_eq!( + std::fs::read(f.project.join("blob.bin")).unwrap(), + [0, 159, 146, 150] + ); + assert!(f._model.requests().is_empty()); +} diff --git a/crates/cyber-core/src/binary/draft.rs b/crates/cyber-core/src/binary/draft.rs index df839d6bf..9022baf60 100644 --- a/crates/cyber-core/src/binary/draft.rs +++ b/crates/cyber-core/src/binary/draft.rs @@ -20,6 +20,7 @@ pub trait ImmutableBase { #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] +#[schemars(rename = "BinarySplice")] pub struct Splice { pub offset: Offset, pub delete_length: Offset, @@ -29,6 +30,7 @@ pub struct Splice { #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] +#[schemars(rename = "BinaryPatchBatch")] pub struct PatchBatch { pub revision: String, pub splices: Vec, @@ -58,6 +60,7 @@ struct Piece { } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[schemars(rename = "BinaryChangeRange")] pub struct ChangeRange { pub original_offset: Offset, pub original_length: Offset, @@ -70,6 +73,7 @@ pub struct ChangeRange { pub preview_truncated: bool, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[schemars(rename = "BinaryReview")] pub struct Review { pub revision: String, pub base_sha256: String, @@ -194,6 +198,21 @@ impl Draft { }) } + pub fn search( + &self, + request: &super::SearchRequest, + cancelled: impl Fn() -> bool, + ) -> Result { + self.current(&request.revision)?; + super::search::bounded( + request, + self.length, + super::Source::Draft, + |at, size| self.read(at, size), + cancelled, + ) + } + fn piece_bytes(&self, piece: &Piece, offset: u64, length: usize) -> Result, Error> { match &piece.bytes { Bytes::Base => { @@ -665,6 +684,38 @@ mod tests { d.read(0, d.length().0 as usize).unwrap() } + #[test] + fn immutable_search_crosses_piece_boundaries_and_rejects_stale_revisions() { + let mut draft = Draft::new(Base::new(b"abCDxy".to_vec())).unwrap(); + let old = draft.revision(); + patch(&mut draft, vec![edit(2, b"CD", &[0, 255])]).unwrap(); + let request = super::super::SearchRequest { + revision: draft.revision(), + hex: "6200ff78".into(), + offset: Offset(0), + }; + let result = draft.search(&request, || false).unwrap(); + assert!(matches!(result.source, super::super::Source::Draft)); + assert_eq!(result.offsets, [Offset(1)]); + assert_eq!(result.revision, draft.revision()); + assert!(matches!( + draft.search(&request, || true), + Err(Error::Cancelled) + )); + let mut stale = request.clone(); + stale.revision = old; + assert!(matches!( + draft.search(&stale, || false), + Err(Error::Changed) + )); + let mut malformed = request; + malformed.hex = "f".into(); + assert!(matches!( + draft.search(&malformed, || false), + Err(Error::Invalid) + )); + } + #[test] fn review_refuses_base_corruption_even_when_only_inserted_bytes_would_be_read() { let mut base = Base::new(b"original".to_vec()); diff --git a/crates/cyber-core/src/binary/editor.rs b/crates/cyber-core/src/binary/editor.rs index a49d70951..82b188389 100644 --- a/crates/cyber-core/src/binary/editor.rs +++ b/crates/cyber-core/src/binary/editor.rs @@ -3,13 +3,12 @@ //! A native intent is never automatically re-executed after interruption. use super::{ Document, Error, Offset, - draft::{Draft, DraftState, ImmutableBase, PatchBatch, Review}, + draft::{Draft, DraftState, ImmutableBase, PatchBatch}, native::{self, Target}, storage::{Base, Store}, }; use cap_fs_ext::{FollowSymlinks, OpenOptionsFollowExt}; use cap_std::fs::OpenOptions; -use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::fs::File; @@ -40,41 +39,8 @@ pub enum DraftScope { Manual, Model { operation: String }, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum Outcome { - Acknowledged, - Conflict, - Unknown, -} -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum FileEffect { - None, - Changed, - Unknown, -} -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct Operation { - pub id: String, - pub kind: String, - pub payload_sha256: String, - pub outcome: Outcome, - pub revision: String, - pub file_effect: FileEffect, - pub reason: Option, - pub notification_pending: bool, -} -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct SaveReview { - pub draft: Review, - pub permissions: u32, - pub owner_uid: u32, - pub owner_gid: u32, - pub metadata_sha256: String, - pub effects: String, - pub fingerprint: String, -} +pub use super::receipts::{FileEffect, Operation, Outcome, SaveReview}; + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct BaseRef { @@ -125,6 +91,7 @@ struct Record { } pub struct Editor { store: Store, + _pins: native::Pins, draft: Draft, record: Record, } @@ -207,14 +174,17 @@ impl Editor { return Err(Error::File); } // Never replace an existing document, including one with unresolved evidence. - if store.directory().symlink_metadata("document.json").is_ok() { - return Err(Error::Changed); + match store.directory().symlink_metadata("document.json") { + Ok(_) => return Err(Error::Changed), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => return Err(Error::Io), } catalog::publish_header(&mut store, &owner, document)?; let (base, target) = document.prepare(&store, cancelled)?; if target.root != owner.root { return Err(Error::File); } + let pins = document.retain_pins()?; let draft = Draft::new(base)?; let record = Record { version: 1, @@ -229,6 +199,7 @@ impl Editor { persist(&mut store, &record)?; Ok(Self { store, + _pins: pins, draft, record, }) @@ -243,9 +214,11 @@ impl Editor { { return Err(Error::File); } + let pins = record.target.pins()?; let draft = Draft::resume(record.base.open(&store)?, record.draft.clone())?; Ok(Self { store, + _pins: pins, draft, record, }) @@ -253,12 +226,28 @@ impl Editor { pub fn draft(&self) -> &Draft { &self.draft } + /// Refuse stale in-memory draft disclosure after an uncertain journal write. + pub fn validate_read_resource(&self) -> Result<(), Error> { + self.store.validate()?; + self.record.target.validate_read_resource() + } + pub fn resource(&self) -> super::Resource { + super::Resource(self.record.target.root.join(&self.record.target.relative)) + } + pub fn target(&self) -> &Target { &self.record.target } pub fn owner(&self) -> &Owner { &self.record.owner } + pub fn pending_operation(&self) -> Option { + self.record + .pending + .as_ref() + .and_then(|pending| self.operation(&pending.operation.id)) + } + pub fn unresolved(&self) -> bool { self.record.pending.is_some() } @@ -269,6 +258,12 @@ impl Editor { } Ok(()) } + /// A fenced/uncertain journal must be reopened before absence can be asserted. + pub fn lookup_operation(&self, id: &str) -> Result, Error> { + self.store.validate()?; + operation_id(id)?; + Ok(self.operation(id)) + } pub fn operation(&self, id: &str) -> Option { self.record .operations @@ -556,6 +551,14 @@ impl Editor { ); } self.store.validate()?; + let stage = stage_file(&self.store, &prepared.intent.stage)?; + if native::id(&stage)? != prepared.intent.after.file + || native::metadata(&stage)? != prepared.intent.after.metadata + || stage.metadata().map_err(|_| Error::Io)?.len() != prepared.intent.after.length.0 + || native::content(&stage, &cancelled)? != prepared.intent.new_base.sha256 + { + return Err(Error::Changed); + } if cancelled() { return Err(Error::Cancelled); } @@ -602,9 +605,9 @@ impl Editor { if native::generation(&displaced)? != displaced_stamp { return Err(Error::Changed); } - Ok(installed_target) + Ok((installed_target, installed)) })(); - let installed_target = match verified { + let (installed_target, installed_pin) = match verified { Ok(target) => target, Err(_) => return Ok(unknown), }; @@ -647,6 +650,7 @@ impl Editor { } self.record = next; self.draft = draft; + self._pins._file = Some(installed_pin); Ok(operation) } /// Called only after the runtime durably publishes the acknowledged observation. @@ -722,6 +726,112 @@ mod tests { _ => panic!("new save must prepare"), } } + #[test] + fn prepared_editors_keep_native_source_and_directory_pins_after_the_live_view_is_dropped() { + use std::os::unix::fs::MetadataExt; + let (_root, editor, _path, owner) = setup(); + let original_root = editor.target().parents[0].clone(); + let original_file = editor.target().file.clone(); + std::fs::remove_dir_all(&owner.root).unwrap(); + std::fs::create_dir(&owner.root).unwrap(); + std::fs::write(owner.root.join("bytes"), b"replacement").unwrap(); + assert_eq!( + native::id(&editor._pins._directories[0]).unwrap(), + original_root + ); + let source = editor._pins._file.as_ref().unwrap(); + assert_eq!(native::id(source).unwrap(), original_file); + assert_eq!(source.metadata().unwrap().nlink(), 0); + assert!(editor.target().validate_read_resource().is_err()); + } + + #[test] + fn uncertain_draft_publication_refuses_stale_reads_and_receipt_absence_until_reopen() { + let (root, mut editor, path, owner) = setup(); + let batch = PatchBatch { + revision: editor.draft().revision(), + splices: vec![crate::binary::draft::Splice { + offset: Offset(0), + delete_length: Offset(1), + expected_hex: "6f".into(), + insert_hex: "ff".into(), + }], + }; + editor.store.fail_after_publication = Some(editor.store.publications + 1); + assert!(editor.patch("patch_uncertain", &batch).is_err()); + assert!(editor.validate_read_resource().is_err()); + assert!(editor.lookup_operation("patch_uncertain").is_err()); + assert!(editor.lookup_operation("never_seen").is_err()); + drop(editor); + let resumed = Editor::resume( + Store::open(&root.path().join("data"), "bin_one").unwrap(), + &owner, + ) + .unwrap(); + resumed.validate_read_resource().unwrap(); + assert_eq!( + resumed + .lookup_operation("patch_uncertain") + .unwrap() + .unwrap() + .outcome, + Outcome::Acknowledged + ); + assert!(resumed.lookup_operation("never_seen").unwrap().is_none()); + assert_eq!(resumed.draft().read(0, 1).unwrap(), [255]); + assert_eq!(std::fs::read(path).unwrap(), b"original"); + } + + #[test] + fn recovered_editor_retains_all_parent_pins_across_later_directory_removal() { + let (root, editor, _path, owner) = setup(); + drop(editor); + std::fs::create_dir(owner.root.join("nested")).unwrap(); + std::fs::write(owner.root.join("nested/bytes"), [0, 255]).unwrap(); + let mut doc = Document::open(&owner.root, "nested/bytes").unwrap(); + let editor = Editor::create( + Store::open(&root.path().join("data"), "bin_nested").unwrap(), + owner.clone(), + &mut doc, + || false, + ) + .unwrap(); + drop((doc, editor)); + let editor = Editor::resume( + Store::open(&root.path().join("data"), "bin_nested").unwrap(), + &owner, + ) + .unwrap(); + assert_eq!(editor._pins._directories.len(), 2); + let original = editor.target().parents[1].clone(); + std::fs::remove_dir_all(owner.root.join("nested")).unwrap(); + std::fs::create_dir(owner.root.join("nested")).unwrap(); + assert_eq!(native::id(&editor._pins._directories[1]).unwrap(), original); + let replacement = cap_std::fs::Dir::open_ambient_dir( + owner.root.join("nested"), + cap_std::ambient_authority(), + ) + .unwrap(); + assert_ne!(native::id(&replacement.into_std_file()).unwrap(), original); + assert!(editor.validate_read_resource().is_err()); + } + + #[test] + fn changed_replacement_bytes_are_refused_before_intent_or_source_effects() { + let (root, mut editor, path, _owner) = setup(); + patch(&mut editor, "patch_replacement"); + let prepared = ready(&mut editor, "save_replacement"); + let stage = root + .path() + .join("data/binary/bin_one") + .join(&prepared.intent.stage); + std::fs::write(stage, [9; 4]).unwrap(); + assert!(matches!(editor.commit_save(*prepared), Err(Error::Changed))); + assert!(!editor.unresolved()); + assert!(editor.operation("save_replacement").is_none()); + assert_eq!(std::fs::read(path).unwrap(), b"original"); + } + #[test] fn creating_a_document_cannot_bind_a_different_owner_location() { let (root, _editor, path, mut owner) = setup(); @@ -1101,6 +1211,38 @@ mod tests { assert_eq!(editor.record.operations.len(), 1); } #[test] + fn preparation_accepts_direct_dot_paths_but_not_parent_traversal() { + let (root, editor, path, owner) = setup(); + drop(editor); + assert!(Document::open(&owner.root, path.to_str().unwrap()).is_err()); + { + let id = "bin_dot"; + let requested = format!("./{}", path.file_name().unwrap().to_str().unwrap()); + let mut doc = Document::open(&owner.root, &requested).unwrap(); + let editor = Editor::create( + Store::open(&root.path().join("data"), id).unwrap(), + owner.clone(), + &mut doc, + || false, + ) + .unwrap(); + assert_eq!(editor.resource().path(), path.canonicalize().unwrap()); + } + std::fs::create_dir(owner.root.join("nested")).unwrap(); + let requested = format!("nested/../{}", path.file_name().unwrap().to_str().unwrap()); + let mut doc = Document::open(&owner.root, &requested).unwrap(); + assert!( + Editor::create( + Store::open(&root.path().join("data"), "bin_parent").unwrap(), + owner, + &mut doc, + || false + ) + .is_err() + ); + assert_eq!(std::fs::read(path).unwrap(), b"original"); + } + #[test] fn source_aliases_and_wrong_session_cannot_prepare_or_resume_mutation() { let (root, editor, path, owner) = setup(); drop(editor); diff --git a/crates/cyber-core/src/binary/editor/catalog.rs b/crates/cyber-core/src/binary/editor/catalog.rs index b4ad769c1..2447b83ed 100644 --- a/crates/cyber-core/src/binary/editor/catalog.rs +++ b/crates/cyber-core/src/binary/editor/catalog.rs @@ -3,6 +3,7 @@ use super::*; use crate::binary::storage::{StorageRoot, small_record}; use cap_fs_ext::DirExt; +use schemars::JsonSchema; use std::path::Component; const HEADER_LIMIT: u64 = 16 * 1024; @@ -30,14 +31,29 @@ pub(super) fn publish_header( owner: &Owner, document: &Document, ) -> Result<(), Error> { - if store.directory().symlink_metadata("owner.json").is_ok() { - return Err(Error::Changed); - } let relative = document .path() .strip_prefix(document.root()) .map_err(|_| Error::File)? .to_owned(); + match store.directory().symlink_metadata("owner.json") { + Ok(_) => { + let prior: Header = serde_json::from_slice(&small_record( + store.directory(), + "owner.json", + HEADER_LIMIT, + )?) + .map_err(|_| Error::Invalid)?; + if prior.version != 1 || &prior.owner != owner || prior.relative != relative { + return Err(Error::Changed); + } + // create() already proved there is no document journal: an interrupted + // preparation has no source effects or staged commands to overwrite. + return Ok(()); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => return Err(Error::Io), + } let header = Header { version: 1, owner: owner.clone(), @@ -52,6 +68,68 @@ pub(super) fn publish_header( }) } impl StorageRoot { + /// Authenticate a small owner header before opening a potentially large draft. + pub fn resource(&self, owner: &Owner, id: &str) -> Result { + if !id.starts_with("bin_") + || id.len() > 100 + || !id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') + { + return Err(Error::File); + } + let root = self.directory()?; + let dir = root.open_dir_nofollow(id).map_err(|_| Error::File)?; + crate::binary::storage::private( + root, + &dir.try_clone().map_err(|_| Error::Io)?.into_std_file(), + true, + )?; + let header: Header = + serde_json::from_slice(&small_record(&dir, "owner.json", HEADER_LIMIT)?) + .map_err(|_| Error::Invalid)?; + if header.version != 1 + || &header.owner != owner + || header.relative.as_os_str().is_empty() + || header + .relative + .components() + .any(|c| !matches!(c, Component::Normal(_))) + { + return Err(Error::File); + } + Ok(super::super::Resource(owner.root.join(header.relative))) + } + + /// Deletion must not orphan dirty drafts or unresolved receipts, including closed + /// documents and records from prior server processes. Locked/corrupt records fail closed. + pub fn check_session_deletion(&self, sessions: &[String]) -> Result<(), Error> { + let root = self.directory()?; + for (count, entry) in root.entries().map_err(|_| Error::Io)?.enumerate() { + if count >= 4096 { + return Err(Error::Limit); + } + let name = entry.map_err(|_| Error::Io)?.file_name(); + let id = name.to_str().ok_or(Error::File)?; + let dir = root.open_dir_nofollow(&name).map_err(|_| Error::File)?; + if !dir.try_exists("owner.json").map_err(|_| Error::Io)? { + continue; + } + let header: Header = + serde_json::from_slice(&small_record(&dir, "owner.json", HEADER_LIMIT)?) + .map_err(|_| Error::Invalid)?; + if !sessions.contains(&header.owner.session_id) { + continue; + } + if !dir.try_exists("document.json").map_err(|_| Error::Io)? { + continue; + } + let editor = Editor::resume(self.open_existing(id)?, &header.owner)?; + if editor.unresolved() || editor.draft().can_undo() { + return Err(Error::Changed); + } + } + self.directory()?; + Ok(()) + } /// Callers establish fresh owner authentication and Session/Location scope. /// Exact scope equality keeps model invocation drafts out of manual recovery. pub fn list( @@ -186,6 +264,22 @@ mod tests { drop((first, second)); Editor::resume(storage.open("bin_a").unwrap(), &owner).unwrap(); } + #[test] + fn missing_recovery_ids_never_create_namespaces_and_headers_remain_owner_scoped() { + let (_temp, storage, owner) = fixture(); + assert!(storage.resource(&owner, "bin_absent").is_err()); + assert!(storage.open_existing("bin_absent").is_err()); + assert!(!storage.path().join("bin_absent").exists()); + let _active = create(&storage, &owner, "bin_active"); + assert_eq!( + storage.resource(&owner, "bin_active").unwrap().path(), + owner.root.join("bytes") + ); + let mut other = owner.clone(); + other.session_id = "ses_other".into(); + assert!(storage.resource(&other, "bin_active").is_err()); + } + #[test] fn interrupted_preparation_remains_discoverable_without_a_claimed_journal() { let (_root, storage, owner) = fixture(); @@ -203,15 +297,15 @@ mod tests { assert_eq!(listing.documents.len(), 1); assert_eq!(listing.documents[0].document_id, "bin_cancelled"); assert!(!listing.documents[0].record_available); - assert!(matches!( - Editor::create( - storage.open("bin_cancelled").unwrap(), - owner.clone(), - &mut doc, - || false - ), - Err(Error::Changed) - )); + let editor = Editor::create( + storage.open("bin_cancelled").unwrap(), + owner.clone(), + &mut doc, + || false, + ) + .unwrap(); + assert_eq!(editor.owner(), &owner); + assert!(storage.list(&owner, None, 64).unwrap().documents[0].record_available); assert_eq!( std::fs::read(owner.root.join("bytes")).unwrap(), [0, 255, 128] diff --git a/crates/cyber-core/src/binary/editor/restore.rs b/crates/cyber-core/src/binary/editor/restore.rs index 37a0f5b49..3af91fa8c 100644 --- a/crates/cyber-core/src/binary/editor/restore.rs +++ b/crates/cyber-core/src/binary/editor/restore.rs @@ -1,7 +1,7 @@ //! Restore is a separately reviewed native operation. It does not use Session //! history or silently replace an owner's unsaved draft. use super::*; -use crate::binary::draft::{ChangeRange, encode_hex}; +use crate::binary::draft::{ChangeRange, Review, encode_hex}; impl Editor { pub fn restore_review(&self, cancelled: impl Fn() -> bool) -> Result { diff --git a/crates/cyber-core/src/binary/mod.rs b/crates/cyber-core/src/binary/mod.rs index d072aabfe..dc1a0c7a8 100644 --- a/crates/cyber-core/src/binary/mod.rs +++ b/crates/cyber-core/src/binary/mod.rs @@ -7,6 +7,8 @@ pub mod draft; pub mod editor; #[cfg(any(target_os = "macos", target_os = "linux"))] pub mod native; +pub mod receipts; +mod search; mod source; pub mod storage; pub use source::Document; @@ -14,6 +16,16 @@ pub use source::Document; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +/// The canonical resource admitted when a document was opened, never a path to +/// resolve again during permission checks. This is a name, not an authority token. +#[derive(Debug, Clone)] +pub struct Resource(std::path::PathBuf); +impl Resource { + pub fn path(&self) -> &std::path::Path { + &self.0 + } +} + pub const DEFAULT_PAGE: usize = 64 * 1024; pub const MAX_PAGE: usize = 1024 * 1024; pub const MAX_PATTERN: usize = 4096; @@ -69,7 +81,7 @@ pub enum Error { pub struct Metadata { pub path: String, pub length: Offset, - /// Observed metadata generation, not an immutable whole-file content digest. + /// Live observation or draft revision; never a whole-file content digest. pub revision: String, pub source: Source, pub within_edit_size_limit: bool, @@ -79,6 +91,7 @@ pub struct Metadata { #[serde(rename_all = "snake_case")] pub enum Source { Live, + Draft, } /// Internal bytes; HTTP adapters choose their bounded transport encoding. diff --git a/crates/cyber-core/src/binary/native.rs b/crates/cyber-core/src/binary/native.rs index 21947b23a..1b058c958 100644 --- a/crates/cyber-core/src/binary/native.rs +++ b/crates/cyber-core/src/binary/native.rs @@ -16,6 +16,12 @@ use std::path::{Component, Path, PathBuf}; #[allow(unsafe_code)] // Bounded owned ACL/copyfile-state allocations, no borrowed raw pointers. mod macos; +/// Live pins prevent inode reuse while a prepared editor owns its source route. +pub(super) struct Pins { + pub _directories: Vec, + pub _file: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct Metadata { @@ -210,6 +216,30 @@ impl Target { observed: generation(&file)?, }) } + pub(super) fn pins(&self) -> Result { + let mut dir = Dir::open_ambient_dir(&self.root, cap_std::ambient_authority()) + .map_err(|_| Error::Changed)?; + let mut directories = vec![dir.try_clone().map_err(|_| Error::Io)?.into_std_file()]; + let mut parts = self.relative.components().peekable(); + while let Some(part) = parts.next() { + let Component::Normal(name) = part else { + return Err(Error::File); + }; + if parts.peek().is_some() { + dir = dir.open_dir_nofollow(name).map_err(|_| Error::Changed)?; + directories.push(dir.try_clone().map_err(|_| Error::Io)?.into_std_file()); + } + } + let identities = directories.iter().map(id).collect::, _>>()?; + if identities != self.parents { + return Err(Error::Changed); + } + let file = self.validate().ok().map(|(_, file)| file); + Ok(Pins { + _directories: directories, + _file: file, + }) + } pub fn validate(&self) -> Result<(Dir, File), Error> { let (parent, file, ids) = open(&self.root, &self.relative, true)?; if ids != self.parents @@ -222,6 +252,53 @@ impl Target { } Ok((parent, file)) } + /// Retained draft disclosure stays bound to the original parent route. The + /// current leaf may have changed/deleted (a save conflict), but aliases must + /// never redirect authority to another resource. No source bytes are read. + pub fn validate_read_resource(&self) -> Result<(), Error> { + let mut dir = Dir::open_ambient_dir(&self.root, cap_std::ambient_authority()) + .map_err(|_| Error::Changed)?; + let mut parents = vec![id(&dir + .try_clone() + .map_err(|_| Error::Io)? + .into_std_file())?]; + let mut parts = self.relative.components().peekable(); + while let Some(part) = parts.next() { + let Component::Normal(name) = part else { + return Err(Error::File); + }; + if parts.peek().is_some() { + dir = dir.open_dir_nofollow(name).map_err(|_| Error::Changed)?; + parents.push(id(&dir + .try_clone() + .map_err(|_| Error::Io)? + .into_std_file())?); + continue; + } + if parents != self.parents { + return Err(Error::Changed); + } + match dir.symlink_metadata(name) { + Ok(metadata) if metadata.is_file() && !metadata.file_type().is_symlink() => { + let mut options = OpenOptions::new(); + use cap_std::fs::OpenOptionsExt; + options + .read(true) + .follow(FollowSymlinks::No) + .custom_flags(libc::O_NONBLOCK); + let file = dir.open_with(name, &options).map_err(|_| Error::Changed)?; + if !file.metadata().map_err(|_| Error::Io)?.is_file() { + return Err(Error::File); + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + _ => return Err(Error::Changed), + } + return Ok(()); + } + Err(Error::File) + } + pub fn same_filesystem(&self, private: &Dir) -> Result<(), Error> { let (_, file) = self.validate()?; if file.metadata().map_err(|_| Error::Io)?.dev() diff --git a/crates/cyber-core/src/binary/receipts.rs b/crates/cyber-core/src/binary/receipts.rs new file mode 100644 index 000000000..e134743a8 --- /dev/null +++ b/crates/cyber-core/src/binary/receipts.rs @@ -0,0 +1,45 @@ +//! Common wire receipts/reviews. Their availability does not imply native write +//! support on every platform; the service advertises admission separately. +use super::draft::Review; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +#[schemars(rename = "BinaryOutcome")] +pub enum Outcome { + Acknowledged, + Conflict, + Unknown, +} +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +#[schemars(rename = "BinaryFileEffect")] +pub enum FileEffect { + None, + Changed, + Unknown, +} +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[schemars(rename = "BinaryOperation")] +pub struct Operation { + pub id: String, + pub kind: String, + pub payload_sha256: String, + pub outcome: Outcome, + pub revision: String, + pub file_effect: FileEffect, + pub reason: Option, + pub notification_pending: bool, +} +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[schemars(rename = "BinarySaveReview")] +pub struct SaveReview { + pub draft: Review, + pub permissions: u32, + pub owner_uid: u32, + pub owner_gid: u32, + pub metadata_sha256: String, + pub effects: String, + pub fingerprint: String, +} diff --git a/crates/cyber-core/src/binary/search.rs b/crates/cyber-core/src/binary/search.rs new file mode 100644 index 000000000..3d7d22d2e --- /dev/null +++ b/crates/cyber-core/src/binary/search.rs @@ -0,0 +1,64 @@ +//! Shared bounded literal search over live sources and immutable draft revisions. +use super::*; +use std::time::{Duration, Instant}; +pub(super) fn bounded( + request: &SearchRequest, + length: u64, + source: Source, + mut read: impl FnMut(u64, usize) -> Result, Error>, + cancelled: impl Fn() -> bool, +) -> Result { + let pattern = decode_hex(&request.hex, MAX_PATTERN)?; + if pattern.is_empty() || request.offset.0 > length { + return Err(Error::Invalid); + } + let matcher = aho_corasick::AhoCorasick::new([&pattern]).map_err(|_| Error::Invalid)?; + let deadline = Instant::now() + Duration::from_millis(250); + let mut at = request.offset.0; + let mut carry = Vec::new(); + let mut offsets = Vec::new(); + let mut scanned = 0usize; + let mut next = None; + while at < length && scanned < MAX_SEARCH_BYTES { + if cancelled() { + return Err(Error::Cancelled); + } + let remaining = usize::try_from(length - at).unwrap_or(usize::MAX); + let chunk = read( + at, + DEFAULT_PAGE.min(MAX_SEARCH_BYTES - scanned).min(remaining), + )?; + if chunk.is_empty() { + return Err(Error::Changed); + } + let start = at - carry.len() as u64; + scanned += chunk.len(); + at += chunk.len() as u64; + carry.extend_from_slice(&chunk); + for found in matcher.find_overlapping_iter(&carry) { + offsets.push(Offset(start + found.start() as u64)); + if offsets.len() == MAX_MATCHES { + next = Some(Offset(start + found.start() as u64 + 1)); + break; + } + } + if next.is_some() { + break; + } + let keep = (pattern.len() - 1).min(carry.len()); + carry.drain(..carry.len() - keep); + if Instant::now() >= deadline { + break; + } + } + if next.is_none() && at < length { + next = Some(Offset(at.saturating_sub(carry.len() as u64))); + } + Ok(SearchResult { + revision: request.revision.clone(), + source, + offsets, + next_offset: next, + scanned: Offset(scanned as u64), + }) +} diff --git a/crates/cyber-core/src/binary/source.rs b/crates/cyber-core/src/binary/source.rs index c2c837402..e24c1619d 100644 --- a/crates/cyber-core/src/binary/source.rs +++ b/crates/cyber-core/src/binary/source.rs @@ -5,7 +5,6 @@ use sha2::{Digest, Sha256}; use std::fs::File; use std::io::{Read, Seek, SeekFrom}; use std::path::{Component, Path, PathBuf}; -use std::time::{Duration, Instant}; /// One live source with a pinned descriptor. No page reopens a caller-supplied path. pub struct Document { @@ -114,7 +113,16 @@ impl Document { return Err(Error::Cancelled); } self.validate()?; - if Path::new(&self.requested) != self.relative { + let direct = Path::new(&self.requested); + let mut normalized = PathBuf::new(); + for component in direct.components() { + match component { + Component::Normal(name) => normalized.push(name), + Component::CurDir => {} + _ => return Err(Error::File), + } + } + if normalized != self.relative { return Err(Error::File); } let target = super::native::Target::capture(&self.root, &self.relative)?; @@ -159,6 +167,24 @@ impl Document { Ok((base, target)) } + #[cfg(any(target_os = "macos", target_os = "linux"))] + pub(super) fn retain_pins(&self) -> Result { + let directories = self + .directories + .iter() + .map(|handle| handle.as_file().try_clone().map_err(|_| Error::Io)) + .collect::>()?; + let file = self.identity.as_file().try_clone().map_err(|_| Error::Io)?; + Ok(super::native::Pins { + _directories: directories, + _file: Some(file), + }) + } + + pub fn resource(&self) -> Resource { + Resource(self.path()) + } + pub fn root(&self) -> &Path { &self.root } @@ -244,60 +270,15 @@ impl Document { request: &SearchRequest, cancelled: impl Fn() -> bool, ) -> Result { - let pattern = decode_hex(&request.hex, MAX_PATTERN)?; - if pattern.is_empty() || request.offset.0 > self.length { - return Err(Error::Invalid); - } self.check_revision(&request.revision)?; - let matcher = aho_corasick::AhoCorasick::new([&pattern]).map_err(|_| Error::Invalid)?; - let deadline = Instant::now() + Duration::from_millis(250); - let mut at = request.offset.0; - let mut carry = Vec::new(); - let mut offsets = Vec::new(); - let mut scanned = 0usize; - let mut next = None; - while at < self.length && scanned < MAX_SEARCH_BYTES { - if cancelled() { - return Err(Error::Cancelled); - } - let remaining = usize::try_from(self.length - at).unwrap_or(usize::MAX); - let chunk = self.read_at( - at, - DEFAULT_PAGE.min(MAX_SEARCH_BYTES - scanned).min(remaining), - )?; - if chunk.is_empty() { - return Err(Error::Changed); - } - let start = at - carry.len() as u64; - scanned += chunk.len(); - at += chunk.len() as u64; - carry.extend_from_slice(&chunk); - for found in matcher.find_overlapping_iter(&carry) { - offsets.push(Offset(start + found.start() as u64)); - if offsets.len() == MAX_MATCHES { - next = Some(Offset(start + found.start() as u64 + 1)); - break; - } - } - if next.is_some() { - break; - } - let keep = (pattern.len() - 1).min(carry.len()); - carry.drain(..carry.len() - keep); - if Instant::now() >= deadline { - break; - } - } + let result = super::search::bounded( + request, + self.length, + Source::Live, + |at, size| self.read_at(at, size), + cancelled, + )?; self.validate()?; - if next.is_none() && at < self.length { - next = Some(Offset(at.saturating_sub(carry.len() as u64))); - } - Ok(SearchResult { - revision: self.observed.clone(), - source: Source::Live, - offsets, - next_offset: next, - scanned: Offset(scanned as u64), - }) + Ok(result) } } diff --git a/crates/cyber-core/src/binary/storage.rs b/crates/cyber-core/src/binary/storage.rs index daae90bce..079a9534c 100644 --- a/crates/cyber-core/src/binary/storage.rs +++ b/crates/cyber-core/src/binary/storage.rs @@ -121,6 +121,20 @@ impl StorageRoot { } Ok(store) } + /// Recovery must never allocate a document namespace for an absent ID. + #[cfg(any(target_os = "macos", target_os = "linux"))] + pub fn open_existing(&self, id: &str) -> Result { + component(id)?; + let root = self.directory()?.try_clone().map_err(|_| Error::Io)?; + let dir = root.open_dir_nofollow(id).map_err(|_| Error::File)?; + private( + &root, + &dir.try_clone().map_err(|_| Error::Io)?.into_std_file(), + true, + )?; + let data = open_data_directory(&self.data)?; + Store::locked(self.data.clone(), data, root, dir, id) + } /// A writable project containing private storage could rename or inspect the /// staging directory under its filesystem grant. Such editing is unsupported. pub fn admit_project(&self, project: &Path) -> Result<(), Error> { @@ -396,6 +410,9 @@ impl Store { component(id)?; let (data_path, data, root) = open_root(data)?; let dir = child(&root, id)?; + Self::locked(data_path, data, root, dir, id) + } + fn locked(data_path: PathBuf, data: Dir, root: Dir, dir: Dir, id: &str) -> Result { #[cfg(windows)] let lock = { let owner = dir.try_clone().map_err(|_| Error::Io)?.into_std_file(); diff --git a/crates/cyber-server/src/http/binary_files.rs b/crates/cyber-server/src/http/binary_files.rs index dcb14bd45..6e669ec23 100644 --- a/crates/cyber-server/src/http/binary_files.rs +++ b/crates/cyber-server/src/http/binary_files.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use axum::extract::{Path, Query, State}; use axum::http::request::Parts; -use axum::routing::{delete, get, post}; +use axum::routing::{get, post}; use axum::{Json, Router}; use base64::Engine; use cyber_core::binary::{self, Document, Error, Offset, SearchRequest, SearchResult}; @@ -17,12 +17,19 @@ use super::{ApiError, AppState, files}; use crate::runtime::binary_documents::Entry; use crate::runtime::{FileDecision, TurnContext}; +#[path = "binary_files/editing.rs"] +pub(super) mod editing; +#[path = "binary_files/recovery.rs"] +pub(super) mod recovery; + #[derive(Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct OpenRequest { pub session_id: String, pub path: String, pub expected_root: Option, + #[serde(default)] + pub read_only: bool, } #[derive(Serialize, JsonSchema)] @@ -64,9 +71,31 @@ pub struct BytePage { pub fn routes() -> Router { Router::new() .route("/files/binary/documents", post(open_document)) + .route("/files/binary/recovery", get(recovery::list)) + .route( + "/files/binary/documents/{id}/recover", + post(recovery::recover), + ) + .route( + "/files/binary/documents/{id}/operations/{operation}", + get(recovery::operation), + ) .route("/files/binary/documents/{id}/bytes", get(bytes)) .route("/files/binary/documents/{id}/search", post(search)) - .route("/files/binary/documents/{id}", delete(close)) + .route( + "/files/binary/documents/{id}", + get(editing::status).delete(close), + ) + .route( + "/files/binary/documents/{id}/prepare", + post(editing::prepare), + ) + .route("/files/binary/documents/{id}/patches", post(editing::patch)) + .route( + "/files/binary/documents/{id}/history", + post(editing::history), + ) + .route("/files/binary/documents/{id}/review", post(editing::review)) } fn refused(error: Error) -> ApiError { @@ -99,13 +128,13 @@ struct ReadAuthority { turn: TurnContext, root: PathBuf, grant: FileDecision, + resource: binary::Resource, } async fn verify_read( state: &AppState, parts: &Parts, authority: &ReadAuthority, - path: &FilePath, ) -> Result<(), ApiError> { let (now, root, _) = files::scope(state, parts, Some(&authority.turn.session_id)).await?; let prior = &authority.turn; @@ -117,11 +146,8 @@ async fn verify_read( } let decision = state .runtime - .file_decisions(&now, "read", &[path.to_path_buf()]) - .await - .first() - .copied() - .unwrap_or(FileDecision::Deny); + .binary_file_decision(&now, "read", &authority.resource) + .await; if decision == FileDecision::Deny || (decision == FileDecision::Ask && authority.grant != FileDecision::Ask) { @@ -141,6 +167,26 @@ async fn verify_read( Ok(()) } +async fn permit_read( + state: &AppState, + turn: &TurnContext, + resource: &binary::Resource, +) -> Result { + let decision = state + .runtime + .binary_file_decision(turn, "read", resource) + .await; + match decision { + FileDecision::Allow | FileDecision::Ask => state + .runtime + .authorize_binary_read(turn, resource) + .await + .map(|_| decision) + .map_err(|message| files::denied(decision, &message)), + _ => Err(files::denied(decision, "Permission denied: read")), + } +} + async fn open_document( State(state): State, parts: Parts, @@ -163,16 +209,21 @@ async fn open_document( } let at = root.clone(); let document = work(&state, move || Document::open(&at, &input.path)).await?; - let path = document.path(); - let grant = files::permit(&state, &turn, "read", &path).await?; - let authority = ReadAuthority { turn, root, grant }; - verify_read(&state, &parts, &authority, &path).await?; + let resource = document.resource(); + let grant = permit_read(&state, &turn, &resource).await?; + let authority = ReadAuthority { + turn, + root, + grant, + resource, + }; + verify_read(&state, &parts, &authority).await?; let document = work(&state, move || { document.validate()?; Ok(document) }) .await?; - verify_read(&state, &parts, &authority, &path).await?; + verify_read(&state, &parts, &authority).await?; let location = LocationInfo::of(FilePath::new(&authority.turn.directory)); let (document_id, metadata) = state .runtime @@ -182,6 +233,7 @@ async fn open_document( authority.turn.directory, authority.root, document, + input.read_only, ) .map_err(refused)?; Ok(Json(Located { @@ -212,10 +264,16 @@ async fn owned( && entry.root == root }) .ok_or_else(|| ApiError::not_found("NotFoundError", "No such binary document"))?; - let grant = files::permit(state, &turn, "read", &entry.path).await?; + let resource = entry.resource.clone(); + let grant = permit_read(state, &turn, &resource).await?; let location = LocationInfo::of(FilePath::new(&turn.directory)); - let authority = ReadAuthority { turn, root, grant }; - verify_read(state, parts, &authority, &entry.path).await?; + let authority = ReadAuthority { + turn, + root, + grant, + resource, + }; + verify_read(state, parts, &authority).await?; Ok((entry, location, authority)) } @@ -230,16 +288,19 @@ async fn bytes( .clone() .ok_or_else(|| ApiError::invalid("revision is required"))?; let (entry, location, authority) = owned(&state, &parts, &query.session_id, &id).await?; - let path = entry.path.clone(); let data = work(&state, move || { - let page = entry.document.lock().map_err(|_| Error::Io)?.page( + let mut content = entry.document.lock().map_err(|_| Error::Io)?; + content.validate_resource()?; + let source = content.metadata().source; + let page = content.page( &revision, query.offset.unwrap_or(Offset(0)), query.limit.unwrap_or(binary::DEFAULT_PAGE), )?; + content.validate_resource()?; Ok(BytePage { revision, - source: binary::Source::Live, + source, offset: page.offset, length: Offset(page.bytes.len() as u64), base64: base64::engine::general_purpose::STANDARD.encode(page.bytes), @@ -248,7 +309,7 @@ async fn bytes( }) }) .await?; - verify_read(&state, &parts, &authority, &path).await?; + verify_read(&state, &parts, &authority).await?; Ok(Json(Located { location, data })) } @@ -259,7 +320,6 @@ async fn search( Json(input): Json, ) -> Result>, ApiError> { let (entry, location, authority) = owned(&state, &parts, &input.session_id, &id).await?; - let path = entry.path.clone(); let input = SearchRequest { revision: input.revision, hex: input.hex, @@ -268,14 +328,14 @@ async fn search( let cancellation = tokio_util::sync::CancellationToken::new(); let _cancel_on_drop = cancellation.clone().drop_guard(); let result = work(&state, move || { - entry - .document - .lock() - .map_err(|_| Error::Io)? - .search(&input, || cancellation.is_cancelled()) + let mut content = entry.document.lock().map_err(|_| Error::Io)?; + content.validate_resource()?; + let result = content.search(&input, || cancellation.is_cancelled())?; + content.validate_resource()?; + Ok(result) }) .await?; - verify_read(&state, &parts, &authority, &path).await?; + verify_read(&state, &parts, &authority).await?; Ok(Json(Located { location, data: result, diff --git a/crates/cyber-server/src/http/binary_files/editing.rs b/crates/cyber-server/src/http/binary_files/editing.rs new file mode 100644 index 000000000..9f8a9c6cd --- /dev/null +++ b/crates/cyber-server/src/http/binary_files/editing.rs @@ -0,0 +1,465 @@ +//! Owner draft operations. File installation remains a separately reviewed action. +use super::*; +use crate::runtime::binary_documents::Content; +#[cfg(any(target_os = "macos", target_os = "linux"))] +use crate::runtime::binary_documents::EditAdmission; +#[cfg(any(target_os = "macos", target_os = "linux"))] +use cyber_core::binary::draft::PatchBatch; +use cyber_core::binary::draft::Splice; +use cyber_core::binary::receipts::{Operation, SaveReview}; +#[cfg(any(target_os = "macos", target_os = "linux"))] +use cyber_core::binary::{ + draft::ImmutableBase, + editor::{DraftScope, Editor, Owner}, +}; +#[cfg(any(target_os = "macos", target_os = "linux"))] +use serde_json::Value; +#[cfg(any(target_os = "macos", target_os = "linux"))] +use std::sync::atomic::Ordering; + +#[derive(Serialize, JsonSchema)] +#[schemars(rename = "BinaryStatus")] +pub struct Status { + #[serde(flatten)] + pub metadata: binary::Metadata, + pub read_only: bool, + pub prepared: bool, + pub dirty: bool, + pub can_undo: bool, + pub can_redo: bool, + pub base_sha256: Option, + pub pending_operation: Option, + pub edit_supported: bool, + pub edit_decision: FileDecision, +} +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(rename = "BinaryPrepareRequest")] +pub struct PrepareRequest { + pub session_id: String, + pub revision: String, +} +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(rename = "BinaryPatchRequest")] +pub struct PatchRequest { + pub session_id: String, + pub operation_id: String, + pub revision: String, + pub splices: Vec, +} +#[derive(Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +#[schemars(rename = "BinaryHistoryAction")] +pub enum HistoryAction { + Undo, + Redo, + Discard, +} +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(rename = "BinaryHistoryRequest")] +pub struct HistoryRequest { + pub session_id: String, + pub operation_id: String, + pub revision: String, + pub action: HistoryAction, +} +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(rename = "BinaryReviewRequest")] +pub struct ReviewRequest { + pub session_id: String, + pub revision: String, + pub start: Option, + pub limit: Option, +} +#[derive(Serialize, JsonSchema)] +#[schemars(rename = "BinaryEdited")] +pub struct Edited { + pub operation: Operation, + pub status: Status, +} + +pub(super) fn unsupported() -> ApiError { + ApiError::invalid("Binary editing is unavailable for this platform or storage configuration") + .with_reason("binary_edit_unsupported") +} +pub(super) fn describe( + entry: &Entry, + content: &Content, + supported: bool, + decision: FileDecision, +) -> Status { + let status = Status { + metadata: content.metadata(), + read_only: entry.read_only, + prepared: false, + dirty: false, + can_undo: false, + can_redo: false, + base_sha256: None, + pending_operation: None, + edit_supported: supported && !entry.read_only && content.metadata().within_edit_size_limit, + edit_decision: decision, + }; + #[cfg(any(target_os = "macos", target_os = "linux"))] + let status = { + let mut status = status; + if let Content::Draft(editor) = content { + status.prepared = true; + status.dirty = editor.draft().can_undo(); + status.can_undo = editor.draft().can_undo(); + status.can_redo = editor.draft().can_redo(); + status.base_sha256 = Some(editor.draft().base().sha256().into()); + status.pending_operation = editor.pending_operation(); + } + status + }; + status +} +fn platform_supported(state: &AppState) -> bool { + cfg!(any(target_os = "macos", target_os = "linux")) + && state.runtime.binary_documents().storage().is_ok() +} + +pub(super) async fn status( + State(state): State, + parts: Parts, + Path(id): Path, + Query(query): Query, +) -> Result>, ApiError> { + let (entry, location, authority) = owned(&state, &parts, &query.session_id, &id).await?; + let decision = state + .runtime + .binary_file_decision(&authority.turn, "edit", &entry.resource) + .await; + let supported = platform_supported(&state); + let result = work(&state, move || { + let content = entry.document.lock().map_err(|_| Error::Io)?; + content.validate_resource()?; + Ok(describe(&entry, &content, supported, decision)) + }) + .await?; + verify_read(&state, &parts, &authority).await?; + Ok(Json(Located { + location, + data: result, + })) +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +async fn permit_edit( + state: &AppState, + authority: &ReadAuthority, + entry: &Entry, + metadata: Value, +) -> Result { + if entry.read_only { + return Err(ApiError::forbidden("This binary document is read-only") + .with_reason("binary_read_only")); + } + let decision = state + .runtime + .binary_file_decision(&authority.turn, "edit", &entry.resource) + .await; + if decision == FileDecision::Deny { + return Err(files::denied(decision, "Permission denied: edit")); + } + state + .runtime + .authorize_binary_edit(&authority.turn, &entry.resource, metadata) + .await + .map_err(|message| files::denied(decision, &message))?; + Ok(decision) +} + +pub(super) async fn prepare( + State(state): State, + parts: Parts, + Path(id): Path, + Json(input): Json, +) -> Result>, ApiError> { + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + let _ = (state, parts, id, input); + Err(unsupported()) + } + #[cfg(any(target_os = "macos", target_os = "linux"))] + { + let (entry, location, authority) = owned(&state, &parts, &input.session_id, &id).await?; + let storage = state + .runtime + .binary_documents() + .storage() + .map_err(|_| unsupported())?; + let root = entry.root.clone(); + work(&state, move || storage.admit_project(&root)) + .await + .map_err(|_| unsupported())?; + let grant = permit_edit( + &state, + &authority, + &entry, + serde_json::json!({"binary_operation": "prepare"}), + ) + .await?; + verify_read(&state, &parts, &authority).await?; + let admission = EditAdmission { + turn: authority.turn.clone(), + grant, + authority: state.runtime.capture_child_admission(&input.session_id)?, + }; + let storage = state + .runtime + .binary_documents() + .storage() + .map_err(|_| unsupported())?; + let turn = authority.turn.clone(); + let resource = entry.resource.clone(); + let result = state + .runtime + .binary_file_work( + turn.clone(), + id.clone(), + resource, + (authority.grant, grant), + move |cancel| { + let mut content = entry.document.lock().map_err(|_| Error::Io)?; + if content.metadata().revision != input.revision { + return Err(Error::Changed); + } + content.validate_resource()?; + if let Content::Live(document) = &mut *content { + let owner = Owner { + session_id: turn.session_id, + directory: std::fs::canonicalize(&turn.directory) + .map_err(|_| Error::File)?, + root: entry.root.clone(), + scope: DraftScope::Manual, + }; + let editor = Editor::create(storage.open(&id)?, owner, document, || { + cancel.is_cancelled() + })?; + *content = Content::Draft(Box::new(editor)); + entry.editing.store(true, Ordering::Release); + } + *entry.edit_admission.lock().map_err(|_| Error::Io)? = Some(admission); + Ok(describe(&entry, &content, true, grant)) + }, + ) + .await? + .map_err(refused)?; + verify_read(&state, &parts, &authority).await?; + Ok(Json(Located { + location, + data: result, + })) + } +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +async fn staged_admission( + state: &AppState, + authority: &ReadAuthority, + entry: &Entry, +) -> Result { + if entry.read_only { + return Err(ApiError::forbidden("This binary document is read-only") + .with_reason("binary_read_only")); + } + let cached = entry + .edit_admission + .lock() + .map_err(|_| refused(Error::Io))? + .clone() + .ok_or_else(|| { + ApiError::forbidden("Prepare this draft under current edit authority") + .with_reason("binary_edit_admission_required") + })?; + if cached.turn != authority.turn + || cached + .authority + .verify(&state.runtime, &authority.turn.session_id) + .is_err() + { + return Err( + ApiError::forbidden("Edit scope changed; prepare this draft again") + .with_reason("binary_authority_changed"), + ); + } + let decision = state + .runtime + .binary_file_decision(&authority.turn, "edit", &entry.resource) + .await; + if decision == FileDecision::Deny + || (decision == FileDecision::Ask && cached.grant != FileDecision::Ask) + { + return Err(files::denied(decision, "Binary edit authority changed")); + } + Ok(cached) +} + +pub(super) async fn patch( + State(state): State, + parts: Parts, + Path(id): Path, + Json(input): Json, +) -> Result>, ApiError> { + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + let _ = (state, parts, id, input); + Err(unsupported()) + } + #[cfg(any(target_os = "macos", target_os = "linux"))] + { + let (entry, location, authority) = owned(&state, &parts, &input.session_id, &id).await?; + let admission = staged_admission(&state, &authority, &entry).await?; + let grant = admission.grant; + let runtime = state.runtime.clone(); + let result = state + .runtime + .binary_file_work( + authority.turn.clone(), + id, + entry.resource.clone(), + (authority.grant, grant), + move |cancel| { + if cancel.is_cancelled() + || admission + .authority + .verify(&runtime, &admission.turn.session_id) + .is_err() + { + return Err(Error::Cancelled); + } + let mut content = entry.document.lock().map_err(|_| Error::Io)?; + content.validate_resource()?; + let Content::Draft(editor) = &mut *content else { + return Err(Error::Invalid); + }; + let operation = editor.patch( + &input.operation_id, + &PatchBatch { + revision: input.revision, + splices: input.splices, + }, + )?; + Ok(Edited { + operation, + status: describe(&entry, &content, true, grant), + }) + }, + ) + .await? + .map_err(refused)?; + verify_read(&state, &parts, &authority).await?; + Ok(Json(Located { + location, + data: result, + })) + } +} + +pub(super) async fn history( + State(state): State, + parts: Parts, + Path(id): Path, + Json(input): Json, +) -> Result>, ApiError> { + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + let _ = (state, parts, id, input); + Err(unsupported()) + } + #[cfg(any(target_os = "macos", target_os = "linux"))] + { + let (entry, location, authority) = owned(&state, &parts, &input.session_id, &id).await?; + let admission = staged_admission(&state, &authority, &entry).await?; + let grant = admission.grant; + let runtime = state.runtime.clone(); + let result = state + .runtime + .binary_file_work( + authority.turn.clone(), + id, + entry.resource.clone(), + (authority.grant, grant), + move |cancel| { + if cancel.is_cancelled() + || admission + .authority + .verify(&runtime, &admission.turn.session_id) + .is_err() + { + return Err(Error::Cancelled); + } + let mut content = entry.document.lock().map_err(|_| Error::Io)?; + content.validate_resource()?; + let Content::Draft(editor) = &mut *content else { + return Err(Error::Invalid); + }; + let operation = match input.action { + HistoryAction::Undo => { + editor.history(&input.operation_id, &input.revision, false)? + } + HistoryAction::Redo => { + editor.history(&input.operation_id, &input.revision, true)? + } + HistoryAction::Discard => { + editor.discard(&input.operation_id, &input.revision)? + } + }; + Ok(Edited { + operation, + status: describe(&entry, &content, true, grant), + }) + }, + ) + .await? + .map_err(refused)?; + verify_read(&state, &parts, &authority).await?; + Ok(Json(Located { + location, + data: result, + })) + } +} + +pub(super) async fn review( + State(state): State, + parts: Parts, + Path(id): Path, + Json(input): Json, +) -> Result>, ApiError> { + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + let _ = (state, parts, id, input); + Err(unsupported()) + } + #[cfg(any(target_os = "macos", target_os = "linux"))] + { + let (entry, location, authority) = owned(&state, &parts, &input.session_id, &id).await?; + let cancellation = tokio_util::sync::CancellationToken::new(); + let _cancel_on_drop = cancellation.clone().drop_guard(); + let result = work(&state, move || { + let content = entry.document.lock().map_err(|_| Error::Io)?; + content.validate_resource()?; + let Content::Draft(editor) = &*content else { + return Err(Error::Invalid); + }; + if editor.draft().revision() != input.revision { + return Err(Error::Changed); + } + editor.review(input.start.unwrap_or(0), input.limit.unwrap_or(128), || { + cancellation.is_cancelled() + }) + }) + .await?; + verify_read(&state, &parts, &authority).await?; + Ok(Json(Located { + location, + data: result, + })) + } +} diff --git a/crates/cyber-server/src/http/binary_files/recovery.rs b/crates/cyber-server/src/http/binary_files/recovery.rs new file mode 100644 index 000000000..f1fdec64a --- /dev/null +++ b/crates/cyber-server/src/http/binary_files/recovery.rs @@ -0,0 +1,188 @@ +//! Owner-index discovery and explicit recovery never replay a source mutation. +use super::*; +#[cfg(any(target_os = "macos", target_os = "linux"))] +use cyber_core::binary::editor::{DraftScope, Editor, Owner}; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RecoveryQuery { + pub session_id: String, + pub after: Option, + pub limit: Option, +} +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(rename = "BinaryRecoverRequest")] +pub struct RecoverRequest { + pub session_id: String, + #[serde(default)] + pub read_only: bool, +} +#[derive(Serialize, JsonSchema)] +#[schemars(rename = "BinaryRecoveryEntry")] +pub struct RecoveryEntry { + pub document_id: String, + pub path: String, + pub record_available: bool, +} +#[derive(Serialize, JsonSchema)] +#[schemars(rename = "BinaryRecoveryList")] +pub struct RecoveryList { + pub documents: Vec, + pub next_cursor: Option, +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +fn owner(turn: &TurnContext, root: PathBuf) -> Result { + Ok(Owner { + session_id: turn.session_id.clone(), + directory: std::fs::canonicalize(&turn.directory).map_err(|_| Error::File)?, + root, + scope: DraftScope::Manual, + }) +} + +pub(super) async fn list( + State(state): State, + parts: Parts, + Query(query): Query, +) -> Result>, ApiError> { + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + let _ = (state, parts, query); + Err(editing::unsupported()) + } + #[cfg(any(target_os = "macos", target_os = "linux"))] + { + let (turn, root, _) = files::scope(&state, &parts, Some(&query.session_id)).await?; + let owner = owner(&turn, root.clone()).map_err(refused)?; + let storage = state + .runtime + .binary_documents() + .storage() + .map_err(refused)?; + let listing = work(&state, move || { + storage.list(&owner, query.after.as_deref(), query.limit.unwrap_or(64)) + }) + .await?; + let mut documents = Vec::new(); + for entry in listing.documents { + // Discovery discloses names only under current ordinary read rules. + let decisions = state + .runtime + .file_decisions(&turn, "read", &[root.join(&entry.path)]) + .await; + if decisions.first() != Some(&FileDecision::Deny) { + documents.push(RecoveryEntry { + document_id: entry.document_id, + path: entry.path, + record_available: entry.record_available, + }); + } + } + let (fresh, fresh_root, _) = files::scope(&state, &parts, Some(&query.session_id)).await?; + if fresh != turn || fresh_root != root { + return Err(ApiError::forbidden("Binary recovery scope changed")); + } + Ok(Json(Located { + location: LocationInfo::of(FilePath::new(&turn.directory)), + data: RecoveryList { + documents, + next_cursor: listing.next_cursor, + }, + })) + } +} + +pub(super) async fn recover( + State(state): State, + parts: Parts, + Path(id): Path, + Json(input): Json, +) -> Result>, ApiError> { + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + let _ = (state, parts, id, input); + Err(editing::unsupported()) + } + #[cfg(any(target_os = "macos", target_os = "linux"))] + { + let (turn, root, _) = files::scope(&state, &parts, Some(&input.session_id)).await?; + let owner = owner(&turn, root.clone()).map_err(refused)?; + let storage = state + .runtime + .binary_documents() + .storage() + .map_err(refused)?; + let resource = { + let (storage, owner, id) = (storage.clone(), owner.clone(), id.clone()); + work(&state, move || storage.resource(&owner, &id)) + .await + .map_err(|_| ApiError::not_found("NotFoundError", "No such binary document"))? + }; + let grant = permit_read(&state, &turn, &resource).await?; + let authority = ReadAuthority { + turn: turn.clone(), + root, + grant, + resource, + }; + verify_read(&state, &parts, &authority).await?; + let lifecycle = state.runtime.memory_mutation_lease().await?; + let registry = state.runtime.clone(); + let result = work(&state, move || { + let _lifecycle = lifecycle; + let editor = Editor::resume(storage.open_existing(&id)?, &owner)?; + editor.validate_read_resource()?; + registry + .binary_documents() + .insert_recovered(id, owner, editor, input.read_only) + }) + .await?; + verify_read(&state, &parts, &authority).await?; + let decision = state + .runtime + .binary_file_decision(&turn, "edit", &result.resource) + .await; + let status = work(&state, move || { + let content = result.document.lock().map_err(|_| Error::Io)?; + Ok(editing::describe(&result, &content, true, decision)) + }) + .await?; + Ok(Json(Located { + location: LocationInfo::of(FilePath::new(&turn.directory)), + data: status, + })) + } +} + +pub(super) async fn operation( + State(state): State, + parts: Parts, + Path((id, operation)): Path<(String, String)>, + Query(query): Query, +) -> Result>>, ApiError> { + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + let _ = (state, parts, id, operation, query); + Err(editing::unsupported()) + } + #[cfg(any(target_os = "macos", target_os = "linux"))] + { + let (entry, location, authority) = owned(&state, &parts, &query.session_id, &id).await?; + let result = work(&state, move || { + let content = entry.document.lock().map_err(|_| Error::Io)?; + content.validate_resource()?; + let crate::runtime::binary_documents::Content::Draft(editor) = &*content else { + return Err(Error::Invalid); + }; + editor.lookup_operation(&operation) + }) + .await?; + verify_read(&state, &parts, &authority).await?; + Ok(Json(Located { + location, + data: result, + })) + } +} diff --git a/crates/cyber-server/src/http/files.rs b/crates/cyber-server/src/http/files.rs index 5bb66d250..87e32bc93 100644 --- a/crates/cyber-server/src/http/files.rs +++ b/crates/cyber-server/src/http/files.rs @@ -103,7 +103,7 @@ fn refused(refusal: Refusal) -> ApiError { } } -fn denied(decision: FileDecision, message: &str) -> ApiError { +pub(super) fn denied(decision: FileDecision, message: &str) -> ApiError { let decision = match decision { FileDecision::Allow => "allow", FileDecision::Ask => "ask", diff --git a/crates/cyber-server/src/http/idempotency.rs b/crates/cyber-server/src/http/idempotency.rs index ea853c43d..eab35400f 100644 --- a/crates/cyber-server/src/http/idempotency.rs +++ b/crates/cyber-server/src/http/idempotency.rs @@ -42,13 +42,19 @@ fn in_flight() -> &'static Mutex> { KEYS.get_or_init(Mutex::default) } +fn bypass_response_cache(req: &Request) -> bool { + matches!(*req.method(), Method::GET | Method::HEAD | Method::OPTIONS) + || req.uri().path().contains("/files/binary/") + || req.uri().path().contains("/collaboration/owner/") +} + pub async fn layer(State(state): State, req: Request, next: Next) -> Response { // Hub owner issuance may return one-time invitations or join codes. The hub's // operation_id handles retries; never persist those response bodies locally. - if req.uri().path().contains("/collaboration/owner/") { - return next.run(req).await; - } - if matches!(*req.method(), Method::GET | Method::HEAD | Method::OPTIONS) { + // Binary routes always refresh Session/Location/read authority. Their private + // operation ledger handles mutation retries; generic response replay must not + // persist byte reviews or return data after permission revocation. + if bypass_response_cache(&req) { return next.run(req).await; } let Some(key) = req diff --git a/crates/cyber-server/src/http/openapi.rs b/crates/cyber-server/src/http/openapi.rs index 3b7ccc22a..4de26805c 100644 --- a/crates/cyber-server/src/http/openapi.rs +++ b/crates/cyber-server/src/http/openapi.rs @@ -1574,11 +1574,27 @@ pub fn operations(g: &mut SchemaGenerator) -> Vec { "post", "/files/binary/documents/{id}/search", "v1.fs.binarySearch", - "Search live bytes with bounded scanning and revision-bound continuation", + "Search live or draft bytes with bounded scanning and revision-bound continuation", ) .located() .body::(g) .ok::>(g), + op("get", "/files/binary/documents/{id}", "v1.fs.binaryStatus", "Read current binary draft status under fresh owner authority") + .located().query(&["session_id"]).ok::>(g), + op("post", "/files/binary/documents/{id}/prepare", "v1.fs.binaryPrepare", "Prepare a durable private draft after manual edit admission; no source mutation") + .located().body::(g).ok::>(g), + op("post", "/files/binary/documents/{id}/patches", "v1.fs.binaryPatch", "Apply an atomic expected-byte splice batch with a stable operation ID") + .located().body::(g).ok::>(g), + op("post", "/files/binary/documents/{id}/history", "v1.fs.binaryHistory", "Undo, redo or discard local draft commands without changing the source") + .located().body::(g).ok::>(g), + op("post", "/files/binary/documents/{id}/review", "v1.fs.binaryReview", "Review original-offset byte ranges and full fingerprints; no source mutation") + .located().body::(g).ok::>(g), + op("get", "/files/binary/recovery", "v1.fs.binaryRecovery", "Discover retained manual drafts in the exact Session Location") + .located().query(&["session_id", "after", "limit"]).ok::>(g), + op("post", "/files/binary/documents/{id}/recover", "v1.fs.binaryRecover", "Explicitly reopen a retained private draft; never replay a save or grant mutation authority") + .located().body::(g).ok::>(g), + op("get", "/files/binary/documents/{id}/operations/{operation}", "v1.fs.binaryOperation", "Query the durable receipt; absence is refused while journal storage is fenced") + .located().query(&["session_id"]).ok::>>(g), op( "delete", "/files/binary/documents/{id}", diff --git a/crates/cyber-server/src/runtime/activity.rs b/crates/cyber-server/src/runtime/activity.rs index 77b400b50..1a6c297f1 100644 --- a/crates/cyber-server/src/runtime/activity.rs +++ b/crates/cyber-server/src/runtime/activity.rs @@ -10,6 +10,7 @@ tokio::task_local! { static CANCEL: CancellationToken; } pub(super) struct Control { pub source: String, + pub binary_document: Option, pub admission_sessions: std::collections::HashSet, pub stop: CancellationToken, pub done: CancellationToken, @@ -19,6 +20,9 @@ pub(super) struct Record { pub session_id: String, pub status: String, pub phase: String, + /// Trusted synchronous filesystem-only workers; no subprocess can escape this scope. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub binary_document: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub admission_bindings: Option>, } @@ -41,6 +45,21 @@ impl Scope { self.authority.clone() } pub async fn reserve(inner: &Inner, handle: &Handle) -> Result { + Self::reserve_kind(inner, handle, None).await + } + #[cfg(any(target_os = "macos", target_os = "linux"))] + pub async fn reserve_binary( + inner: &Inner, + handle: &Handle, + document: String, + ) -> Result { + Self::reserve_kind(inner, handle, Some(document)).await + } + async fn reserve_kind( + inner: &Inner, + handle: &Handle, + binary_document: Option, + ) -> Result { let runtime = Runtime { inner: inner.me.upgrade().ok_or(RuntimeError::ShuttingDown)?, }; @@ -52,6 +71,7 @@ impl Scope { session_id: source.clone(), status: "pending".into(), phase: "reserved".into(), + binary_document, admission_bindings: Some(authority.bindings.clone()), }; inner @@ -59,6 +79,7 @@ impl Scope { .append(&id, Expected::Seq(-1), vec![change(&record)])?; let control = Arc::new(Control { source: source.clone(), + binary_document: record.binary_document.clone(), admission_sessions: super::admission_authority::binding_sessions(&authority.bindings), stop: inner.closed.child_token(), done: CancellationToken::new(), @@ -120,10 +141,22 @@ impl Drop for Scope { fn drop(&mut self) { self.control.stop.cancel(); if !self.settled { - if self.launched { + let filesystem_only = self.record.binary_document.is_some(); + if self.launched && !filesystem_only { self.handle.location_uncertain.store(true, Ordering::SeqCst); } - self.record.status = if self.launched { "unknown" } else { "settled" }.into(); + // This trusted scope owns only synchronous filesystem calls. Once + // its actual worker unwinds, it has no escaped task/process to settle; + // possible file effects remain in the separate document intent. + self.record.status = if self.launched && !filesystem_only { + "unknown" + } else { + "settled" + } + .into(); + if filesystem_only { + self.record.phase = "filesystem_worker_disposed_query_document".into(); + } self.record.admission_bindings = None; if let Err(error) = self.store @@ -175,6 +208,9 @@ impl Inner { if controls.is_empty() { return Ok(()); } + let filesystem_only = controls + .iter() + .all(|control| control.binary_document.is_some()); let acknowledged = tokio::time::timeout(std::time::Duration::from_secs(2), async { for control in controls { control.done.cancelled().await; @@ -183,7 +219,15 @@ impl Inner { .await; let handle = self.handle(source).await?; if acknowledged.is_err() { - handle.location_uncertain.store(true, Ordering::SeqCst); + if !filesystem_only { + handle.location_uncertain.store(true, Ordering::SeqCst); + } + return Err(RuntimeError::Invalid(if filesystem_only { + "Binary filesystem work is still settling; query its operation before retrying" + .into() + } else { + "Idle operation did not acknowledge interruption; recovery is required".into() + })); } if handle.location_uncertain.load(Ordering::SeqCst) { return Err(RuntimeError::Invalid( diff --git a/crates/cyber-server/src/runtime/binary_documents.rs b/crates/cyber-server/src/runtime/binary_documents.rs index ab2538103..1c0ea20c3 100644 --- a/crates/cyber-server/src/runtime/binary_documents.rs +++ b/crates/cyber-server/src/runtime/binary_documents.rs @@ -5,12 +5,75 @@ use std::sync::{Arc, Mutex}; use cyber_core::binary::{Document, Error, Metadata}; +pub(crate) enum Content { + Live(Document), + #[cfg(any(target_os = "macos", target_os = "linux"))] + Draft(Box), +} +impl Content { + pub fn metadata(&self) -> Metadata { + match self { + Self::Live(doc) => doc.metadata(), + #[cfg(any(target_os = "macos", target_os = "linux"))] + Self::Draft(editor) => Metadata { + path: cyber_core::vcs::slash_path(&editor.target().relative), + length: editor.draft().length(), + revision: editor.draft().revision(), + source: cyber_core::binary::Source::Draft, + within_edit_size_limit: true, + }, + } + } + pub fn validate_resource(&self) -> Result<(), Error> { + match self { + Self::Live(doc) => doc.validate(), + #[cfg(any(target_os = "macos", target_os = "linux"))] + Self::Draft(editor) => editor.validate_read_resource(), + } + } + pub fn page( + &mut self, + revision: &str, + offset: cyber_core::binary::Offset, + limit: usize, + ) -> Result { + match self { + Self::Live(doc) => doc.page(revision, offset, limit), + #[cfg(any(target_os = "macos", target_os = "linux"))] + Self::Draft(editor) => editor.draft().page(revision, offset, limit), + } + } + pub fn search( + &mut self, + request: &cyber_core::binary::SearchRequest, + cancelled: impl Fn() -> bool, + ) -> Result { + match self { + Self::Live(doc) => doc.search(request, cancelled), + #[cfg(any(target_os = "macos", target_os = "linux"))] + Self::Draft(editor) => editor.draft().search(request, cancelled), + } + } +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +#[derive(Clone)] +pub(crate) struct EditAdmission { + pub turn: super::TurnContext, + pub grant: super::FileDecision, + pub authority: super::AdmissionAuthority, +} + pub(crate) struct Entry { pub session_id: String, pub directory: String, pub root: PathBuf, - pub path: PathBuf, - pub document: Mutex, + pub resource: cyber_core::binary::Resource, + pub document: Mutex, + pub read_only: bool, + pub editing: std::sync::atomic::AtomicBool, + #[cfg(any(target_os = "macos", target_os = "linux"))] + pub edit_admission: Mutex>, used: Mutex, } @@ -19,6 +82,8 @@ pub(crate) struct Registry( Mutex>>, std::sync::OnceLock>, std::sync::OnceLock>, + #[cfg(any(target_os = "macos", target_os = "linux"))] + std::sync::OnceLock>, ); impl Registry { @@ -30,19 +95,33 @@ impl Registry { .map_err(|_| Error::Limit) } + pub fn storage(&self) -> Result, Error> { + self.2.get().cloned().ok_or(Error::File) + } + #[cfg(any(target_os = "macos", target_os = "linux"))] + pub fn edit_worker(&self) -> Result { + self.3 + .get_or_init(|| Arc::new(tokio::sync::Semaphore::new(4))) + .clone() + .try_acquire_owned() + .map_err(|_| Error::Limit) + } + pub fn insert( &self, session_id: String, directory: String, root: PathBuf, document: Document, + read_only: bool, ) -> Result<(String, Metadata), Error> { let mut documents = self.0.lock().map_err(|_| Error::Io)?; documents.retain(|_, entry| { - entry - .used - .lock() - .is_ok_and(|used| used.elapsed() < std::time::Duration::from_secs(24 * 60 * 60)) + entry.editing.load(std::sync::atomic::Ordering::Acquire) + || entry + .used + .lock() + .is_ok_and(|used| used.elapsed() < std::time::Duration::from_secs(24 * 60 * 60)) }); if documents.len() >= 16 || documents @@ -61,8 +140,12 @@ impl Registry { session_id, directory, root, - path: document.path(), - document: Mutex::new(document), + resource: document.resource(), + document: Mutex::new(Content::Live(document)), + read_only, + editing: std::sync::atomic::AtomicBool::new(false), + #[cfg(any(target_os = "macos", target_os = "linux"))] + edit_admission: Mutex::new(None), used: Mutex::new(std::time::Instant::now()), }), ); @@ -75,6 +158,64 @@ impl Registry { Some(entry) } + #[cfg(any(target_os = "macos", target_os = "linux"))] + pub fn insert_recovered( + &self, + id: String, + owner: cyber_core::binary::editor::Owner, + editor: cyber_core::binary::editor::Editor, + read_only: bool, + ) -> Result, Error> { + let mut documents = self.0.lock().map_err(|_| Error::Io)?; + if documents.contains_key(&id) + || documents.len() >= 16 + || documents + .values() + .filter(|entry| entry.session_id == owner.session_id) + .count() + >= 4 + { + return Err(Error::Limit); + } + let entry = Arc::new(Entry { + session_id: owner.session_id, + directory: owner.directory.to_str().ok_or(Error::File)?.into(), + root: owner.root, + resource: editor.resource(), + document: Mutex::new(Content::Draft(Box::new(editor))), + read_only, + editing: std::sync::atomic::AtomicBool::new(true), + edit_admission: Mutex::new(None), + used: Mutex::new(std::time::Instant::now()), + }); + documents.insert(id, entry.clone()); + Ok(entry) + } + + #[cfg(any(target_os = "macos", target_os = "linux"))] + pub fn check_session_deletion(&self, ids: &[String]) -> Result<(), Error> { + // Idle scopes have already been stopped and fenced. Drop clean cache handles + // so the durable index can acquire each exclusive journal lock. + let mut documents = self.0.lock().map_err(|_| Error::Io)?; + for entry in documents + .values() + .filter(|entry| ids.contains(&entry.session_id)) + { + let content = entry.document.try_lock().map_err(|_| Error::Changed)?; + if let Content::Draft(editor) = &*content + && (editor.unresolved() || editor.draft().can_undo()) + { + return Err(Error::Changed); + } + } + documents.retain(|_, entry| !ids.contains(&entry.session_id)); + drop(documents); + if let Some(storage) = self.2.get() { + storage.check_session_deletion(ids)?; + } + Ok(()) + } + pub fn remove_sessions(&self, ids: &[String]) { if let Ok(mut documents) = self.0.lock() { documents.retain(|_, entry| !ids.contains(&entry.session_id)); @@ -110,3 +251,113 @@ impl super::Runtime { &self.inner.binary_documents } } + +impl super::Runtime { + /// Filesystem-only work retains its actual native Location/path/lifecycle owners + /// even if the HTTP request is disposed. It never launches a subprocess. + #[cfg(any(target_os = "macos", target_os = "linux"))] + pub(crate) async fn binary_file_work( + &self, + turn: super::TurnContext, + document: String, + resource: cyber_core::binary::Resource, + grants: (super::FileDecision, super::FileDecision), + work: impl FnOnce(tokio_util::sync::CancellationToken) -> T + Send + 'static, + ) -> Result { + let edit_permit = self + .binary_documents() + .edit_worker() + .map_err(|error| super::RuntimeError::Invalid(error.to_string()))?; + let lifecycle = self.memory_mutation_lease().await?; + self.inner.ensure_idle(&turn.session_id)?; + let handle = self.inner.handle(&turn.session_id).await?; + let info = handle.state.lock().await.info.clone(); + if self + .file_turn( + Some(&turn.session_id), + std::path::Path::new(&turn.directory), + ) + .await? + != turn + { + return Err(super::RuntimeError::Conflict( + "Binary operation scope changed".into(), + )); + } + let mut activity = + super::activity::Scope::reserve_binary(&self.inner, &handle, document).await?; + let cancel = activity.control.stop.clone(); + let _cancel_on_disposal = cancel.clone().drop_guard(); + let location = self + .inner + .claim_location(&info, false, cancel.clone()) + .await?; + if location.worktree_id != info.worktree_id { + return Err(super::RuntimeError::Conflict( + "Binary checkout binding changed".into(), + )); + } + // This scope owns no subprocess. The retained proof keeps the native + // checkout lock through every synchronous filesystem call, including crash. + let location = location + .settle_retained() + .map_err(super::RuntimeError::Invalid)?; + let paths = tokio::select! { + result = self.inner.tools.file_mutation_guard(&turn, &resource) => result.map_err(super::RuntimeError::Invalid)?, + _ = cancel.cancelled() => return Err(super::RuntimeError::Invalid("Binary operation cancelled before mutation".into())), + }; + if self + .file_turn( + Some(&turn.session_id), + std::path::Path::new(&turn.directory), + ) + .await? + != turn + { + return Err(super::RuntimeError::Conflict( + "Binary operation scope changed".into(), + )); + } + self.verify_binary_grants(&turn, &resource, grants).await?; + let permit = self + .binary_documents() + .worker() + .map_err(|error| super::RuntimeError::Invalid(error.to_string()))?; + tokio::task::spawn_blocking(move || { + let (_lifecycle, _location, _paths, _permit, _edit_permit) = + (lifecycle, location, paths, permit, edit_permit); + activity.launch()?; + let result = work(cancel); + // A returned unknown file outcome is not a live/unowned native process. + // Its durable intent remains unresolved in the document journal. + activity.settle()?; + Ok(result) + }) + .await + .map_err(|_| { + super::RuntimeError::Conflict( + "Binary worker interrupted; query its operation before retrying".into(), + ) + })? + } + #[cfg(any(target_os = "macos", target_os = "linux"))] + async fn verify_binary_grants( + &self, + turn: &super::TurnContext, + resource: &cyber_core::binary::Resource, + grants: (super::FileDecision, super::FileDecision), + ) -> Result<(), super::RuntimeError> { + for (action, granted) in [("read", grants.0), ("edit", grants.1)] { + let decision = Some(self.binary_file_decision(turn, action, resource).await); + if !matches!(decision, Some(super::FileDecision::Allow)) + && !(matches!(decision, Some(super::FileDecision::Ask)) + && granted == super::FileDecision::Ask) + { + return Err(super::RuntimeError::Conflict( + "Binary file authority changed".into(), + )); + } + } + Ok(()) + } +} diff --git a/crates/cyber-server/src/runtime/binary_documents_tests.rs b/crates/cyber-server/src/runtime/binary_documents_tests.rs new file mode 100644 index 000000000..10282e8f7 --- /dev/null +++ b/crates/cyber-server/src/runtime/binary_documents_tests.rs @@ -0,0 +1,215 @@ +//! Runtime ownership regressions use a deterministic filesystem-only worker barrier. +use super::*; +use futures::future::BoxFuture; +use std::sync::Arc; +use tokio_util::sync::CancellationToken; + +struct Models; +impl ModelResolver for Models { + fn role(&self, _: cyber_llm::catalog::ModelRole) -> Option { + None + } + fn resolve(&self, _: &str) -> Result { + Err("inference is forbidden in this test".into()) + } +} +struct Host(Arc); +impl ToolHost for Host { + fn definitions(&self, _: &TurnContext) -> Vec { + vec![] + } + fn execute(&self, _: Invocation, _: CancellationToken) -> BoxFuture<'_, ToolOutcome> { + Box::pin(async { panic!("filesystem-only worker invoked a model tool") }) + } + fn binary_file_decision<'a>( + &'a self, + _: &'a TurnContext, + _: &'a str, + _: &'a cyber_core::binary::Resource, + ) -> BoxFuture<'a, FileDecision> { + Box::pin(async { FileDecision::Allow }) + } + fn file_mutation_guard<'a>( + &'a self, + _: &'a TurnContext, + _: &'a cyber_core::binary::Resource, + ) -> BoxFuture<'a, Result, String>> { + Box::pin(async { + Ok( + Box::new(self.0.clone().acquire_owned().await.map_err(|_| "closed")?) + as Box, + ) + }) + } +} +async fn fixture() -> ( + tempfile::TempDir, + Runtime, + String, + cyber_core::binary::Resource, + Arc, +) { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().canonicalize().unwrap(); + std::fs::write(root.join("bytes"), [0, 255]).unwrap(); + let resource = cyber_core::binary::Document::open(&root, "bytes") + .unwrap() + .resource(); + let paths = Arc::new(tokio::sync::Semaphore::new(1)); + let runtime = Runtime::new(RuntimeOptions { + store: Arc::new( + cyber_store::Store::open(cyber_store::StoreOptions::new( + cyber_core::paths::DatabaseLocation::Memory, + Runtime::registry(), + )) + .unwrap(), + ), + resolver: Arc::new(Models), + tools: Arc::new(Host(paths.clone())), + global_config_dir: root.join("config"), + shell: "test".into(), + claude_compat: false, + compaction: Default::default(), + retry: Default::default(), + max_steps: None, + today: None, + interactive: false, + snapshots: Arc::new(NoSnapshots), + }); + let session = runtime + .create_session(CreateSession { + directory: root.to_str().unwrap().into(), + model: "test/main".into(), + ..Default::default() + }) + .await + .unwrap() + .id; + (temp, runtime, session, resource, paths) +} + +#[tokio::test] +async fn disposed_binary_request_retains_worker_ownership_until_actual_exit() { + let (temp, runtime, session, resource, paths) = fixture().await; + let turn = runtime + .file_turn(Some(&session), temp.path()) + .await + .unwrap(); + let (started, running) = tokio::sync::oneshot::channel(); + let (finish, wait) = std::sync::mpsc::channel(); + let task = { + let runtime = runtime.clone(); + tokio::spawn(async move { + runtime + .binary_file_work( + turn, + "bin_worker".into(), + resource, + (FileDecision::Allow, FileDecision::Allow), + move |cancel| { + started.send(()).unwrap(); + wait.recv().unwrap(); + assert!(cancel.is_cancelled()); + }, + ) + .await + }) + }; + running.await.unwrap(); + assert_eq!(paths.available_permits(), 0); + task.abort(); + assert!(task.await.unwrap_err().is_cancelled()); + let controls = runtime.inner.cancel_idle_activities(&session); + assert_eq!(controls.len(), 1); + assert!( + runtime + .inner + .wait_idle_activities(&session, controls) + .await + .is_err() + ); + assert_eq!( + paths.available_permits(), + 0, + "timed-out Stop released the actual worker guard" + ); + let handle = runtime.inner.handle(&session).await.unwrap(); + assert!( + !handle + .location_uncertain + .load(std::sync::atomic::Ordering::SeqCst) + ); + let shutdown = { + let runtime = runtime.clone(); + tokio::spawn(async move { runtime.shutdown().await }) + }; + tokio::task::yield_now().await; + assert!( + !shutdown.is_finished(), + "shutdown outlived an unjoined filesystem worker" + ); + finish.send(()).unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(5), shutdown) + .await + .unwrap() + .unwrap(); + assert_eq!(paths.available_permits(), 1); + assert!(runtime.inner.activities.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn unwound_binary_worker_does_not_claim_file_success_or_poison_native_process_recovery() { + let (temp, runtime, session, resource, paths) = fixture().await; + let turn = runtime + .file_turn(Some(&session), temp.path()) + .await + .unwrap(); + let result = runtime + .binary_file_work( + turn, + "bin_panic".into(), + resource, + (FileDecision::Allow, FileDecision::Allow), + |_| panic!("injected filesystem unwind"), + ) + .await; + assert!(result.is_err()); + assert_eq!(paths.available_permits(), 1); + let handle = runtime.inner.handle(&session).await.unwrap(); + assert!( + !handle + .location_uncertain + .load(std::sync::atomic::Ordering::SeqCst) + ); + assert!(runtime.inner.activities.lock().unwrap().is_empty()); + runtime.shutdown().await; +} + +#[tokio::test] +async fn session_deletion_does_not_infer_settlement_from_a_missing_local_binary_control() { + let (_temp, runtime, session, _resource, _paths) = fixture().await; + let handle = runtime.inner.handle(&session).await.unwrap(); + let mut activity = super::activity::Scope::reserve_binary( + &runtime.inner, + &handle, + "bin_external_worker".into(), + ) + .await + .unwrap(); + activity.launch().unwrap(); + // Simulate a worker owned by a different process/runtime registry. Durable + // activity remains authoritative even before a document journal exists. + runtime + .inner + .activities + .lock() + .unwrap() + .remove(activity.id()); + let result = runtime.delete(&session).await; + assert!( + matches!(result, Err(RuntimeError::Conflict(message)) if message.contains("Binary filesystem activity")) + ); + assert!(runtime.inner.handle(&session).await.is_ok()); + drop(activity); + runtime.shutdown().await; +} diff --git a/crates/cyber-server/src/runtime/deletion.rs b/crates/cyber-server/src/runtime/deletion.rs index 97f28d0d7..e5a425d06 100644 --- a/crates/cyber-server/src/runtime/deletion.rs +++ b/crates/cyber-server/src/runtime/deletion.rs @@ -27,9 +27,9 @@ impl Runtime { self.fence_deletion(session_id, &ids)?; ids }; - for id in &ids { - self.computer_stop(id); - } + self.settle_deletion_activity(&ids) + .await + .inspect_err(|_| self.lift_deletion_fence(&ids))?; // SessionEnd is observational and never blocks the deletion it reports. self.inner.session_end(&handle, "delete").await; self.settle_hook_work(&ids).await; @@ -108,6 +108,50 @@ impl Runtime { Ok(()) } + /// Keep all refusal work before SessionEnd and durable Session deletion. + async fn settle_deletion_activity(&self, ids: &[String]) -> Result<(), RuntimeError> { + for id in ids { + self.computer_stop(id); + } + for id in ids { + let controls = self.inner.cancel_idle_activities(id); + self.inner.wait_idle_activities(id, controls).await?; + } + // Another runtime/process can own a filesystem worker whose Store lock + // has not yet been acquired. A missing local control is not settlement. + let sources = ids.to_vec(); + let unresolved = self.inner.store.read(move |db| { + for id in sources { + let retained: bool = db.query_row( + "SELECT EXISTS(SELECT 1 FROM event e WHERE type='native.activity.changed.1' AND json_extract(data,'$.session_id')=?1 AND json_extract(data,'$.binary_document') IS NOT NULL AND json_extract(data,'$.status')<>'settled' AND NOT EXISTS(SELECT 1 FROM event n WHERE n.aggregate_id=e.aggregate_id AND n.seq>e.seq))", + [&id], |row| row.get(0))?; + if retained { return Ok(true); } + } + Ok(false) + })?; + if unresolved { + return Err(RuntimeError::Conflict( + "Binary filesystem activity requires recovery before Session deletion".into(), + )); + } + #[cfg(any(target_os = "macos", target_os = "linux"))] + { + let runtime = self.clone(); + let doomed = ids.to_vec(); + let checked = tokio::task::spawn_blocking(move || { + runtime.binary_documents().check_session_deletion(&doomed) + }) + .await; + if !matches!(checked, Ok(Ok(()))) { + return Err(RuntimeError::Conflict( + "Binary drafts or receipts require recovery/discard before Session deletion" + .into(), + )); + } + } + Ok(()) + } + /// What the Sessions own outside the database: the copies of the files they sent /// (`/sent//`), their browser evidence artifacts /// (`/browser/artifacts//`), their prompt attachments diff --git a/crates/cyber-server/src/runtime/files.rs b/crates/cyber-server/src/runtime/files.rs index d2eb809ba..57edf5165 100644 --- a/crates/cyber-server/src/runtime/files.rs +++ b/crates/cyber-server/src/runtime/files.rs @@ -57,4 +57,50 @@ impl Runtime { .authorize_file(turn, action, path, asker, self.inner.closed.child_token()) .await } + pub async fn authorize_binary_edit( + &self, + turn: &TurnContext, + resource: &cyber_core::binary::Resource, + review: serde_json::Value, + ) -> Result<(), String> { + let asker = self + .operation_asker(&turn.session_id) + .await + .map_err(|error| error.to_string())?; + self.inner + .tools + .authorize_binary_edit( + turn, + resource, + review, + asker, + self.inner.closed.child_token(), + ) + .await + } + pub async fn binary_file_decision( + &self, + turn: &TurnContext, + action: &str, + resource: &cyber_core::binary::Resource, + ) -> FileDecision { + self.inner + .tools + .binary_file_decision(turn, action, resource) + .await + } + pub async fn authorize_binary_read( + &self, + turn: &TurnContext, + resource: &cyber_core::binary::Resource, + ) -> Result<(), String> { + let asker = self + .operation_asker(&turn.session_id) + .await + .map_err(|error| error.to_string())?; + self.inner + .tools + .authorize_binary_read(turn, resource, asker, self.inner.closed.child_token()) + .await + } } diff --git a/crates/cyber-server/src/runtime/host.rs b/crates/cyber-server/src/runtime/host.rs index a96e1736e..b8140b77e 100644 --- a/crates/cyber-server/src/runtime/host.rs +++ b/crates/cyber-server/src/runtime/host.rs @@ -1208,6 +1208,49 @@ pub trait ToolHost: Send + Sync { Box::pin(async { Err("File access is unavailable in this host".into()) }) } + /// Decisions use the document's recorded canonical resource. Resolving this + /// name through a later symlink would check permission for the wrong bytes. + fn binary_file_decision<'a>( + &'a self, + _turn: &'a TurnContext, + _action: &'a str, + _resource: &'a cyber_core::binary::Resource, + ) -> BoxFuture<'a, FileDecision> { + Box::pin(async { FileDecision::Deny }) + } + fn authorize_binary_read<'a>( + &'a self, + _turn: &'a TurnContext, + _resource: &'a cyber_core::binary::Resource, + _asker: super::Asker, + _cancel: CancellationToken, + ) -> BoxFuture<'a, Result<(), String>> { + Box::pin(async { Err("Binary read admission is unavailable in this host".into()) }) + } + + /// Owner binary review uses the ordinary edit permission UI with bounded byte + /// diff metadata. Hosts must explicitly implement this admission boundary. + fn authorize_binary_edit<'a>( + &'a self, + _turn: &'a TurnContext, + _resource: &'a cyber_core::binary::Resource, + _review: Value, + _asker: super::Asker, + _cancel: CancellationToken, + ) -> BoxFuture<'a, Result<(), String>> { + Box::pin(async { Err("Binary editing is unavailable in this host".into()) }) + } + + /// Shared workspace then canonical path ownership, retained by the actual + /// blocking worker. Acquiring a guard alone grants no file authority. + fn file_mutation_guard<'a>( + &'a self, + _turn: &'a TurnContext, + _resource: &'a cyber_core::binary::Resource, + ) -> BoxFuture<'a, Result, String>> { + Box::pin(async { Err("File mutation ownership is unavailable in this host".into()) }) + } + /// Expand a planned command template once, before admission commits /// (`skills-commands` → Shell output injection, File and agent references): each /// `` !`cmd` `` span becomes its sandboxed output or a visible notice, and `@` diff --git a/crates/cyber-server/src/runtime/mod.rs b/crates/cyber-server/src/runtime/mod.rs index 412f98023..16d2c56da 100644 --- a/crates/cyber-server/src/runtime/mod.rs +++ b/crates/cyber-server/src/runtime/mod.rs @@ -2779,3 +2779,6 @@ impl Inner { mod workflow_solve; pub use workflow_solve::{SolveCandidateEvidence, SolveEvidence}; + +#[cfg(all(test, any(target_os = "macos", target_os = "linux")))] +mod binary_documents_tests; diff --git a/crates/cyber-tools/src/host.rs b/crates/cyber-tools/src/host.rs index dfbb68a55..889deefc7 100644 --- a/crates/cyber-tools/src/host.rs +++ b/crates/cyber-tools/src/host.rs @@ -812,6 +812,9 @@ impl BuiltinHost { /// Writes to one canonical path are serialized within the server. pub(crate) fn path_lock(&self, path: &Path) -> Arc> { + self.canonical_path_lock(&canonical(path)) + } + fn canonical_path_lock(&self, path: &Path) -> Arc> { Arc::clone( self.locks .lock() @@ -937,6 +940,56 @@ impl ToolHost for BuiltinHost { Box::pin(self.authorize_browse(turn, action, path, asker, cancel, Value::Null)) } + fn binary_file_decision<'a>( + &'a self, + turn: &'a TurnContext, + action: &'a str, + resource: &'a cyber_core::binary::Resource, + ) -> BoxFuture<'a, cyber_server::runtime::FileDecision> { + Box::pin(self.decide_binary(turn, action, resource)) + } + fn authorize_binary_read<'a>( + &'a self, + turn: &'a TurnContext, + resource: &'a cyber_core::binary::Resource, + asker: cyber_server::runtime::Asker, + cancel: CancellationToken, + ) -> BoxFuture<'a, Result<(), String>> { + Box::pin(self.authorize_browse_path( + turn, + "read", + resource.path(), + asker, + cancel, + serde_json::json!({"requires_confirmation": true}), + )) + } + fn authorize_binary_edit<'a>( + &'a self, + turn: &'a TurnContext, + resource: &'a cyber_core::binary::Resource, + review: Value, + asker: cyber_server::runtime::Asker, + cancel: CancellationToken, + ) -> BoxFuture<'a, Result<(), String>> { + Box::pin(self.authorize_browse_path(turn, "edit", resource.path(), asker, cancel, review)) + } + + fn file_mutation_guard<'a>( + &'a self, + turn: &'a TurnContext, + resource: &'a cyber_core::binary::Resource, + ) -> BoxFuture<'a, Result, String>> { + Box::pin(async move { + let workspace = cyber_server::workspace::mutation_lock(Path::new(&turn.directory)) + .map_err(|error| error.to_string())? + .lock_owned() + .await; + let path = self.canonical_path_lock(resource.path()).lock_owned().await; + Ok(Box::new((workspace, path)) as Box) + }) + } + fn resolve_prompt_resources<'a>( &'a self, turn: &'a TurnContext, diff --git a/crates/cyber-tools/src/host/files.rs b/crates/cyber-tools/src/host/files.rs index 0f9cbc564..8be3c9024 100644 --- a/crates/cyber-tools/src/host/files.rs +++ b/crates/cyber-tools/src/host/files.rs @@ -57,10 +57,9 @@ fn stricter(a: FileDecision, b: FileDecision) -> FileDecision { /// The decision for `action` on `path`, with `external_directory` when the path leaves /// the Location. fn decide(policy: &Policy, location: &Path, tool: &str, action: &str, path: &Path) -> FileDecision { - let path = canonical(path); let resource = path .strip_prefix(location) - .map_or_else(|_| permissions::slash(&path), permissions::slash); + .map_or_else(|_| permissions::slash(path), permissions::slash); let edit = action == "edit"; let request = |action: &str, resource: String| Request { tool: Some(tool.into()), @@ -68,7 +67,11 @@ fn decide(policy: &Policy, location: &Path, tool: &str, action: &str, path: &Pat resources: vec![resource], read_only: !edit, file_edit: edit, - mutates: if edit { vec![path.clone()] } else { vec![] }, + mutates: if edit { + vec![path.to_path_buf()] + } else { + vec![] + }, ..Request::default() }; let decision = file_decision(policy.decide(&request(action, resource))); @@ -76,10 +79,10 @@ fn decide(policy: &Policy, location: &Path, tool: &str, action: &str, path: &Pat return decision; } let dir = if path.is_dir() { - path.clone() + path.to_path_buf() } else { path.parent() - .map_or_else(|| path.clone(), Path::to_path_buf) + .map_or_else(|| path.to_path_buf(), Path::to_path_buf) }; let external = request( "external_directory", @@ -138,14 +141,48 @@ impl BuiltinHost { { return FileDecision::Deny; } - decide(&ctx.policy, &location, &inv.name, action, path) + decide(&ctx.policy, &location, &inv.name, action, &canonical(path)) }) .collect() } + pub(crate) async fn decide_binary( + &self, + turn: &TurnContext, + action: &str, + resource: &cyber_core::binary::Resource, + ) -> FileDecision { + if !matches!(action, "read" | "edit") { + return FileDecision::Deny; + } + let path = resource.path(); + let inv = invocation(turn, action, path, Asker::detached()); + let Ok(ctx) = self.browse_context(&inv, CancellationToken::new()).await else { + return FileDecision::Deny; + }; + if crate::sandboxing::hidden(&ctx).denies(path) + || (action == "edit" && crate::sandboxing::binary_edit_boundary(&ctx, path).is_err()) + { + return FileDecision::Deny; + } + decide(&ctx.policy, &ctx.location, &inv.name, action, path) + } + /// Authorize as the `read` tool does: `external_directory`, then the action, with /// permission hooks and the normal request when the rules say `ask`. pub(crate) async fn authorize_browse( + &self, + turn: &TurnContext, + action: &str, + path: &Path, + asker: Asker, + cancel: CancellationToken, + metadata: Value, + ) -> Result<(), String> { + self.authorize_browse_path(turn, action, &canonical(path), asker, cancel, metadata) + .await + } + pub(crate) async fn authorize_browse_path( &self, turn: &TurnContext, action: &str, @@ -167,7 +204,7 @@ impl BuiltinHost { } let inv = invocation(turn, action, path, asker); let ctx = self.browse_context(&inv, cancel).await?; - let path = canonical(path); + let path = path.to_path_buf(); let resource = ctx.resource(&path); let request = Request { action: action.into(), @@ -182,6 +219,7 @@ impl BuiltinHost { ..Request::default() }; let outcome = async { + crate::sandboxing::refuse_hidden(&ctx, &[&path]).await?; if action == "edit" { crate::sandboxing::binary_edit_boundary(&ctx, &path).map_err(ToolError::Failed)?; ctx.check_external_writes(std::slice::from_ref(&path)) diff --git a/crates/cyber-tools/src/sandboxing.rs b/crates/cyber-tools/src/sandboxing.rs index 941bbc40b..b73309eea 100644 --- a/crates/cyber-tools/src/sandboxing.rs +++ b/crates/cyber-tools/src/sandboxing.rs @@ -94,8 +94,8 @@ fn resolve( pub(crate) fn binary_edit_boundary(ctx: &Ctx<'_>, path: &Path) -> Result<(), String> { let (config, sources) = configuration(ctx)?; let sandbox = resolve(ctx, &config, &sources); - if sandbox.error.is_some() || sandbox.policy == Policy::ReadOnly { - return Err("Binary editing is unavailable under the selected sandbox".into()); + if sandbox.error.is_some() || sandbox.policy != Policy::WorkspaceWrite { + return Err("Binary editing requires an enforced writable workspace sandbox".into()); } let hidden = sandbox .deny_read_for(&ctx.location, &ctx.host.opts.home) diff --git a/crates/cyber-tools/tests/workspace_edits.rs b/crates/cyber-tools/tests/workspace_edits.rs index 2859c6469..5460d5e1e 100644 --- a/crates/cyber-tools/tests/workspace_edits.rs +++ b/crates/cyber-tools/tests/workspace_edits.rs @@ -176,3 +176,78 @@ async fn binary_edit_file_admission_retains_plan_sandbox_and_private_path_ceilin ); assert_eq!(readonly.read("bytes.bin"), "source"); } + +#[cfg(unix)] +#[tokio::test] +async fn binary_permission_uses_the_recorded_resource_after_a_later_alias_change() { + use cyber_server::runtime::{Asker, FileDecision, ToolHost}; + use tokio_util::sync::CancellationToken; + let fixture = Fixture::new(); + fixture.set_config(json!({"permissions":{"read":{"secrets.bin":"deny","public.bin":"allow"}}})); + fixture.write("secrets.bin", "private original bytes"); + fixture.write("public.bin", "public bytes"); + let document = cyber_core::binary::Document::open(&fixture.repo, "secrets.bin").unwrap(); + let resource = document.resource(); + std::fs::rename( + fixture.repo.join("secrets.bin"), + fixture.repo.join("original.bin"), + ) + .unwrap(); + std::os::unix::fs::symlink("public.bin", fixture.repo.join("secrets.bin")).unwrap(); + let context = turn(&fixture, "default"); + // A fresh read through the alias names public.bin. The already-open document + // still represents secrets.bin and must retain that resource's denial. + assert_eq!( + fixture + .host + .file_decisions(&context, "read", &[fixture.repo.join("secrets.bin")]) + .await, + vec![FileDecision::Allow] + ); + assert_eq!( + fixture + .host + .binary_file_decision(&context, "read", &resource) + .await, + FileDecision::Deny + ); + assert!( + fixture + .host + .authorize_binary_read( + &context, + &resource, + Asker::detached(), + CancellationToken::new() + ) + .await + .is_err() + ); +} + +#[tokio::test] +async fn binary_editing_refuses_unrestricted_contexts_without_private_draft_isolation() { + use cyber_server::runtime::{FileDecision, ToolHost}; + let fixture = Fixture::with_policy( + "bash", + cyber_sandbox::find_helper(), + Some("full-access".into()), + ); + fixture.set_config(json!({"permissions":{"*":"allow"}})); + fixture.write("bytes.bin", "source"); + let context = turn(&fixture, "accept-edits"); + assert_eq!( + fixture + .host + .file_decisions(&context, "read", &[fixture.repo.join("bytes.bin")]) + .await, + vec![FileDecision::Allow] + ); + assert_eq!( + fixture + .host + .file_decisions(&context, "edit", &[fixture.repo.join("bytes.bin")]) + .await, + vec![FileDecision::Deny] + ); +} diff --git a/crates/cyber-tui/src/hex.rs b/crates/cyber-tui/src/hex.rs index f0e269cdf..82e19faeb 100644 --- a/crates/cyber-tui/src/hex.rs +++ b/crates/cyber-tui/src/hex.rs @@ -20,6 +20,7 @@ pub enum Request { Open { path: String, expected_root: Option, + read_only: bool, offset: u64, generation: u64, }, @@ -180,7 +181,7 @@ pub fn goto(input: &str, cursor: u64, length: u64) -> Result impl App { pub fn open_hex(&mut self, path: &str) -> Vec { - self.open_hex_at(path, None, 0) + self.open_hex_at(path, None, 0, false) } pub fn open_hex_at( @@ -188,6 +189,7 @@ impl App { path: &str, expected_root: Option, offset: u64, + read_only: bool, ) -> Vec { if path.is_empty() { self.toast("Usage: /hex "); @@ -208,6 +210,7 @@ impl App { actions.push(Action::Hex(Request::Open { path: path.into(), expected_root, + read_only, offset, generation: self.hex.generation, })); @@ -622,6 +625,7 @@ pub async fn perform( Request::Open { path, expected_root, + read_only, offset, generation, } => { @@ -630,7 +634,7 @@ pub async fn perform( .at(&session.directory) .post( "/files/binary/documents", - json!({"session_id":session.id,"path":path,"expected_root":expected_root}), + json!({"session_id":session.id,"path":path,"expected_root":expected_root,"read_only":read_only}), ) .await .map_err(|e| e.to_string())?; @@ -726,6 +730,35 @@ mod tests { #[cfg(test)] mod ownership_tests { use super::*; + #[test] + fn read_only_launch_is_carried_into_the_server_document_admission() { + let mut app = App::new( + Session { + id: "ses_one".into(), + directory: "/project".into(), + ..Default::default() + }, + vec![], + "cyber", + ); + let actions = app.open_hex_at("bytes", Some("/project".into()), 0, true); + assert!(actions.iter().any(|action| matches!( + action, + Action::Hex(Request::Open { + read_only: true, + .. + }) + ))); + let actions = app.open_hex("bytes"); + assert!(actions.iter().any(|action| matches!( + action, + Action::Hex(Request::Open { + read_only: false, + .. + }) + ))); + } + #[test] fn permission_overlay_keeps_the_hex_reply_and_other_views_retire_it() { let mut app = App::new( diff --git a/crates/cyber-tui/src/runner.rs b/crates/cyber-tui/src/runner.rs index 1af72c907..6182728ac 100644 --- a/crates/cyber-tui/src/runner.rs +++ b/crates/cyber-tui/src/runner.rs @@ -475,7 +475,12 @@ fn initial_actions( }); } if let Some(hex) = &opts.hex { - initial.extend(app.open_hex_at(&hex.path, Some(hex.root.clone()), hex.offset)); + initial.extend(app.open_hex_at( + &hex.path, + Some(hex.root.clone()), + hex.offset, + hex.read_only, + )); } if let Some(prompt) = &opts.prompt { initial.push(Action::Prompt { diff --git a/docs/binary-editor.md b/docs/binary-editor.md index 303108e5d..41b8ca372 100644 --- a/docs/binary-editor.md +++ b/docs/binary-editor.md @@ -13,10 +13,12 @@ on an explorer file. The Session's read rules apply to every request, including normal permission prompts. Selecting an existing Session never rebinds its Location; the direct command refuses a target from another project. -The current implementation is **live viewing and inspection**. Staged editing, -review/save/recovery, the model `binary` tool and snapshot integration remain -unfinished under [issue #94](https://github.com/CyberdyneCorp/cybercode-cli/issues/94). -Both normal and `--read-only` entry currently open a read-only view. +The terminal currently provides **live viewing and inspection**. The owner API/SDK +also supports private staged patches, draft undo/redo, byte review and explicit +recovery on admitted Linux/macOS workspaces. Terminal editing, production save/ +restore, model operations and snapshot/hooks integration remain unfinished under +[issue #94](https://github.com/CyberdyneCorp/cybercode-cli/issues/94). Both normal and +`--read-only` terminal entry currently open a read-only view. ## Navigation and interpretation @@ -54,9 +56,17 @@ The authenticated `fs` group adds: | Operation | Route | |---|---| -| Open | `POST /api/v1/files/binary/documents` (`session_id`, `path`, optional `expected_root`) | +| Open | `POST /api/v1/files/binary/documents` (`session_id`, `path`, optional `expected_root` and immutable `read_only`) | | Byte page | `GET …/{id}/bytes?session_id=…&revision=…&offset=0&limit=65536` | | Literal search | `POST …/{id}/search` (`session_id`, `revision`, `hex`, decimal-string `offset`) | +| Status | `GET …/{id}?session_id=…` | +| Prepare private draft | `POST …/{id}/prepare` (`session_id`, current `revision`) | +| Atomic draft patch | `POST …/{id}/patches` (`session_id`, `operation_id`, `revision`, `splices`) | +| Undo/redo/discard | `POST …/{id}/history` (`session_id`, `operation_id`, `revision`, `action`) | +| Byte review | `POST …/{id}/review` (`session_id`, `revision`, optional `start`, `limit`) | +| Discover retained drafts | `GET /api/v1/files/binary/recovery?session_id=…&after=…&limit=64` | +| Recover retained draft | `POST …/{id}/recover` (`session_id`, optional immutable `read_only`) | +| Query receipt | `GET …/{id}/operations/{operation}?session_id=…` | | Close | `DELETE …/{id}?session_id=…` | Opaque document IDs do not grant Session or Location authority. Routes retain @@ -88,8 +98,38 @@ and 100 matches per request. Continuations preserve cross-chunk/slice matches. Cancellation stops scanning at the next bounded IO chunk; native filesystem IO itself is not preempted. Up to four documents per Session and sixteen per server are admitted. Sixteen worker/admission permits bound pending binary IO; saturation -returns an explicit resource-limit refusal. Session deletion releases its live -views; clean abandoned views can be pruned after 24 hours. +returns an explicit resource-limit refusal. Session deletion releases live views but +refuses while dirty drafts, locked journals or unresolved receipts would be orphaned, including previously closed drafts. +Live abandoned views can be pruned after 24 hours; prepared drafts remain durable. The 512 MiB flag in metadata reports only the planned edit-size ceiling. It does not mean editing is available or that a native save guarantee has been proved. + +## Private draft API + +Preparation requires fresh read and edit authority, an enforced writable workspace +sandbox, supported native metadata/storage and at most 512 MiB. The private data +directory and source must share a filesystem; there is no copy/delete fallback. Plan, read-only and +unrestricted sandbox profiles refuse preparation. Windows continues to support live +viewing; native replacement is unavailable. Preparation copies an immutable private +base and changes no source bytes. It does not run the model, including in Auto mode. + +Patch offsets and delete lengths are canonical decimal strings. A batch contains at +most 128 non-overlapping splices and 64 KiB of combined expected/inserted bytes; +`expected_hex` must match every deleted byte. The service commits all splices or none. +Draft history caps at 1,000 commands; it refuses further edits instead of silently +losing undo history. Explicit discard checkpoints local commands and keeps receipts. + +Every operation refreshes owner/Session/Location/read scope. Patch/history also need +current prepared edit admission; recovery itself never grants it. Stable operation +IDs bind exact request payloads, including the original revision. Query the receipt +after a lost response, then refresh status; never manufacture a new operation ID to +retry an uncertain operation. Binary responses are excluded from generic HTTP +idempotency replay, and SDK binary POSTs do not retry transport failures automatically. + +Closing releases the in-memory handle, retaining private commands and receipts. +Use discovery in the same Session Location, recover the selected ID, then prepare +again before changing its draft. Recovery does not install bytes or replay a save. +A replaced/aliased parent refuses access; an ordinary changed source remains separate +from the private draft. Production save/restore and crash reconciliation of native +activity scopes are not yet exposed by these routes. diff --git a/docs/features.md b/docs/features.md index 978f0ae73..f8145f38b 100644 --- a/docs/features.md +++ b/docs/features.md @@ -7,7 +7,7 @@ Implemented behavior and remaining scope, reviewed on **2026-10-16** (Cross-sess | Area | What you can use today | Status | |---|---|---| -| Binary workspace | `cyber hex`, `/hex` and explorer `x`; paged hex/ASCII, checked goto, literal search, named bookmarks, LE/BE inspector and Session-scoped owner API/SDK ([guide](binary-editor.md)) | Partial P1 (`add-binary-hex-editor`); staged edits, recoverable saves, model operations and snapshot integration remain | +| Binary workspace | `cyber hex`, `/hex` and explorer `x`; paged hex/ASCII, checked goto, literal search, named bookmarks, LE/BE inspector; owner API/SDK private drafts, undo/redo, byte review and recovery on admitted Linux/macOS workspaces ([guide](binary-editor.md)) | Partial P1 (`add-binary-hex-editor`); staged edits, recoverable saves, model operations and snapshot integration remain | | Models | Provider adapters, model catalog, credentials and usage pricing; a ChatGPT Plus/Pro subscription instead of an API key (`cyber providers login\|logout chatgpt`, a `chatgpt` provider on the Codex backend, an owner-only login file refreshed across processes; [guide](local-core-guide.md#signing-in-with-a-chatgpt-subscription)); a GitHub Copilot plan (`cyber providers login\|logout copilot`, device-flow sign-in, a `copilot` provider whose models use Chat Completions or Responses, one premium request per prompt; [guide](local-core-guide.md#signing-in-with-a-github-copilot-subscription); live sign-in not yet checked) | Available; broader catalog work remains | | Model protocol fidelity | Reasoning kept as ordered items with opaque provider state and replayed in each provider's own field (Responses `encrypted_content`, Anthropic signed and redacted thinking, `reasoning_content`, `reasoning`, `reasoning_details`, Gemini thought signatures, Copilot `reasoning_opaque`) under the profile's `reasoning_replay`; per-model behavior profiles (`profile` in `cyber models --json` and `GET /api/v1/models`, `providers.

.models..profile` overrides, effort clamping); a stream idle timeout (`providers.

.timeout.idle_ms`, 300 s); provider error codes with server-advised retry delays and `usage limit reached; resets at