diff --git a/README.md b/README.md index 2cf25e34..cac63acd 100644 --- a/README.md +++ b/README.md @@ -81,12 +81,16 @@ 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. 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). +The [binary workspace](docs/binary-editor.md) provides 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. On admitted Linux/macOS workspaces, F2 prepares private staged edits with undo/redo; Ctrl+S reviews the exact byte changes, then explicitly saves. Reviewed Restore Last Save and retained draft recovery are also available through the owner API/SDK. Authorized Sessions can review shared saved history, and Session diffs show saved binary references independently of Git. Model binary operations, checkpoint/retention/rewind adapters and remaining acceptance work are 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) *Actual TUI capture of a synthetic 512-byte fixture, opened with `cyber hex fixtures/firmware.bin --offset 0x20 --read-only`.* +![Reviewed binary edit with exact before/after bytes and SHA-256 fingerprints](docs/screenshots/binary-hex-edit.png) + +*Actual TUI review of a staged one-byte overwrite. Opening the review leaves the source unchanged; a second Ctrl+S applies it.* + ## Sessions and people working together Cyber supports communication between Sessions and between different people's agents working on the same Git project. diff --git a/ROADMAP.md b/ROADMAP.md index 5b3d973f..2dd63a82 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. 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. +**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. The terminal now stages edits with draft undo/redo and explicit reviewed save/Restore Last Save through the same owner API. Acknowledged saves feed the native FileChanged producer. Authorized Sessions can review the shared saved-version catalog and Session diffs include binary references. Model operations, checkpoint/retention/rewind adapters and remaining acceptance are still 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/tests/files.rs b/crates/cyber-app/tests/files.rs index a9ae97ba..d2b65bcd 100644 --- a/crates/cyber-app/tests/files.rs +++ b/crates/cyber-app/tests/files.rs @@ -1094,6 +1094,207 @@ async fn binary_owner_drafts_patch_history_review_and_permission_revocation() { ); } +#[cfg(any(target_os = "macos", target_os = "linux"))] +#[tokio::test] +async fn binary_reviewed_save_retry_and_restore_use_the_production_router_without_inference() { + let f = Fixture::with(json!({"read":"allow","edit":"allow"})).await; + let session = f.session().await; + let opened = f.binary(&session, "blob.bin").await; + let path = format!( + "/files/binary/documents/{}", + opened["document_id"].as_str().unwrap() + ); + 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,"operation_id":"patch_save", + "revision":prepared["data"]["revision"],"splices":[{"offset":"1","delete_length":"2","expected_hex":"9f92","insert_hex":"ff0041"}]}))).await; + assert_eq!(code, 200, "{edited}"); + let other_session = f.session().await; + let reader = f.binary(&other_session, "blob.bin").await; + let reader_path = format!( + "/files/binary/documents/{}", + reader["document_id"].as_str().unwrap() + ); + let (code, private_history) = f + .get(&format!( + "{reader_path}/versions?session_id={other_session}" + )) + .await; + assert_eq!(code, 200, "{private_history}"); + assert!( + private_history["data"]["versions"] + .as_array() + .unwrap() + .is_empty(), + "unsaved draft leaked into shared history" + ); + let (code, review) = f + .send( + "POST", + &format!("{path}/review"), + Some(json!({"session_id":session,"revision":edited["data"]["status"]["revision"]})), + ) + .await; + assert_eq!(code, 200, "{review}"); + let request = json!({"session_id":session,"operation_id":"save_one","review":review["data"]["fingerprint"]}); + let mut stale = request.clone(); + stale["review"] = json!("0".repeat(64)); + let (code, refused) = f.send("POST", &format!("{path}/save"), Some(stale)).await; + assert!(code >= 400, "stale review accepted: {refused}"); + let mut cross_session = request.clone(); + cross_session["session_id"] = json!(other_session); + let (code, refused) = f + .send("POST", &format!("{path}/save"), Some(cross_session)) + .await; + assert!(code >= 400, "cross-Session save accepted: {refused}"); + f.set_permissions(json!({"read":"allow","edit":"deny"})); + let (code, refused) = f + .send("POST", &format!("{path}/save"), Some(request.clone())) + .await; + assert!(code >= 400, "revoked edit grant accepted: {refused}"); + assert_eq!( + std::fs::read(f.project.join("blob.bin")).unwrap(), + [0, 159, 146, 150] + ); + f.set_permissions(json!({"read":"allow","edit":"allow"})); + f._app + .store + .set_append_fault(Some(std::sync::Arc::new(|_, event| { + event.kind == "binary.source.handoff.1" + }))); + let (code, saved) = f + .send("POST", &format!("{path}/save"), Some(request.clone())) + .await; + assert_eq!(code, 200, "{saved}"); + assert_eq!(saved["data"]["operation"]["outcome"], "acknowledged"); + assert_eq!(saved["data"]["operation"]["file_effect"], "changed"); + assert_eq!( + std::fs::read(f.project.join("blob.bin")).unwrap(), + [0, 255, 0, 65, 150] + ); + let (code, replayed) = f.send("POST", &format!("{path}/save"), Some(request)).await; + assert_eq!(code, 200, "{replayed}"); + assert_eq!(replayed["data"]["operation"]["id"], "save_one"); + assert_eq!( + f.get(&format!( + "{reader_path}/versions?session_id={other_session}" + )) + .await + .0, + 400, + "an invalidated live view must be reopened" + ); + assert_eq!( + f.send( + "DELETE", + &format!("{reader_path}?session_id={other_session}"), + None + ) + .await + .0, + 200 + ); + let reader = f.binary(&other_session, "blob.bin").await; + let reader_path = format!( + "/files/binary/documents/{}", + reader["document_id"].as_str().unwrap() + ); + let (code, shared) = f + .get(&format!( + "{reader_path}/versions?session_id={other_session}" + )) + .await; + assert_eq!(code, 200, "{shared}"); + let version = &shared["data"]["versions"][0]; + assert_eq!(version["operation_id"], "save_one"); + let version_path = format!( + "{reader_path}/versions/{}/{}/review?session_id={other_session}", + version["document_id"].as_str().unwrap(), + version["version_id"].as_str().unwrap() + ); + let (code, shared_review) = f.get(&version_path).await; + assert_eq!(code, 200, "{shared_review}"); + assert_eq!(shared_review["data"]["ranges"][0]["before_hex"], "9f92"); + assert_eq!(shared_review["data"]["ranges"][0]["after_hex"], "ff0041"); + f.set_permissions(json!({"read":"deny","edit":"allow"})); + assert_eq!(f.get(&version_path).await.0, 403); + assert_eq!( + f.get(&format!( + "{reader_path}/versions?session_id={other_session}" + )) + .await + .0, + 403 + ); + f.set_permissions(json!({"read":"allow","edit":"allow"})); + let (code, diff) = f.get(&format!("/sessions/{session}/diff")).await; + assert_eq!(code, 200, "{diff}"); + let change = diff["data"] + .as_array() + .unwrap() + .iter() + .find(|change| change["file"] == "blob.bin") + .expect("non-Git binary source snapshot reference"); + assert_eq!(change["binary"][0]["operation_id"], "save_one"); + assert_eq!(change["patch"], ""); + f.set_permissions(json!({"read":"deny","edit":"allow"})); + assert!( + f.get(&format!("/sessions/{session}/diff")).await.1["data"] + .as_array() + .unwrap() + .is_empty() + ); + f.set_permissions(json!({"read":"allow","edit":"allow"})); + assert!( + f._app + .runtime + .binary_handoff(version["version_id"].as_str().unwrap()) + .unwrap() + .is_none(), + "the fault must isolate snapshot references from notification delivery" + ); + f._app.store.set_append_fault(None); + let (code, restore) = f + .send( + "POST", + &format!("{path}/restore-review"), + Some(json!({"session_id":session, + "revision":saved["data"]["status"]["revision"]})), + ) + .await; + assert_eq!(code, 200, "{restore}"); + let (code, restored) = f + .send( + "POST", + &format!("{path}/restore"), + Some(json!({"session_id":session,"operation_id":"restore_one", + "review":restore["data"]["fingerprint"]})), + ) + .await; + assert_eq!(code, 200, "{restored}"); + assert_eq!(restored["data"]["operation"]["outcome"], "acknowledged"); + assert_eq!( + std::fs::read(f.project.join("blob.bin")).unwrap(), + [0, 159, 146, 150] + ); + let (code, old_receipt) = f + .get(&format!("{path}/operations/save_one?session_id={session}")) + .await; + assert_eq!( + code, 200, + "historical notification lookup tried to re-observe the replaced source: {old_receipt}" + ); + assert_eq!(old_receipt["data"]["outcome"], "acknowledged"); + assert!(f._model.requests().is_empty()); + f._app.runtime.shutdown().await; +} + #[tokio::test] async fn binary_http_idempotency_keys_never_replay_private_data_after_read_revocation() { let f = Fixture::with(json!({"read":"allow"})).await; diff --git a/crates/cyber-core/src/binary/editor.rs b/crates/cyber-core/src/binary/editor.rs index b641a2a2..13f58714 100644 --- a/crates/cyber-core/src/binary/editor.rs +++ b/crates/cyber-core/src/binary/editor.rs @@ -19,13 +19,15 @@ use std::path::PathBuf; mod catalog; #[path = "editor/changes.rs"] mod changes; +#[path = "editor/history.rs"] +mod history; #[path = "editor/restore.rs"] mod restore; #[path = "editor/retention.rs"] mod retention; #[path = "editor/versions.rs"] mod versions; -pub use catalog::{StoredDocument, StoredDocuments}; +pub use catalog::{SavedReference, StoredDocument, StoredDocuments}; pub use changes::AcknowledgedChange; pub use retention::Cleanup; pub use versions::VersionView; @@ -304,6 +306,22 @@ impl Editor { operation_id(id)?; Ok(self.operation(id)) } + pub fn lookup_installation( + &self, + id: &str, + review: &str, + restore: bool, + ) -> Result, Error> { + let payload = signature(&(if restore { "restore-v1" } else { "save-v1" }, review))?; + let operation = self.lookup_operation(id)?; + if operation + .as_ref() + .is_some_and(|operation| operation.payload_sha256 != payload) + { + return Err(Error::Changed); + } + Ok(operation) + } pub fn operation(&self, id: &str) -> Option { self.record .operations @@ -768,6 +786,20 @@ impl Editor { /// Called only after the runtime durably publishes the acknowledged observation. /// The operation ID is the producer's deduplication identity, not just a path. pub fn notified(&mut self, id: &str) -> Result<(), Error> { + self.finish_notification(id, None) + } + /// A durable producer disposition can close notification work without + /// claiming that a handler ran or changing the acknowledged source outcome. + pub fn notification_suppressed(&mut self, id: &str, reason: &str) -> Result<(), Error> { + if reason.is_empty() + || reason.len() > 64 + || !reason.bytes().all(|b| b.is_ascii_lowercase() || b == b'_') + { + return Err(Error::Invalid); + } + self.finish_notification(id, Some(format!("notification_suppressed:{reason}"))) + } + fn finish_notification(&mut self, id: &str, reason: Option) -> Result<(), Error> { self.store.validate()?; let operation = self .record @@ -779,7 +811,11 @@ impl Editor { && op.file_effect == FileEffect::Changed }) .ok_or(Error::Invalid)?; + if !operation.notification_pending { + return Ok(()); + } operation.notification_pending = false; + operation.reason = reason; persist(&mut self.store, &self.record) } } @@ -838,6 +874,67 @@ mod tests { _ => panic!("new save must prepare"), } } + #[test] + fn installation_retries_bind_the_original_review_and_notification_disposition() { + let (_root, mut editor, path, _owner) = setup(); + patch(&mut editor, "patch_once"); + let review = editor.review(0, 128, || false).unwrap(); + let prepared = ready(&mut editor, "saved_once"); + editor.commit_save(*prepared).unwrap(); + let bytes = std::fs::read(&path).unwrap(); + let notification = editor.notification_key("saved_once").unwrap().unwrap(); + assert_eq!( + editor + .lookup_installation("saved_once", &review.fingerprint, false) + .unwrap() + .unwrap() + .outcome, + Outcome::Acknowledged + ); + assert!( + editor + .lookup_installation("saved_once", &"0".repeat(64), false) + .is_err() + ); + assert!( + editor + .lookup_installation("saved_once", &review.fingerprint, true) + .is_err() + ); + assert!( + editor + .notification_suppressed("saved_once", "invalid reason") + .is_err() + ); + editor + .notification_suppressed("saved_once", "native_unavailable") + .unwrap(); + editor.notified("saved_once").unwrap(); + let receipt = editor.lookup_operation("saved_once").unwrap().unwrap(); + assert!(!receipt.notification_pending); + assert_eq!(receipt.outcome, Outcome::Acknowledged); + assert_eq!(receipt.file_effect, FileEffect::Changed); + assert_eq!( + receipt.reason.as_deref(), + Some("notification_suppressed:native_unavailable") + ); + std::fs::write(&path, b"later external source").unwrap(); + assert_eq!( + editor.notification_key("saved_once").unwrap().as_deref(), + Some(notification.as_str()) + ); + assert!( + editor + .acknowledged_change("saved_once") + .unwrap() + .unwrap() + .observed_stamp() + .is_err(), + "old metadata must not confirm a fresh source observation" + ); + assert_ne!(std::fs::read(path).unwrap(), bytes); + } + #[test] fn final_save_authority_refusal_preserves_source_draft_and_receipt_absence() { let (_root, mut editor, path, _owner) = setup(); diff --git a/crates/cyber-core/src/binary/editor/catalog.rs b/crates/cyber-core/src/binary/editor/catalog.rs index 2447b83e..7338cce8 100644 --- a/crates/cyber-core/src/binary/editor/catalog.rs +++ b/crates/cyber-core/src/binary/editor/catalog.rs @@ -206,6 +206,170 @@ impl StorageRoot { } } +impl StorageRoot { + /// Discover candidate namespaces by their small source header. No private + /// command journal, base image or Session metadata is returned to the caller. + pub(super) fn source_document_ids( + &self, + source: &Document, + cancelled: &impl Fn() -> bool, + ) -> Result, Error> { + source.validate()?; + let root = self.directory()?; + let mut found = Vec::new(); + for (count, entry) in root.entries().map_err(|_| Error::Io)?.enumerate() { + if count >= 4096 { + return Err(Error::Limit); + } + if cancelled() { + return Err(Error::Cancelled); + } + let name = entry.map_err(|_| Error::Io)?.file_name(); + let id = name.to_str().ok_or(Error::File)?; + if !id.starts_with("bin_") { + continue; + } + 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, + )?; + 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 header.version != 1 + || header.relative.as_os_str().is_empty() + || header + .relative + .components() + .any(|part| !matches!(part, Component::Normal(_))) + { + return Err(Error::Changed); + } + if header.owner.root == source.root() + && header.owner.root.join(&header.relative) == source.path() + { + found.push(id.to_owned()); + } + } + found.sort(); + source.validate()?; + self.directory()?; + Ok(found) + } +} + +/// Metadata-only acknowledged source reference. This does not grant read access +/// to its source or to either saved byte image. +#[derive(Debug, Clone)] +pub struct SavedReference { + pub version: crate::binary::receipts::Version, + pub root: PathBuf, + pub resource: super::super::Resource, +} +impl StorageRoot { + /// Enumerate only published immutable evidence for a Session/Location. Never + /// opens command journals or bases; callers authorize each returned source. + pub fn session_saved_references( + &self, + session: &str, + directory: &std::path::Path, + cancelled: impl Fn() -> bool, + ) -> Result, Error> { + let directory = directory.canonicalize().map_err(|_| Error::File)?; + let root = self.directory()?; + let mut found = Vec::new(); + for (count, entry) in root.entries().map_err(|_| Error::Io)?.enumerate() { + if count >= 4096 { + return Err(Error::Limit); + } + if cancelled() { + return Err(Error::Cancelled); + } + let name = entry.map_err(|_| Error::Io)?.file_name(); + let id = name.to_str().ok_or(Error::File)?; + if !id.starts_with("bin_") { + continue; + } + 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, + )?; + 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 header.owner.session_id != session || header.owner.directory != directory { + continue; + } + read_saved_references(&dir, id, &header, &cancelled, &mut found)?; + } + self.directory()?; + found.sort_by(|a, b| { + a.version + .created_ms + .cmp(&b.version.created_ms) + .then_with(|| a.version.version_id.cmp(&b.version.version_id)) + }); + Ok(found) + } +} +fn read_saved_references( + dir: &cap_std::fs::Dir, + document: &str, + header: &Header, + cancelled: &impl Fn() -> bool, + found: &mut Vec, +) -> Result<(), Error> { + if header.version != 1 + || !header.owner.root.is_absolute() + || header.relative.as_os_str().is_empty() + || header + .relative + .components() + .any(|part| !matches!(part, Component::Normal(_))) + { + return Err(Error::Changed); + } + for (count, entry) in dir.entries().map_err(|_| Error::Io)?.enumerate() { + if count >= 4096 { + return Err(Error::Limit); + } + if cancelled() { + return Err(Error::Cancelled); + } + let name = entry.map_err(|_| Error::Io)?.file_name(); + let name = name.to_str().ok_or(Error::File)?; + let Some(version) = name + .strip_suffix(".json") + .filter(|id| super::versions::valid_version(id)) + else { + continue; + }; + let evidence = super::versions::read_any_evidence(dir, document, version)?; + if evidence.owner != header.owner || evidence.target.relative != header.relative { + return Err(Error::Changed); + } + found.push(SavedReference { + version: evidence.info, + root: header.owner.root.clone(), + resource: super::super::Resource(header.owner.root.join(&header.relative)), + }); + if found.len() > 512 { + return Err(Error::Limit); + } + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/cyber-core/src/binary/editor/changes.rs b/crates/cyber-core/src/binary/editor/changes.rs index b3512acd..7c45fb9c 100644 --- a/crates/cyber-core/src/binary/editor/changes.rs +++ b/crates/cyber-core/src/binary/editor/changes.rs @@ -50,8 +50,14 @@ impl Editor { /// A known earlier save can finish metadata publication after explicit /// recovery. Unsaved edits and unknown native intents are never candidates. pub fn pending_notification_change(&mut self) -> Result, Error> { - let pending = self - .record + let pending = self.pending_notification_id(); + match pending { + Some(id) => self.acknowledged_change(&id), + None => Ok(None), + } + } + pub fn pending_notification_id(&self) -> Option { + self.record .operations .iter() .rev() @@ -60,15 +66,12 @@ impl Editor { && operation.outcome == Outcome::Acknowledged && operation.file_effect == FileEffect::Changed }) - .map(|operation| operation.id.clone()); - match pending { - Some(id) => self.acknowledged_change(&id), - None => Ok(None), - } + .map(|operation| operation.id.clone()) } - /// Retain durable source-version evidence before handing acknowledgement to - /// another producer. Unknown/conflict/no-effect operations never mint proof. - pub fn acknowledged_change(&mut self, id: &str) -> Result, Error> { + /// Identity of an acknowledged notification's immutable evidence. This can + /// settle an existing producer disposition after a later source save, but it + /// cannot mint native observation or callback authority. + pub fn notification_key(&mut self, id: &str) -> Result, Error> { self.publish_last_version()?; let Some(operation) = self.lookup_operation(id)? else { return Ok(None); @@ -94,6 +97,17 @@ impl Editor { { return Err(Error::Changed); } + Ok(Some(version)) + } + + /// Retain durable source-version evidence before handing acknowledgement to + /// another producer. Unknown/conflict/no-effect operations never mint proof. + pub fn acknowledged_change(&mut self, id: &str) -> Result, Error> { + let Some(version) = self.notification_key(id)? else { + return Ok(None); + }; + let evidence = + versions::read_evidence(self.store.directory(), self.store.document_id(), &version)?; let pins = evidence.target.pins()?; Ok(Some(AcknowledgedChange { key: version, diff --git a/crates/cyber-core/src/binary/editor/history.rs b/crates/cyber-core/src/binary/editor/history.rs new file mode 100644 index 00000000..9175f727 --- /dev/null +++ b/crates/cyber-core/src/binary/editor/history.rs @@ -0,0 +1,57 @@ +//! Shared acknowledged source history across private document namespaces. A page +//! visits at most 64 matching namespaces; empty pages may carry a continuation. +use super::*; +use crate::binary::{receipts::Versions, storage::StorageRoot}; + +impl StorageRoot { + pub fn source_versions( + &self, + source: &Document, + after: Option<&str>, + limit: usize, + cancelled: impl Fn() -> bool, + ) -> Result { + if limit == 0 || limit > 64 { + return Err(Error::Limit); + } + let (position, prior) = after.map(cursor).transpose()?.unwrap_or((0, None)); + let ids = self.source_document_ids(source, &cancelled)?; + if position > ids.len() { + return Err(Error::Invalid); + } + let mut found = Vec::new(); + let mut continuation = None; + for (visited, (index, id)) in ids.iter().enumerate().skip(position).take(64).enumerate() { + let after = if index == position { prior } else { None }; + let versions = self.versions(source, id, after, limit - found.len(), &cancelled)?; + found.extend(versions.versions); + if let Some(version) = versions.next_cursor { + continuation = Some(format!("{index}:{version}")); + break; + } + if found.len() == limit || visited == 63 { + // Positions reveal no private namespace identity for unsaved + // candidates. Only acknowledged versions return document IDs. + continuation = (index + 1 < ids.len()).then(|| format!("{}:", index + 1)); + break; + } + } + source.validate()?; + self.directory()?; + Ok(Versions { + versions: found, + next_cursor: continuation, + }) + } +} +fn cursor(value: &str) -> Result<(usize, Option<&str>), Error> { + let (position, version) = value.split_once(':').ok_or(Error::Invalid)?; + let index = position.parse::().map_err(|_| Error::Invalid)?; + if index > 4096 + || index.to_string() != position + || (!version.is_empty() && !super::versions::valid_version(version)) + { + return Err(Error::Invalid); + } + Ok((index, (!version.is_empty()).then_some(version))) +} diff --git a/crates/cyber-core/src/binary/editor/versions.rs b/crates/cyber-core/src/binary/editor/versions.rs index f7b958bc..7d84e56d 100644 --- a/crates/cyber-core/src/binary/editor/versions.rs +++ b/crates/cyber-core/src/binary/editor/versions.rs @@ -391,6 +391,34 @@ impl crate::binary::storage::StorageRoot { next_cursor, }) } + /// Read a saved reference, including an expired payload's tombstone, without + /// opening either byte image. The caller still needs fresh source read access. + pub fn version_info( + &self, + source: &Document, + document: &str, + version: &str, + ) -> Result { + use cap_fs_ext::DirExt; + valid_document(document)?; + if !valid_version(version) { + return Err(Error::Invalid); + } + source.validate()?; + let root = self.directory()?; + let dir = root.open_dir_nofollow(document).map_err(|_| Error::File)?; + crate::binary::storage::private( + root, + &dir.try_clone().map_err(|_| Error::Io)?.into_std_file(), + true, + )?; + let evidence = read_any_evidence(&dir, document, version)?; + bind_source(&evidence, source)?; + source.validate()?; + self.directory()?; + Ok(evidence.info) + } + /// Caller establishes fresh read authority for the live source resource first. /// Saved versions are shared source history; unsaved owner/model drafts are not. pub fn open_version( @@ -551,6 +579,72 @@ mod tests { Outcome::Acknowledged ); } + #[test] + fn source_history_shares_only_acknowledged_versions_across_sessions_and_pages() { + let (temp, mut first, owner) = fixture(); + patch(&mut first, "patch_a", &[0], &[1]); + save(&mut first, "save_a"); + let a = first.publish_last_version().unwrap().unwrap(); + let storage = + crate::binary::storage::StorageRoot::prepare(&temp.path().join("data")).unwrap(); + let mut source = Document::open(&owner.root, "bytes").unwrap(); + let mut second = Editor::create( + storage.open("bin_another_session").unwrap(), + Owner { + session_id: "ses_another".into(), + ..owner.clone() + }, + &mut source, + || false, + ) + .unwrap(); + patch(&mut second, "private_only", &[1], &[2]); + let references = storage + .session_saved_references(&owner.session_id, &owner.directory, || false) + .unwrap(); + assert_eq!(references.len(), 1); + assert_eq!(references[0].version.operation_id, "save_a"); + assert_eq!(references[0].resource.path(), owner.root.join("bytes")); + assert!( + storage + .session_saved_references("ses_another", &owner.directory, || false) + .unwrap() + .is_empty() + ); + let history = storage.source_versions(&source, None, 1, || false).unwrap(); + assert_eq!(history.versions.len(), 1); + assert_eq!(history.versions[0].version_id, a.version_id); + save(&mut second, "save_b"); + let b = second.publish_last_version().unwrap().unwrap(); + let source = Document::open(&owner.root, "bytes").unwrap(); + let page = storage.source_versions(&source, None, 1, || false).unwrap(); + let next = storage + .source_versions(&source, page.next_cursor.as_deref(), 1, || false) + .unwrap(); + let ids = [ + page.versions[0].version_id.clone(), + next.versions[0].version_id.clone(), + ]; + assert!(ids.contains(&a.version_id)); + assert!(ids.contains(&b.version_id)); + assert!(next.next_cursor.is_none()); + assert!( + storage + .source_versions(&source, Some("../bad:version"), 1, || false) + .is_err() + ); + assert!(storage.source_versions(&source, None, 1, || true).is_err()); + std::fs::write(owner.root.join("other"), b"other").unwrap(); + let other = Document::open(&owner.root, "other").unwrap(); + assert!( + storage + .source_versions(&other, None, 64, || false) + .unwrap() + .versions + .is_empty() + ); + } + #[test] fn saved_views_hold_shared_leases_until_the_last_reader_closes() { let (temp, mut editor, owner) = fixture(); diff --git a/crates/cyber-core/src/binary/receipts.rs b/crates/cyber-core/src/binary/receipts.rs index 5aca4efd..76380bac 100644 --- a/crates/cyber-core/src/binary/receipts.rs +++ b/crates/cyber-core/src/binary/receipts.rs @@ -45,7 +45,7 @@ pub struct SaveReview { } /// Shared saved-source metadata. It never contains unsaved command buffers. -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] #[schemars(rename = "BinaryVersion")] pub struct Version { diff --git a/crates/cyber-server/src/http/binary_files.rs b/crates/cyber-server/src/http/binary_files.rs index 6e669ec2..49899d4d 100644 --- a/crates/cyber-server/src/http/binary_files.rs +++ b/crates/cyber-server/src/http/binary_files.rs @@ -21,6 +21,11 @@ use crate::runtime::{FileDecision, TurnContext}; pub(super) mod editing; #[path = "binary_files/recovery.rs"] pub(super) mod recovery; +#[path = "binary_files/saving.rs"] +pub(super) mod saving; + +#[path = "binary_files/versions.rs"] +pub(super) mod versions; #[derive(Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] @@ -70,6 +75,24 @@ pub struct BytePage { pub fn routes() -> Router { Router::new() + .route("/files/binary/documents/{id}/versions", get(versions::list)) + .route( + "/files/binary/documents/{id}/versions/{document}/{version}/review", + get(versions::review), + ) + .route("/files/binary/documents/{id}/save", post(saving::save)) + .route( + "/files/binary/documents/{id}/restore-review", + post(saving::restore_review), + ) + .route( + "/files/binary/documents/{id}/restore", + post(saving::restore), + ) + .route( + "/files/binary/documents/{id}/reconcile", + post(saving::reconcile), + ) .route("/files/binary/documents", post(open_document)) .route("/files/binary/recovery", get(recovery::list)) .route( diff --git a/crates/cyber-server/src/http/binary_files/editing.rs b/crates/cyber-server/src/http/binary_files/editing.rs index dacd0d3d..f67f005f 100644 --- a/crates/cyber-server/src/http/binary_files/editing.rs +++ b/crates/cyber-server/src/http/binary_files/editing.rs @@ -135,9 +135,19 @@ pub(super) async fn status( .binary_file_decision(&authority.turn, "edit", &entry.resource) .await; let supported = platform_supported(&state); + #[cfg(any(target_os = "macos", target_os = "linux"))] + let runtime = state.runtime.clone(); let result = work(&state, move || { let content = entry.document.lock().map_err(|_| Error::Io)?; + #[cfg(any(target_os = "macos", target_os = "linux"))] + let mut content = content; content.validate_resource()?; + #[cfg(any(target_os = "macos", target_os = "linux"))] + if let Content::Draft(editor) = &mut *content + && let Some(id) = editor.pending_notification_id() + { + runtime.settle_binary_notification(editor, &id)?; + } Ok(describe(&entry, &content, supported, decision)) }) .await?; @@ -149,7 +159,7 @@ pub(super) async fn status( } #[cfg(any(target_os = "macos", target_os = "linux"))] -async fn permit_edit( +pub(super) async fn permit_edit( state: &AppState, authority: &ReadAuthority, entry: &Entry, @@ -263,7 +273,7 @@ pub(super) async fn prepare( } #[cfg(any(target_os = "macos", target_os = "linux"))] -async fn staged_admission( +pub(super) async fn staged_admission( state: &AppState, authority: &ReadAuthority, entry: &Entry, diff --git a/crates/cyber-server/src/http/binary_files/recovery.rs b/crates/cyber-server/src/http/binary_files/recovery.rs index b22cb74f..1a44f541 100644 --- a/crates/cyber-server/src/http/binary_files/recovery.rs +++ b/crates/cyber-server/src/http/binary_files/recovery.rs @@ -173,12 +173,14 @@ pub(super) async fn operation( #[cfg(any(target_os = "macos", target_os = "linux"))] { let (entry, location, authority) = owned(&state, &parts, &query.session_id, &id).await?; + let runtime = state.runtime.clone(); let result = work(&state, move || { - let content = entry.document.lock().map_err(|_| Error::Io)?; + let mut content = entry.document.lock().map_err(|_| Error::Io)?; content.validate_resource()?; - let crate::runtime::binary_documents::Content::Draft(editor) = &*content else { + let crate::runtime::binary_documents::Content::Draft(editor) = &mut *content else { return Err(Error::Invalid); }; + runtime.settle_binary_notification(editor, &operation)?; editor.lookup_operation(&operation) }) .await?; diff --git a/crates/cyber-server/src/http/binary_files/saving.rs b/crates/cyber-server/src/http/binary_files/saving.rs new file mode 100644 index 00000000..13f5993c --- /dev/null +++ b/crates/cyber-server/src/http/binary_files/saving.rs @@ -0,0 +1,241 @@ +//! Reviewed native installation. Permission prompts finish before filesystem +//! ownership; the actual worker refreshes authority immediately before intent. +use super::editing::{Edited, ReviewRequest}; +use super::*; +#[cfg(any(target_os = "macos", target_os = "linux"))] +use crate::runtime::binary_documents::{Content, EditAdmission}; +#[cfg(any(target_os = "macos", target_os = "linux"))] +use cyber_core::binary::editor::SavePreparation; +use cyber_core::binary::receipts::SaveReview; + +#[derive(Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(rename = "BinarySaveRequest")] +pub struct SaveRequest { + pub session_id: String, + pub operation_id: String, + pub review: String, +} +#[derive(Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(rename = "BinaryReconcileRequest")] +pub struct ReconcileRequest { + pub session_id: String, +} + +pub(super) async fn save( + State(state): State, + parts: Parts, + Path(id): Path, + Json(input): Json, +) -> Result>, ApiError> { + install(state, parts, id, input, false).await +} +pub(super) async fn restore( + State(state): State, + parts: Parts, + Path(id): Path, + Json(input): Json, +) -> Result>, ApiError> { + install(state, parts, id, input, true).await +} + +async fn install( + state: AppState, + parts: Parts, + id: String, + input: SaveRequest, + restore: bool, +) -> Result>, ApiError> { + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + let _ = (state, parts, id, input, restore); + Err(editing::unsupported()) + } + #[cfg(any(target_os = "macos", target_os = "linux"))] + { + let (entry, location, authority) = owned(&state, &parts, &input.session_id, &id).await?; + let cached = editing::staged_admission(&state, &authority, &entry).await?; + let existing = { + let (entry, operation_id, review) = ( + entry.clone(), + input.operation_id.clone(), + input.review.clone(), + ); + 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); + }; + Ok(editor + .lookup_installation(&operation_id, &review, restore)? + .map(|operation| Edited { + operation, + status: editing::describe(&entry, &content, true, cached.grant), + })) + }) + .await? + }; + if let Some(existing) = existing { + verify_read(&state, &parts, &authority).await?; + return Ok(Json(Located { + location, + data: existing, + })); + } + let review = { + let entry = entry.clone(); + work(&state, move || { + let content = entry.document.lock().map_err(|_| Error::Io)?; + let Content::Draft(editor) = &*content else { + return Err(Error::Invalid); + }; + if restore { + editor.restore_review(|| false) + } else { + editor.review(0, 128, || false) + } + }) + .await? + }; + if review.fingerprint != input.review { + return Err(refused(Error::Changed)); + } + let metadata = serde_json::json!({"binary_operation":if restore {"restore"} else {"save"}, + "requires_confirmation":true,"byte_review":review}); + let grant = editing::permit_edit(&state, &authority, &entry, metadata).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 runtime = state.runtime.clone(); + let result = state + .runtime + .binary_file_work( + authority.turn.clone(), + id, + entry.resource.clone(), + (authority.grant, grant), + move |work| { + admission + .authority + .verify(&runtime, &admission.turn.session_id) + .map_err(|_| Error::Changed)?; + 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 prepared = if restore { + editor.prepare_restore(&input.operation_id, &input.review, || { + work.is_cancelled() + })? + } else { + editor.prepare_save(&input.operation_id, &input.review, || { + work.is_cancelled() + })? + }; + let operation = match prepared { + SavePreparation::Existing(operation) => operation, + SavePreparation::Ready(prepared) => editor.commit_save_checked( + *prepared, + || work.is_cancelled(), + || { + admission + .authority + .verify(&runtime, &admission.turn.session_id) + .map_err(|_| Error::Changed)?; + work.verify() + }, + )?, + }; + work.resume_notifications(editor); + Ok(Edited { + operation, + status: editing::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 restore_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(editing::unsupported()) + } + #[cfg(any(target_os = "macos", target_os = "linux"))] + { + let (entry, location, authority) = owned(&state, &parts, &input.session_id, &id).await?; + 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.restore_review(|| false) + }) + .await?; + verify_read(&state, &parts, &authority).await?; + Ok(Json(Located { + location, + data: result, + })) + } +} +pub(super) async fn reconcile( + 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 (entry, location, authority) = owned(&state, &parts, &input.session_id, &id).await?; + let decision = state + .runtime + .binary_file_decision(&authority.turn, "edit", &entry.resource) + .await; + let result = work(&state, move || { + 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.reconcile()?; + Ok(Edited { + operation, + status: editing::describe(&entry, &content, true, decision), + }) + }) + .await?; + verify_read(&state, &parts, &authority).await?; + Ok(Json(Located { + location, + data: result, + })) + } +} diff --git a/crates/cyber-server/src/http/binary_files/versions.rs b/crates/cyber-server/src/http/binary_files/versions.rs new file mode 100644 index 00000000..b9039d58 --- /dev/null +++ b/crates/cyber-server/src/http/binary_files/versions.rs @@ -0,0 +1,121 @@ +//! Saved source history is shared by authorized readers. Discovery and review +//! never open another owner's command journal or admit its unsaved draft. +use super::*; +use cyber_core::binary::draft::Review; +use cyber_core::binary::receipts::Versions; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct VersionsQuery { + pub session_id: String, + pub after: Option, + pub limit: Option, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ReviewQuery { + pub session_id: String, + pub start: Option, + pub limit: Option, +} + +pub(super) async fn list( + State(state): State, + parts: Parts, + Path(id): Path, + Query(query): Query, +) -> Result>, ApiError> { + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + let _ = (state, parts, id, query.session_id, query.after, query.limit); + Err(editing::unsupported()) + } + #[cfg(any(target_os = "macos", target_os = "linux"))] + { + let (entry, location, authority) = owned(&state, &parts, &query.session_id, &id).await?; + let storage = state + .runtime + .binary_documents() + .storage() + .map_err(refused)?; + let cancellation = tokio_util::sync::CancellationToken::new(); + let _cancel = cancellation.clone().drop_guard(); + let result = work(&state, move || { + let content = entry.document.lock().map_err(|_| Error::Io)?; + content.validate_resource()?; + let source = source(&entry)?; + storage.source_versions( + &source, + query.after.as_deref(), + query.limit.unwrap_or(64), + || cancellation.is_cancelled(), + ) + }) + .await?; + verify_read(&state, &parts, &authority).await?; + Ok(Json(Located { + location, + data: result, + })) + } +} +pub(super) async fn review( + State(state): State, + parts: Parts, + Path((id, document, version)): Path<(String, String, String)>, + Query(query): Query, +) -> Result>, ApiError> { + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + let _ = ( + state, + parts, + id, + document, + version, + query.session_id, + query.start, + query.limit, + ); + Err(editing::unsupported()) + } + #[cfg(any(target_os = "macos", target_os = "linux"))] + { + let (entry, location, authority) = owned(&state, &parts, &query.session_id, &id).await?; + let storage = state + .runtime + .binary_documents() + .storage() + .map_err(refused)?; + let cancellation = tokio_util::sync::CancellationToken::new(); + let _cancel = cancellation.clone().drop_guard(); + let result = work(&state, move || { + let content = entry.document.lock().map_err(|_| Error::Io)?; + content.validate_resource()?; + let source = source(&entry)?; + let view = storage + .open_version(&source, &document, &version, || cancellation.is_cancelled())?; + let result = + view.review(query.start.unwrap_or(0), query.limit.unwrap_or(128), || { + cancellation.is_cancelled() + })?; + source.validate()?; + Ok(result) + }) + .await?; + verify_read(&state, &parts, &authority).await?; + Ok(Json(Located { + location, + data: result, + })) + } +} +#[cfg(any(target_os = "macos", target_os = "linux"))] +fn source(entry: &Entry) -> Result { + let path = entry + .resource + .path() + .strip_prefix(&entry.root) + .map_err(|_| Error::File)?; + Document::open(&entry.root, &cyber_core::vcs::slash_path(path)) +} diff --git a/crates/cyber-server/src/http/openapi.rs b/crates/cyber-server/src/http/openapi.rs index 4de26805..d4485727 100644 --- a/crates/cyber-server/src/http/openapi.rs +++ b/crates/cyber-server/src/http/openapi.rs @@ -1595,6 +1595,18 @@ pub fn operations(g: &mut SchemaGenerator) -> Vec { .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("get", "/files/binary/documents/{id}/versions", "v1.fs.binaryVersions", "List acknowledged source history across Sessions without exposing private drafts") + .located().query(&["session_id","after","limit"]).ok::>(g), + op("get", "/files/binary/documents/{id}/versions/{document}/{version}/review", "v1.fs.binaryVersionReview", "Read an immutable saved-source byte diff under fresh source read authority") + .located().query(&["session_id","start","limit"]).ok::>(g), + op("post", "/files/binary/documents/{id}/save", "v1.fs.binarySave", "Install a separately reviewed native binary draft under fresh authority") + .located().body::(g).ok::>(g), + op("post", "/files/binary/documents/{id}/restore-review", "v1.fs.binaryRestoreReview", "Review the protected preimage for Restore Last Save") + .located().body::(g).ok::>(g), + op("post", "/files/binary/documents/{id}/restore", "v1.fs.binaryRestore", "Restore a reviewed preimage without overwriting unrelated changes") + .located().body::(g).ok::>(g), + op("post", "/files/binary/documents/{id}/reconcile", "v1.fs.binaryReconcile", "Recheck retained native evidence without executing another exchange") + .located().body::(g).ok::>(g), op( "delete", "/files/binary/documents/{id}", diff --git a/crates/cyber-server/src/runtime/binary_documents.rs b/crates/cyber-server/src/runtime/binary_documents.rs index a8c0756a..dca7cbe6 100644 --- a/crates/cyber-server/src/runtime/binary_documents.rs +++ b/crates/cyber-server/src/runtime/binary_documents.rs @@ -83,7 +83,31 @@ impl BinaryWork { /// leaves its notification pending and never changes a staged edit's receipt. pub fn resume_notifications(&self, editor: &mut cyber_core::binary::editor::Editor) { let result = editor.pending_notification_change().and_then(|change| { - change.map_or(Ok(()), |change| self.acknowledge_source(Arc::new(change))) + let Some(change) = change else { + return Ok(()); + }; + if self + .runtime + .binary_handoff(change.key()) + .map_err(|_| Error::Io)? + .is_some() + { + let handoff = self + .runtime + .reconcile_binary_handoff(change.key()) + .map_err(|_| Error::Io)?; + match handoff.state { + super::binary_effects::State::Observed { .. } => { + return editor.notified(&change.version().operation_id); + } + super::binary_effects::State::Suppressed { reason } => { + return editor + .notification_suppressed(&change.version().operation_id, &reason); + } + _ => {} + } + } + self.acknowledge_source(Arc::new(change)) }); if result.is_err() { self.runtime.hook_notice( diff --git a/crates/cyber-server/src/runtime/binary_effects.rs b/crates/cyber-server/src/runtime/binary_effects.rs index b34b0190..f1a23a4c 100644 --- a/crates/cyber-server/src/runtime/binary_effects.rs +++ b/crates/cyber-server/src/runtime/binary_effects.rs @@ -244,6 +244,35 @@ fn verify_observation(tx: &Transaction<'_>, claim: &State, id: &str) -> Result<( } } impl Runtime { + #[cfg(any(target_os = "macos", target_os = "linux"))] + pub(crate) fn settle_binary_notification( + &self, + editor: &mut cyber_core::binary::editor::Editor, + id: &str, + ) -> Result<(), cyber_core::binary::Error> { + use cyber_core::binary::Error; + if !editor + .lookup_operation(id)? + .is_some_and(|operation| operation.notification_pending) + { + return Ok(()); + } + let Some(key) = editor.notification_key(id)? else { + return Ok(()); + }; + if self.binary_handoff(&key).map_err(|_| Error::Io)?.is_none() { + return Ok(()); + } + match self + .reconcile_binary_handoff(&key) + .map_err(|_| Error::Io)? + .state + { + State::Observed { .. } => editor.notified(id), + State::Suppressed { reason } => editor.notification_suppressed(id, &reason), + _ => Ok(()), + } + } /// Invoke after the actual filesystem worker releases checkout/path owners. /// A repeated receipt may reconcile durable evidence, never re-enqueue an /// already claimed native observation or renew expired hook provenance. diff --git a/crates/cyber-server/src/runtime/binary_snapshots.rs b/crates/cyber-server/src/runtime/binary_snapshots.rs new file mode 100644 index 00000000..a4c6dc58 --- /dev/null +++ b/crates/cyber-server/src/runtime/binary_snapshots.rs @@ -0,0 +1,150 @@ +//! Saved binary references in the shared Session diff. The immutable source +//! catalog is independent of Git and of notification/hook delivery. No private +//! command journal or byte image is opened to build this summary. +#[cfg(any(target_os = "macos", target_os = "linux"))] +use super::FileDecision; +use super::{FileDiff, Runtime, RuntimeError, TurnContext}; + +impl Runtime { + pub(super) async fn merge_binary_snapshot_diffs( + &self, + turn: &TurnContext, + files: &mut Vec, + ) -> Result<(), RuntimeError> { + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + let _ = (turn, files); + Ok(()) + } + #[cfg(any(target_os = "macos", target_os = "linux"))] + { + let mut grouped = std::collections::BTreeMap::< + String, + ( + std::path::PathBuf, + Vec, + ), + >::new(); + for record in self.binary_snapshot_records(turn).await? { + let path = record.resource.path().to_path_buf(); + if self + .file_decisions(turn, "read", std::slice::from_ref(&path)) + .await + .first() + != Some(&FileDecision::Allow) + { + files.retain(|file| file.file != record.version.path); + continue; + } + let Some(version) = self.binary_snapshot_reference(turn, record).await? else { + continue; + }; + grouped + .entry(version.path.clone()) + .or_insert_with(|| (path, Vec::new())) + .1 + .push(version); + } + if self + .file_turn( + Some(&turn.session_id), + std::path::Path::new(&turn.directory), + ) + .await? + != *turn + { + return Err(RuntimeError::Conflict( + "Binary snapshot scope changed".into(), + )); + } + for (path, (resource, versions)) in grouped { + if self.file_decisions(turn, "read", &[resource]).await.first() + != Some(&FileDecision::Allow) + { + files.retain(|file| file.file != path); + continue; + } + if let Some(existing) = files.iter_mut().find(|file| file.file == path) { + existing.binary = Some(versions); + } else { + files.push(FileDiff { + file: path, + status: "modified".into(), + additions: 0, + deletions: 0, + patch: String::new(), + binary: Some(versions), + }); + } + } + Ok(()) + } + } + #[cfg(any(target_os = "macos", target_os = "linux"))] + async fn binary_snapshot_records( + &self, + turn: &TurnContext, + ) -> Result, RuntimeError> { + let Ok(storage) = self.binary_documents().storage() else { + return Ok(Vec::new()); + }; + let permit = self + .binary_documents() + .worker() + .map_err(|e| RuntimeError::Conflict(e.to_string()))?; + let (session, directory) = (turn.session_id.clone(), turn.directory.clone()); + let cancel = self.inner.closed.child_token(); + let _cancel = cancel.clone().drop_guard(); + tokio::task::spawn_blocking(move || { + let _permit = permit; + storage.session_saved_references(&session, std::path::Path::new(&directory), || { + cancel.is_cancelled() + }) + }) + .await + .map_err(|_| RuntimeError::Conflict("Binary snapshot worker failed".into()))? + .map_err(|e| { + RuntimeError::Conflict(format!( + "Binary snapshot references are unavailable: {e}; use paged source history" + )) + }) + } + #[cfg(any(target_os = "macos", target_os = "linux"))] + async fn binary_snapshot_reference( + &self, + turn: &TurnContext, + record: cyber_core::binary::editor::SavedReference, + ) -> Result, RuntimeError> { + let Ok(storage) = self.binary_documents().storage() else { + return Ok(None); + }; + let permit = self + .binary_documents() + .worker() + .map_err(|e| RuntimeError::Conflict(e.to_string()))?; + let directory = turn.directory.clone(); + tokio::task::spawn_blocking(move || { + let _permit = permit; + let root = cyber_core::config::project_root(std::path::Path::new(&directory)) + .canonicalize() + .ok()?; + if root != record.root { + return None; + } + let source = cyber_core::binary::Document::open(&root, &record.version.path).ok()?; + if source.path() != record.resource.path() { + return None; + } + let version = storage + .version_info( + &source, + &record.version.document_id, + &record.version.version_id, + ) + .ok()?; + (version == record.version).then_some(version) + }) + .await + .map_err(|_| RuntimeError::Conflict("Binary snapshot worker failed".into())) + } +} diff --git a/crates/cyber-server/src/runtime/host.rs b/crates/cyber-server/src/runtime/host.rs index fbf25031..24898060 100644 --- a/crates/cyber-server/src/runtime/host.rs +++ b/crates/cyber-server/src/runtime/host.rs @@ -1383,6 +1383,10 @@ pub struct FileDiff { pub additions: u32, pub deletions: u32, pub patch: String, + /// Acknowledged binary source versions, independent of Git snapshot limits. + /// These are saved references, not a synthesized text patch or net byte diff. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub binary: Option>, } #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] diff --git a/crates/cyber-server/src/runtime/mod.rs b/crates/cyber-server/src/runtime/mod.rs index 4fe3c380..cdc0a241 100644 --- a/crates/cyber-server/src/runtime/mod.rs +++ b/crates/cyber-server/src/runtime/mod.rs @@ -55,6 +55,7 @@ pub(crate) mod binary_documents; pub mod binary_effects; #[cfg(any(target_os = "macos", target_os = "linux"))] mod binary_recovery; +mod binary_snapshots; mod file_observation_hooks; mod file_observation_roots; mod file_observation_scopes; diff --git a/crates/cyber-server/src/runtime/rewind.rs b/crates/cyber-server/src/runtime/rewind.rs index 733f2ad5..d961a5d9 100644 --- a/crates/cyber-server/src/runtime/rewind.rs +++ b/crates/cyber-server/src/runtime/rewind.rs @@ -331,21 +331,24 @@ impl Runtime { /// latest post-Turn snapshot; empty before any snapshot. pub async fn session_diff(&self, session_id: &str) -> Result, RuntimeError> { let handle = self.inner.handle(session_id).await?; - let (directory, first, last) = { + let (turn, first, last) = { let state = handle.state.lock().await; let first = state.steps.iter().find_map(|s| s.pre.clone()); let last = state.steps.iter().rev().find_map(|s| s.post.clone()); - (state.info.directory.clone(), first, last) + (super::drain::turn_context_for(&state, false), first, last) }; - let (Some(first), Some(last)) = (first, last) else { - return Ok(Vec::new()); + let mut files = match (first, last) { + (Some(first), Some(last)) => self + .inner + .options + .snapshots + .diff(&turn.directory, &first, &last) + .await + .map_err(|e| RuntimeError::Conflict(format!("Session diff is unavailable: {e}")))?, + _ => Vec::new(), }; - self.inner - .options - .snapshots - .diff(&directory, &first, &last) - .await - .map_err(|e| RuntimeError::Conflict(format!("Session diff is unavailable: {e}"))) + self.merge_binary_snapshot_diffs(&turn, &mut files).await?; + Ok(files) } /// Stage a rewind to just before `message_id`: restore files when the target includes diff --git a/crates/cyber-snapshot/src/git.rs b/crates/cyber-snapshot/src/git.rs index 7371c574..e697783a 100644 --- a/crates/cyber-snapshot/src/git.rs +++ b/crates/cyber-snapshot/src/git.rs @@ -268,6 +268,7 @@ impl Shadow { _ => "modified", }; out.push(FileDiff { + binary: None, file: file.clone(), status: status.into(), additions, diff --git a/crates/cyber-tui/src/hex.rs b/crates/cyber-tui/src/hex.rs index 82e19fae..8883ab06 100644 --- a/crates/cyber-tui/src/hex.rs +++ b/crates/cyber-tui/src/hex.rs @@ -6,6 +6,8 @@ use base64::Engine; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; +#[path = "hex/edit.rs"] +pub(crate) mod edit; #[derive(Debug, Clone, PartialEq)] pub struct Document { @@ -13,10 +15,17 @@ pub struct Document { pub path: String, pub revision: String, pub length: u64, + pub source: String, } #[derive(Debug, Clone, PartialEq)] pub enum Request { + Edit { + document: Document, + command: edit::Command, + operation_id: String, + generation: u64, + }, Open { path: String, expected_root: Option, @@ -55,6 +64,10 @@ pub enum Input { FindHex, FindText, Bookmark, + InsertHex, + InsertText, + ReplaceHex, + ReplaceText, } impl Input { pub fn label(self) -> &'static str { @@ -63,11 +76,16 @@ impl Input { Self::FindHex => "Find hex (Tab: UTF-8)", Self::FindText => "Find UTF-8 (Tab: hex)", Self::Bookmark => "Bookmark name", + Self::InsertHex => "Insert hex (Tab: UTF-8)", + Self::InsertText => "Insert UTF-8 (Tab: hex)", + Self::ReplaceHex => "Overwrite hex (Tab: UTF-8)", + Self::ReplaceText => "Overwrite UTF-8 (Tab: hex)", } } } #[derive(Debug, Default)] pub struct Workspace { + pub edit: edit::State, pub document: Option, pub generation: u64, pub bytes: Vec, @@ -86,7 +104,13 @@ pub struct Workspace { } impl Workspace { + pub(crate) fn selection(&self) -> Option<(u64, u64)> { + let anchor = self.edit.anchor?; + (anchor != self.cursor).then_some((anchor.min(self.cursor), anchor.max(self.cursor))) + } + pub fn forget(&mut self, session: &Session) { + self.edit = edit::State::default(); if let Some(document) = self.document.take() { self.pending_closes.push(Request::Close { id: document.id, @@ -196,6 +220,10 @@ impl App { return vec![]; } let mut actions = self.close_hex_document(); + self.hex.edit = edit::State { + read_only, + ..Default::default() + }; self.hex.generation = self.hex.generation.wrapping_add(1); self.hex.bytes.clear(); self.hex.cursor = offset; @@ -217,7 +245,7 @@ impl App { actions } - fn close_hex_document(&mut self) -> Vec { + pub(super) fn close_hex_document(&mut self) -> Vec { self.hex.wanted = false; self.hex.bytes.clear(); self.hex.bookmarks.clear(); @@ -239,6 +267,9 @@ impl App { if self.hex.input.is_some() { return self.hex_input_key(key); } + if let Some(actions) = self.hex_edit_key(key) { + return actions; + } match self.keymap.action("hex", &key, false) { Some("hex_close") => { self.hex.generation = self.hex.generation.wrapping_add(1); @@ -314,6 +345,10 @@ impl App { *kind = match *kind { Input::FindHex => Input::FindText, Input::FindText => Input::FindHex, + Input::InsertHex => Input::InsertText, + Input::InsertText => Input::InsertHex, + Input::ReplaceHex => Input::ReplaceText, + Input::ReplaceText => Input::ReplaceHex, other => other, } } @@ -332,6 +367,12 @@ impl App { } fn hex_submit(&mut self, kind: Input, input: String) -> Vec { + if matches!( + kind, + Input::InsertHex | Input::InsertText | Input::ReplaceHex | Input::ReplaceText + ) { + return self.hex_edit_submit(kind, input); + } match kind { Input::Goto => { let length = self.hex.document.as_ref().map_or(0, |d| d.length); @@ -381,6 +422,9 @@ impl App { } } } + Input::InsertHex | Input::InsertText | Input::ReplaceHex | Input::ReplaceText => { + unreachable!() + } } vec![] } @@ -417,6 +461,14 @@ impl App { pub(crate) fn apply(app: &mut App, msg: crate::perform::Msg) -> Vec { use crate::perform::Msg; match msg { + Msg::HexEdit { + session_id, + directory, + generation, + result, + } if active(app, &session_id, &directory, generation) => { + return app.hex_edit_reply(result); + } Msg::Hex { session_id, directory, @@ -558,7 +610,7 @@ fn decode_page(value: &Value, document: &Document, offset: u64) -> Result crate::perform::Msg { use crate::perform::Msg; let (generation, opened, result) = match request { + Request::Edit { + document, + command, + operation_id, + generation, + } => { + return crate::perform::Msg::HexEdit { + session_id: session.id.clone(), + directory: session.directory.clone(), + generation, + result: edit::perform(client, session, document, command, operation_id).await, + }; + } Request::Close { id, session_id, @@ -643,6 +708,7 @@ pub async fn perform( id: field(data, "document_id")?, path: field(data, "path")?, revision: field(data, "revision")?, + source: field(data,"source")?, length: field(data, "length")? .parse() .map_err(|_| "Invalid byte length")?, @@ -683,6 +749,7 @@ mod tests { #[test] fn byte_page_checks_encoding_budget_scope_and_content_evidence() { let document = Document { + source: "live".into(), id: "bin_one".into(), path: "bytes".into(), revision: "observed".into(), @@ -780,6 +847,7 @@ mod ownership_tests { opened: true, result: Ok(Loaded { document: Document { + source: "live".into(), id: "bin_one".into(), path: "bytes".into(), revision: "observed".into(), diff --git a/crates/cyber-tui/src/hex/edit.rs b/crates/cyber-tui/src/hex/edit.rs new file mode 100644 index 00000000..c0ef68ad --- /dev/null +++ b/crates/cyber-tui/src/hex/edit.rs @@ -0,0 +1,543 @@ +//! Explicit local commands. A request is issued once; a lost response requires +//! status/reconciliation before another command can be submitted. +use super::*; +#[path = "edit_client.rs"] +mod client; +pub use client::perform; + +#[derive(Debug, Default)] +pub struct State { + pub read_only: bool, + pub prepared: bool, + pub enabled: bool, + pub dirty: bool, + pub uncertain: bool, + pub closing: bool, + pub nibble: Option, + pub anchor: Option, + pub notice: Option, + pub review_scroll: usize, + pub review_start: usize, + pub review: Option<(cyber_core::binary::receipts::SaveReview, bool)>, + pub last_operation: Option, +} +#[derive(Debug, Clone, PartialEq)] +pub enum Command { + Prepare, + Copy { + offset: u64, + length: u64, + }, + Patch { + offset: u64, + delete: u64, + hex: String, + }, + History(&'static str), + Review { + restore: bool, + start: usize, + }, + Save { + fingerprint: String, + restore: bool, + }, + Reconcile, +} +#[derive(Debug, Clone)] +pub struct Reply { + pub status: Value, + pub clipboard: Option, + pub review: Option<(cyber_core::binary::receipts::SaveReview, bool)>, + pub operation: Option, +} + +#[derive(serde::Deserialize)] +struct Status { + #[serde(flatten)] + metadata: cyber_core::binary::Metadata, + prepared: bool, + dirty: bool, + pending_operation: Option, +} +fn status( + value: Value, + document: &Document, + review: Option<&(cyber_core::binary::receipts::SaveReview, bool)>, +) -> Result { + if value.get("pending_operation").is_none() { + return Err("Missing binary operation state".into()); + } + let status: Status = serde_json::from_value(value).map_err(|_| "Invalid binary status")?; + if status.metadata.path != document.path + || review.is_some_and(|(review, _)| review.draft.revision != status.metadata.revision) + { + return Err("Binary response scope/revision changed".into()); + } + Ok(status) +} + +impl App { + fn hex_edit_request(&mut self, command: Command) -> Vec { + let Some(document) = self.hex.document.clone() else { + return vec![]; + }; + if self.hex.loading { + return vec![]; + } + if let Command::Review { start, .. } = &command { + self.hex.edit.review_start = *start; + self.hex.edit.review_scroll = 0; + } + self.hex.edit.nibble = None; + self.hex.edit.anchor = None; + self.hex.edit.notice = None; + self.hex.edit.review = None; + self.hex.error = None; + self.hex.generation = self.hex.generation.wrapping_add(1); + self.hex.loading = true; + let operation_id = cyber_core::ids::new_id("op"); + if matches!( + command, + Command::Patch { .. } | Command::History(_) | Command::Save { .. } + ) { + self.hex.edit.last_operation = Some(operation_id.clone()); + } + vec![Action::Hex(Request::Edit { + operation_id, + document, + command, + generation: self.hex.generation, + })] + } + pub(super) fn hex_edit_key(&mut self, key: KeyEvent) -> Option> { + if self.hex.edit.review.is_some() && matches!(key.code, KeyCode::PageUp | KeyCode::PageDown) + { + return Some(self.hex_review_move(key.code == KeyCode::PageDown)); + } + if matches!( + key.code, + KeyCode::Left + | KeyCode::Right + | KeyCode::Up + | KeyCode::Down + | KeyCode::PageUp + | KeyCode::PageDown + | KeyCode::Home + | KeyCode::End + ) { + if key.modifiers.contains(KeyModifiers::SHIFT) { + self.hex.edit.anchor.get_or_insert(self.hex.cursor); + } else { + self.hex.edit.anchor = None; + } + } + if self.keymap.action("hex", &key, false) == Some("hex_copy_selection") + && let Some((offset, end)) = self.hex.selection() + { + return Some(self.hex_edit_request(Command::Copy { + offset, + length: end - offset, + })); + } + if let Some(actions) = self.hex_edit_interrupt(key) { + return Some(actions); + } + let command = self.hex_edit_command(key); + if let Some(command) = command { + if self.hex.edit.uncertain && !matches!(command, Command::Reconcile) { + self.hex.error = + Some("Outcome requires Ctrl+R reconciliation before editing".into()); + return Some(vec![]); + } + return Some(self.hex_edit_request(command)); + } + if !self.hex.edit.enabled || self.hex.edit.uncertain { + return None; + } + Some(self.hex_edit_content_key(key)) + } + fn hex_review_move(&mut self, down: bool) -> Vec { + let Some((review, restore)) = &self.hex.edit.review else { + return vec![]; + }; + if down && self.hex.edit.review_scroll + 1 < review.draft.ranges.len() { + self.hex.edit.review_scroll += 1; + } else if down && let Some(start) = review.draft.next_range { + return self.hex_edit_request(Command::Review { + restore: *restore, + start, + }); + } else if !down && self.hex.edit.review_scroll > 0 { + self.hex.edit.review_scroll -= 1; + } else if !down && self.hex.edit.review_start > 0 { + return self.hex_edit_request(Command::Review { + restore: *restore, + start: self.hex.edit.review_start.saturating_sub(128), + }); + } + vec![] + } + fn hex_edit_command(&mut self, key: KeyEvent) -> Option { + match self.keymap.action("hex", &key, false) { + Some("hex_edit") if !self.hex.edit.read_only => { + if self.hex.edit.prepared { + self.hex.edit.enabled = !self.hex.edit.enabled; + return None; + } + Some(Command::Prepare) + } + Some("hex_reconcile") => Some(Command::Reconcile), + Some("hex_save") if self.hex.edit.prepared => match self.hex.edit.review.as_ref() { + Some((review, restore)) => Some(Command::Save { + fingerprint: review.fingerprint.clone(), + restore: *restore, + }), + None => Some(Command::Review { + restore: false, + start: 0, + }), + }, + Some("hex_restore") if self.hex.edit.prepared => Some(Command::Review { + restore: true, + start: 0, + }), + Some("hex_undo") if self.hex.edit.enabled => Some(Command::History("undo")), + Some("hex_redo") if self.hex.edit.enabled => Some(Command::History("redo")), + _ => None, + } + } + fn hex_edit_interrupt(&mut self, key: KeyEvent) -> Option> { + if self.hex.edit.closing { + return Some(self.hex_close_choice(key)); + } + if key.code == KeyCode::Esc { + if self.hex.edit.nibble.take().is_some() || self.hex.edit.review.take().is_some() { + return Some(vec![]); + } + if self.hex.edit.dirty + || self.hex.edit.uncertain + || (self.hex.edit.prepared && self.hex.loading) + { + self.hex.edit.closing = true; + return Some(vec![]); + } + } + if self.hex.loading { + return if key.code == KeyCode::Esc { + None + } else { + Some(vec![]) + }; + } + None + } + fn hex_edit_content_key(&mut self, key: KeyEvent) -> Vec { + match key.code { + _ if self.keymap.action("hex", &key, false) == Some("hex_insert") => { + self.hex.input = Some((Input::InsertHex, String::new())) + } + _ if self.keymap.action("hex", &key, false) == Some("hex_replace") => { + self.hex.input = Some((Input::ReplaceHex, String::new())) + } + _ if self.keymap.action("hex", &key, false) == Some("hex_delete") => { + if let Some((offset, end)) = self.hex.selection() { + return self.hex_edit_request(Command::Patch { + offset, + delete: end - offset, + hex: String::new(), + }); + } + let length = self.hex.document.as_ref().map_or(0, |d| d.length); + if self.hex.cursor < length { + return self.hex_edit_request(Command::Patch { + offset: self.hex.cursor, + delete: 1, + hex: String::new(), + }); + } + } + KeyCode::Char(c) + if !key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) + && c.is_ascii_hexdigit() => + { + self.hex.edit.anchor = None; + let value = c.to_digit(16).unwrap() as u8; + if let Some(high) = self.hex.edit.nibble.take() { + let delete = u64::from( + self.hex + .document + .as_ref() + .is_some_and(|d| self.hex.cursor < d.length), + ); + return self.hex_edit_request(Command::Patch { + offset: self.hex.cursor, + delete, + hex: format!("{:02x}", high * 16 + value), + }); + } + self.hex.edit.nibble = Some(value); + } + _ => { + self.hex.edit.nibble = None; + return self.hex_navigate(key.code); + } + } + vec![] + } + fn hex_close_choice(&mut self, key: KeyEvent) -> Vec { + match key.code { + KeyCode::Esc => self.hex.edit.closing = false, + KeyCode::Char('k') => { + self.hex.generation = self.hex.generation.wrapping_add(1); + self.overlay = Overlay::None; + return self.close_hex_document(); + } + KeyCode::Char('d') if !self.hex.edit.uncertain && !self.hex.loading => { + self.hex.edit.closing = false; + return self.hex_edit_request(Command::History("discard")); + } + _ => {} + } + vec![] + } + pub(super) fn hex_edit_submit(&mut self, kind: Input, input: String) -> Vec { + if !self.hex.edit.enabled || self.hex.edit.uncertain { + return vec![]; + } + let bytes = if matches!(kind, Input::InsertText | Input::ReplaceText) { + Ok(input.into_bytes()) + } else { + cyber_core::binary::decode_hex(&input, 4096) + .map_err(|_| "Enter complete hex byte pairs") + }; + match bytes { + Ok(bytes) if !bytes.is_empty() && bytes.len() <= 4096 => { + let length = self.hex.document.as_ref().map_or(0, |d| d.length); + let selection = self.hex.selection(); + let offset = selection.map_or(self.hex.cursor, |(start, _)| start); + let delete = if matches!(kind, Input::ReplaceHex | Input::ReplaceText) { + selection.map_or( + (bytes.len() as u64).min(length.saturating_sub(offset)), + |(start, end)| end - start, + ) + } else { + 0 + }; + let hex = bytes.iter().map(|b| format!("{b:02x}")).collect(); + self.hex_edit_request(Command::Patch { + offset, + delete, + hex, + }) + } + _ => { + self.hex.error = + Some("Enter 1–4096 bytes, with complete hex pairs or literal UTF-8".into()); + vec![] + } + } + } + pub(super) fn hex_edit_reply(&mut self, result: Result) -> Vec { + self.hex.loading = false; + match result { + Err(error) => { + self.hex.bytes.clear(); + self.hex.edit.uncertain = true; + self.hex.error = Some(format!( + "{error}; Ctrl+R checks retained state without retrying a save" + )); + vec![] + } + Ok(reply) => { + let clipboard = reply.clipboard; + let Some(document) = self.hex.document.as_ref() else { + return vec![]; + }; + let status = match status(reply.status, document, reply.review.as_ref()) { + Ok(status) => status, + Err(error) => return self.hex_edit_reply(Err(error)), + }; + let document = self.hex.document.as_mut().unwrap(); + document.revision = status.metadata.revision; + document.source = match status.metadata.source { + cyber_core::binary::Source::Live => "live", + cyber_core::binary::Source::Draft => "draft", + } + .into(); + document.length = status.metadata.length.0; + self.hex.cursor = self.hex.cursor.min(document.length); + self.hex.edit.prepared = status.prepared; + self.hex.edit.enabled = status.prepared && !self.hex.edit.read_only; + self.hex.edit.dirty = status.dirty; + self.hex.edit.uncertain = status.pending_operation.is_some(); + self.hex.edit.review = reply.review; + if let Some(operation) = reply.operation.as_ref() { + self.hex.edit.last_operation = operation["id"].as_str().map(str::to_owned); + self.hex.edit.notice = Some(format!( + "{}: {} · file effect {}{}", + operation["kind"].as_str().unwrap_or("operation"), + operation["outcome"].as_str().unwrap_or("unknown"), + operation["file_effect"].as_str().unwrap_or("unknown"), + if operation["notification_pending"] == true { + " · notification pending" + } else { + "" + } + )); + } + self.hex.found.clear(); + self.hex.search_hex = None; + self.hex.search_next = None; + let mut actions = self.hex.read(); + if let Some(hex) = clipboard { + actions.push(Action::Copy(hex)); + } + actions + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn app() -> App { + let mut app = App::new( + Session { + id: "ses_test".into(), + directory: "/project".into(), + ..Default::default() + }, + vec![], + "cyber", + ); + app.open_hex("bytes"); + app.hex.loading = false; + app.hex.document = Some(Document { + id: "bin_test".into(), + path: "bytes".into(), + revision: "draft_one".into(), + source: "draft".into(), + length: 2, + }); + app.hex.bytes = vec![0, 255]; + app.hex.edit.prepared = true; + app.hex.edit.enabled = true; + app + } + fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) + } + #[test] + fn review_navigation_reaches_ranges_beyond_the_first_server_page() { + let mut app = app(); + let range = serde_json::json!({"original_offset":"0","original_length":"1","draft_offset":"0","draft_length":"1","before_sha256":"a".repeat(64),"after_sha256":"b".repeat(64),"before_hex":"00","after_hex":"ff","preview_truncated":false}); + let review=serde_json::from_value(serde_json::json!({"draft":{"revision":"draft_one","base_sha256":"a".repeat(64),"proposed_sha256":"b".repeat(64),"base_length":"2","proposed_length":"2","original_bytes":"1","proposed_bytes":"1","range_count":129,"ranges":vec![range;128],"next_range":128,"fingerprint":"c".repeat(64)},"permissions":420,"owner_uid":0,"owner_gid":0,"metadata_sha256":"d".repeat(64),"effects":"review only","fingerprint":"e".repeat(64)})).unwrap(); + app.hex.edit.review = Some((review, false)); + app.hex.edit.review_scroll = 126; + assert!(app.hex_key(key(KeyCode::PageDown)).is_empty()); + assert_eq!(app.hex.edit.review_scroll, 127); + let actions = app.hex_key(key(KeyCode::PageDown)); + assert!(matches!( + &actions[..], + [Action::Hex(Request::Edit { + command: Command::Review { + start: 128, + restore: false + }, + .. + })] + )); + assert_eq!(app.hex.document.as_ref().unwrap().revision, "draft_one"); + assert_eq!(app.hex.bytes, [0, 255]); + } + + #[test] + fn nibble_is_local_until_complete_and_escape_cancels_without_a_patch() { + let mut app = app(); + assert!(app.hex_key(key(KeyCode::Char('a'))).is_empty()); + assert_eq!(app.hex.edit.nibble, Some(10)); + assert!(app.hex_key(key(KeyCode::Esc)).is_empty()); + assert!(app.hex.wanted); + assert_eq!(app.hex.edit.nibble, None); + app.hex_key(key(KeyCode::Char('f'))); + let actions = app.hex_key(key(KeyCode::Char('1'))); + assert!( + matches!(&actions[..],[Action::Hex(Request::Edit {command:Command::Patch {offset:0,delete:1,hex},..})] if hex=="f1") + ); + assert_eq!( + app.hex.bytes, + [0, 255], + "source and displayed page are unchanged before acknowledgement" + ); + } + #[test] + fn dirty_close_requires_a_choice_and_unknown_never_discards() { + let mut app = app(); + app.hex.edit.dirty = true; + assert!(app.hex_key(key(KeyCode::Esc)).is_empty()); + assert!(app.hex.edit.closing); + app.hex.edit.uncertain = true; + assert!(app.hex_key(key(KeyCode::Char('d'))).is_empty()); + assert!(app.hex.wanted); + let actions = app.hex_key(key(KeyCode::Char('k'))); + assert!(matches!(&actions[..], [Action::Hex(Request::Close { .. })])); + assert!(!app.hex.wanted); + } + #[test] + fn uncertain_reply_requires_reconciliation_and_stale_reply_cannot_change_the_draft() { + let mut app = app(); + app.hex_edit_reply(Err("connection lost".into())); + assert!(app.hex.edit.uncertain); + assert!(app.hex.bytes.is_empty()); + assert!( + app.hex_key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)) + .is_empty() + ); + let actions = app.hex_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + assert!(matches!( + &actions[..], + [Action::Hex(Request::Edit { + command: Command::Reconcile, + .. + })] + )); + let generation = app.hex.generation; + let session_id = app.session.id.clone(); + let directory = app.session.directory.clone(); + super::super::apply( + &mut app, + crate::perform::Msg::HexEdit { + session_id, + directory, + generation: generation.wrapping_sub(1), + result: Ok(Reply { + clipboard: None, + status: json!({"path":"bytes","revision":"stale","source":"draft","length":"1"}), + review: None, + operation: None, + }), + }, + ); + assert_eq!(app.hex.document.as_ref().unwrap().revision, "draft_one"); + assert!(app.hex.edit.uncertain); + } + #[test] + fn read_only_view_never_prepares_and_text_insertion_is_encoding_explicit() { + let mut app = app(); + app.hex.edit = State { + read_only: true, + ..Default::default() + }; + assert!(app.hex_key(key(KeyCode::F(2))).is_empty()); + app.hex.edit.read_only = false; + app.hex.edit.enabled = true; + let actions = app.hex_edit_submit(Input::InsertText, "é".into()); + assert!( + matches!(&actions[..],[Action::Hex(Request::Edit {command:Command::Patch {delete:0,hex,..},..})] if hex=="c3a9") + ); + } +} diff --git a/crates/cyber-tui/src/hex/edit_client.rs b/crates/cyber-tui/src/hex/edit_client.rs new file mode 100644 index 00000000..c1c20292 --- /dev/null +++ b/crates/cyber-tui/src/hex/edit_client.rs @@ -0,0 +1,150 @@ +use super::*; +use cyber_client::Client; + +pub async fn perform( + client: &Client, + session: &Session, + document: Document, + command: Command, + operation_id: String, +) -> Result { + let base = format!("/files/binary/documents/{}", document.id); + if let Command::Copy { offset, length } = command { + return copy(client, session, document, &base, offset, length).await; + } + if matches!(command, Command::Reconcile) { + let response = client + .at(&session.directory) + .get(&format!("{base}?session_id={}", session.id)) + .await + .map_err(|e| e.to_string())?; + if response["data"]["pending_operation"].is_null() { + return Ok(Reply { + clipboard: None, + status: response["data"].clone(), + review: None, + operation: None, + }); + } + } + + let mut body = json!({"session_id":session.id,"revision":document.revision}); + let (route, review) = match command { + Command::Copy { .. } => unreachable!("copy returned above"), + Command::Prepare => ("prepare", None), + Command::Patch { + offset, + delete, + hex, + } => { + if delete > 4096 { + return Err("Patch exceeds bounded selection limit".into()); + } + let expected = if delete == 0 { + String::new() + } else { + let loaded = super::super::page(client, session, document.clone(), offset).await?; + let bytes = loaded + .bytes + .get(..delete as usize) + .ok_or("Short expected-byte read")?; + bytes.iter().map(|b| format!("{b:02x}")).collect() + }; + body["operation_id"] = json!(operation_id); + body["splices"] = json!([{"offset":offset.to_string(),"delete_length":delete.to_string(),"expected_hex":expected,"insert_hex":hex}]); + ("patches", None) + } + Command::History(action) => { + body["operation_id"] = json!(operation_id); + body["action"] = json!(action); + ("history", None) + } + Command::Review { restore, start } => { + if !restore { + body["start"] = json!(start); + body["limit"] = json!(128); + } + ( + if restore { "restore-review" } else { "review" }, + Some(restore), + ) + } + Command::Save { + fingerprint, + restore, + } => { + body = + json!({"session_id":session.id,"operation_id":operation_id,"review":fingerprint}); + (if restore { "restore" } else { "save" }, None) + } + Command::Reconcile => { + // Reconciliation never starts another native exchange. + body = json!({"session_id":session.id}); + ("reconcile", None) + } + }; + let response = client + .at(&session.directory) + .post(&format!("{base}/{route}"), body) + .await + .map_err(|e| e.to_string())?; + let data = &response["data"]; + let review = review + .map(|restore| { + serde_json::from_value(data.clone()) + .map(|review| (review, restore)) + .map_err(|_| "Invalid binary review".to_owned()) + }) + .transpose()?; + let status = if route == "prepare" { + data.clone() + } else if data.get("status").is_some() { + data["status"].clone() + } else { + client + .at(&session.directory) + .get(&format!("{base}?session_id={}", session.id)) + .await + .map_err(|e| e.to_string())?["data"] + .clone() + }; + Ok(Reply { + clipboard: None, + status, + review, + operation: data.get("operation").cloned(), + }) +} + +async fn copy( + client: &Client, + session: &Session, + document: Document, + base: &str, + offset: u64, + length: u64, +) -> Result { + if length == 0 || length > 4096 { + return Err("Copy requires a selection of 1–4096 bytes".into()); + } + let loaded = super::super::page(client, session, document, offset).await?; + let bytes = loaded + .bytes + .get(..length as usize) + .ok_or("Short selection read")?; + let hex = bytes.iter().map(|b| format!("{b:02x}")).collect(); + let response = client + .at(&session.directory) + .get(&format!("{base}?session_id={}", session.id)) + .await + .map_err(|e| e.to_string())?; + if response["data"]["revision"] != loaded.document.revision { + return Err("Selection revision changed".into()); + } + Ok(Reply { + status: response["data"].clone(), + clipboard: Some(hex), + review: None, + operation: None, + }) +} diff --git a/crates/cyber-tui/src/keys.rs b/crates/cyber-tui/src/keys.rs index 0f395d40..d8137b3d 100644 --- a/crates/cyber-tui/src/keys.rs +++ b/crates/cyber-tui/src/keys.rs @@ -143,6 +143,26 @@ const DEFAULTS: &[(&str, &str, &str, &str)] = &[ "move focus between the tree and the preview", ), ("hex", "hex_close", "esc", "close hex"), + ("hex", "hex_edit", "f2", "toggle private draft editing"), + ("hex", "hex_save", "ctrl+s", "review or apply reviewed save"), + ("hex", "hex_restore", "alt+r", "review Restore Last Save"), + ( + "hex", + "hex_reconcile", + "ctrl+r", + "reconcile retained outcome", + ), + ("hex", "hex_undo", "ctrl+z", "undo draft command"), + ("hex", "hex_redo", "ctrl+y", "redo draft command"), + ("hex", "hex_insert", "alt+i", "insert literal bytes"), + ("hex", "hex_replace", "alt+o", "overwrite literal bytes"), + ("hex", "hex_delete", "delete", "delete selected bytes"), + ( + "hex", + "hex_copy_selection", + "ctrl+c", + "copy bounded selection as hex", + ), ("hex", "hex_goto", "g", "goto offset"), ("hex", "hex_endian", "l", "toggle LE/BE"), ("hex", "hex_bookmark", "b", "name bookmark"), diff --git a/crates/cyber-tui/src/perform.rs b/crates/cyber-tui/src/perform.rs index 39d35132..44be658e 100644 --- a/crates/cyber-tui/src/perform.rs +++ b/crates/cyber-tui/src/perform.rs @@ -101,6 +101,12 @@ pub enum Msg { generation: u64, result: Result, }, + HexEdit { + session_id: String, + directory: String, + generation: u64, + result: Result, + }, Hex { session_id: String, directory: String, diff --git a/crates/cyber-tui/src/runner.rs b/crates/cyber-tui/src/runner.rs index 6182728a..d2be42d0 100644 --- a/crates/cyber-tui/src/runner.rs +++ b/crates/cyber-tui/src/runner.rs @@ -757,7 +757,9 @@ fn apply_msg(app: &mut App, msg: Result, refresh: &mut Refresh) -> | Msg::Insights { .. } | Msg::Limits { .. } | Msg::Recap { .. }) => settings_reply(app, msg), - msg @ (Msg::Hex { .. } | Msg::HexSearch { .. }) => return crate::hex::apply(app, msg), + msg @ (Msg::Hex { .. } | Msg::HexSearch { .. } | Msg::HexEdit { .. }) => { + return crate::hex::apply(app, msg); + } msg @ (Msg::Dir { .. } | Msg::FileHits { .. } | Msg::Preview { .. }) => { explorer_reply(app, msg) } diff --git a/crates/cyber-tui/src/screens_explorer.rs b/crates/cyber-tui/src/screens_explorer.rs index 334dd302..4d7919c4 100644 --- a/crates/cyber-tui/src/screens_explorer.rs +++ b/crates/cyber-tui/src/screens_explorer.rs @@ -967,3 +967,101 @@ async fn hex_stale_open_is_closed_and_session_switch_forgets_private_bytes() { f.run(&mut app, actions).await; } } + +#[cfg(any(target_os = "macos", target_os = "linux"))] +#[tokio::test] +async fn hex_edit_review_save_undo_and_restore_use_real_owner_routes() { + let f = Fixture::new().await; + let original = [0, 255, 27, 128]; + std::fs::write(f.work.join("editable.bin"), original).unwrap(); + let mut app = f.app().await; + let actions = app.open_hex("editable.bin"); + f.run(&mut app, actions).await; + app.on_key(KeyEvent::new(KeyCode::Right, KeyModifiers::SHIFT)); + app.on_key(KeyEvent::new(KeyCode::Right, KeyModifiers::SHIFT)); + assert_eq!(app.hex.selection(), Some((0, 2))); + let mut actions = app.on_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); + let result = f.perform(&app, actions.pop().unwrap()).await; + let mut replies = crate::runner::apply(&mut app, result, &mut Default::default()); + assert!( + replies + .iter() + .any(|action| matches!(action,Action::Copy(hex) if hex=="00ff")) + ); + replies.retain(|action| !matches!(action, Action::Copy(_))); + f.run(&mut app, replies).await; + app.hex.cursor = 0; + let actions = press(&mut app, KeyCode::F(2)); + f.run(&mut app, actions).await; + assert!(app.hex.edit.enabled, "{:?}", app.hex.error); + press(&mut app, KeyCode::Char('4')); + let actions = press(&mut app, KeyCode::Char('1')); + f.run(&mut app, actions).await; + assert!(app.hex.edit.dirty, "{:?}", app.hex.error); + assert_eq!(app.hex.bytes, [65, 255, 27, 128]); + assert_eq!( + std::fs::read(f.work.join("editable.bin")).unwrap(), + original + ); + let actions = app.on_key(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::CONTROL)); + f.run(&mut app, actions).await; + assert_eq!(app.hex.bytes, original); + let actions = app.on_key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL)); + f.run(&mut app, actions).await; + assert_eq!(app.hex.bytes, [65, 255, 27, 128]); + let actions = app.on_key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)); + f.run(&mut app, actions).await; + assert!(app.hex.edit.review.is_some(), "{:?}", app.hex.error); + assert_eq!( + std::fs::read(f.work.join("editable.bin")).unwrap(), + original + ); + let shown = render(&mut app, 120, 30, &Opts::default()); + assert!(shown.contains("BASE SHA256")); + assert!(shown.contains("SAVE REVIEW")); + let actions = app.on_key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)); + f.run(&mut app, actions).await; + assert!(!app.hex.edit.dirty, "{:?}", app.hex.error); + assert_eq!( + std::fs::read(f.work.join("editable.bin")).unwrap(), + [65, 255, 27, 128] + ); + let diff = f + .server + .client + .at(&app.session.directory) + .get(&format!("/sessions/{}/diff", app.session.id)) + .await + .unwrap(); + app.sidebar.files = crate::sidebar::Source::Ready(crate::sidebar::parse_files(&diff["data"])); + let index = app + .sidebar + .changes() + .iter() + .position(|file| file.file == "editable.bin") + .expect("saved binary in shared Session diff"); + assert!(app.sidebar.changes()[index].binary.is_some()); + app.overlay = Overlay::FileDiff { index, scroll: 0 }; + let shown = render(&mut app, 120, 30, &Opts::default()); + assert!(shown.contains("binary")); + assert!(shown.contains("SHA256")); + assert!(shown.contains("Saved binary versions")); + app.overlay = Overlay::Hex; + let actions = app.on_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::ALT)); + f.run(&mut app, actions).await; + assert!( + app.hex + .edit + .review + .as_ref() + .is_some_and(|(_, restore)| *restore), + "{:?}", + app.hex.error + ); + let actions = app.on_key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)); + f.run(&mut app, actions).await; + assert_eq!( + std::fs::read(f.work.join("editable.bin")).unwrap(), + original + ); +} diff --git a/crates/cyber-tui/src/sidebar.rs b/crates/cyber-tui/src/sidebar.rs index 4e2c330d..54bac6a5 100644 --- a/crates/cyber-tui/src/sidebar.rs +++ b/crates/cyber-tui/src/sidebar.rs @@ -117,6 +117,7 @@ pub struct LspServer { #[derive(Debug, Clone, PartialEq)] pub struct FileChange { + pub binary: Option>, pub file: String, pub additions: u64, pub deletions: u64, @@ -758,12 +759,32 @@ pub fn parse_lsp(data: &Value) -> Vec { .collect() } +fn parse_binary(data: &Value) -> Option> { + let array = data.as_array()?; + if array.is_empty() || array.len() > 512 { + return None; + } + let mut versions: Vec = + serde_json::from_value(data.clone()).ok()?; + for version in &mut versions { + version.path = clean(&version.path); + version.kind = clean(&version.kind); + version.document_id = clean(&version.document_id); + version.version_id = clean(&version.version_id); + version.operation_id = clean(&version.operation_id); + version.before_sha256 = clean(&version.before_sha256); + version.after_sha256 = clean(&version.after_sha256); + } + Some(versions) +} + /// `GET /sessions/:id/diff` data. pub fn parse_files(data: &Value) -> Vec { data.as_array() .into_iter() .flatten() .map(|d| FileChange { + binary: d.get("binary").and_then(parse_binary), file: clean(d["file"].as_str().unwrap_or_default()), additions: d["additions"].as_u64().unwrap_or(0), deletions: d["deletions"].as_u64().unwrap_or(0), @@ -1125,6 +1146,7 @@ mod tests { files: Source::Ready( (0..12) .map(|i| FileChange { + binary: None, file: format!("f{i}"), additions: 1, deletions: 0, diff --git a/crates/cyber-tui/src/snapshots/cyber_tui__screens_explorer__hex_light.snap b/crates/cyber-tui/src/snapshots/cyber_tui__screens_explorer__hex_light.snap index ffc10d57..58ca6a0c 100644 --- a/crates/cyber-tui/src/snapshots/cyber_tui__screens_explorer__hex_light.snap +++ b/crates/cyber-tui/src/snapshots/cyber_tui__screens_explorer__hex_light.snap @@ -3,7 +3,7 @@ source: crates/cyber-tui/src/screens_explorer.rs expression: "shown.replace(&revision, \"obs_revision\")" --- HEX fixture.bin 256 bytes -LIVE VIEW · observed obs_revision · read only +LIVE VIEW · revision obs_revision · view mode OFFSET HEX BYTES ASCII 0000000000000000 >00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F ................ 0000000000000010 10 11 12 13 14 15 16 17 18 19 1A 1B 1C 1D 1E 1F ................ @@ -30,5 +30,5 @@ u16 256 i16 256 u32 50462976 i32 50462976 u64 506097522914230528 i64 506097522914230528 f32 3.8204714e-37 f64 7.949928895127363e-275 offset 0x0 / 0 bookmarks 0 -esc close · g goto · / find · l LE/BE · b bookmark · f3 next match · c copy byte +esc close · g goto · / find · l LE/BE · b bookmark · f3 next match · c copy byte · f2 edit · ctrl+r 100 · — ctrl+p commands diff --git a/crates/cyber-tui/src/snapshots/cyber_tui__screens_explorer__hex_narrow.snap b/crates/cyber-tui/src/snapshots/cyber_tui__screens_explorer__hex_narrow.snap index 20760850..5db216ac 100644 --- a/crates/cyber-tui/src/snapshots/cyber_tui__screens_explorer__hex_narrow.snap +++ b/crates/cyber-tui/src/snapshots/cyber_tui__screens_explorer__hex_narrow.snap @@ -3,7 +3,7 @@ source: crates/cyber-tui/src/screens_explorer.rs expression: "shown.replace(&revision, \"obs_revision\")" --- HEX fixture.bin 256 bytes -LIVE VIEW · observed obs_revision · read only +LIVE VIEW · revision obs_revision · view mode OFFSET HEX BYTES ASCII 0000000000000000 >00 01 02 03 04 05 06 07 ........ 0000000000000008 08 09 0A 0B 0C 0D 0E 0F ........ diff --git a/crates/cyber-tui/src/snapshots/cyber_tui__screens_explorer__hex_no_color.snap b/crates/cyber-tui/src/snapshots/cyber_tui__screens_explorer__hex_no_color.snap index ffc10d57..58ca6a0c 100644 --- a/crates/cyber-tui/src/snapshots/cyber_tui__screens_explorer__hex_no_color.snap +++ b/crates/cyber-tui/src/snapshots/cyber_tui__screens_explorer__hex_no_color.snap @@ -3,7 +3,7 @@ source: crates/cyber-tui/src/screens_explorer.rs expression: "shown.replace(&revision, \"obs_revision\")" --- HEX fixture.bin 256 bytes -LIVE VIEW · observed obs_revision · read only +LIVE VIEW · revision obs_revision · view mode OFFSET HEX BYTES ASCII 0000000000000000 >00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F ................ 0000000000000010 10 11 12 13 14 15 16 17 18 19 1A 1B 1C 1D 1E 1F ................ @@ -30,5 +30,5 @@ u16 256 i16 256 u32 50462976 i32 50462976 u64 506097522914230528 i64 506097522914230528 f32 3.8204714e-37 f64 7.949928895127363e-275 offset 0x0 / 0 bookmarks 0 -esc close · g goto · / find · l LE/BE · b bookmark · f3 next match · c copy byte +esc close · g goto · / find · l LE/BE · b bookmark · f3 next match · c copy byte · f2 edit · ctrl+r 100 · — ctrl+p commands diff --git a/crates/cyber-tui/src/snapshots/cyber_tui__screens_explorer__hex_wide.snap b/crates/cyber-tui/src/snapshots/cyber_tui__screens_explorer__hex_wide.snap index a02ddfb6..7cfa9d44 100644 --- a/crates/cyber-tui/src/snapshots/cyber_tui__screens_explorer__hex_wide.snap +++ b/crates/cyber-tui/src/snapshots/cyber_tui__screens_explorer__hex_wide.snap @@ -3,7 +3,7 @@ source: crates/cyber-tui/src/screens_explorer.rs expression: "shown.replace(&revision, \"obs_revision\")" --- HEX fixture.bin 256 bytes -LIVE VIEW · observed obs_revision · read only +LIVE VIEW · revision obs_revision · view mode OFFSET HEX BYTES ASCII 0000000000000000 >00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F ................ 0000000000000010 10 11 12 13 14 15 16 17 18 19 1A 1B 1C 1D 1E 1F ................ @@ -36,5 +36,5 @@ u16 256 i16 256 u32 50462976 i32 50462976 u64 506097522914230528 i64 506097522914230528 f32 3.8204714e-37 f64 7.949928895127363e-275 offset 0x0 / 0 bookmarks 0 -esc close · g goto · / find · l LE/BE · b bookmark · f3 next match · c copy byte +esc close · g goto · / find · l LE/BE · b bookmark · f3 next match · c copy byte · f2 edit · ctrl+r reconcile 100 · — ctrl+p commands diff --git a/crates/cyber-tui/src/view/hex.rs b/crates/cyber-tui/src/view/hex.rs index c5b35c03..ead68455 100644 --- a/crates/cyber-tui/src/view/hex.rs +++ b/crates/cyber-tui/src/view/hex.rs @@ -6,7 +6,7 @@ use ratatui::{ layout::Rect, style::{Modifier, Style}, text::{Line, Span}, - widgets::{Block, Clear, Paragraph}, + widgets::{Block, Clear, Paragraph, Wrap}, }; pub fn draw(f: &mut Frame, app: &App, area: Rect) { @@ -45,8 +45,24 @@ pub fn draw(f: &mut Frame, app: &App, area: Rect) { .map(|d| d.revision.chars().take(12).collect::()) .unwrap_or_else(|| "pending".into()); f.render_widget( - Paragraph::new(format!("LIVE VIEW · observed {revision} · read only")) - .style(Style::default().fg(t.text_muted)), + Paragraph::new(format!( + "{} · revision {revision} · {}", + if h.edit.prepared { + "PRIVATE DRAFT" + } else { + "LIVE VIEW" + }, + if h.edit.uncertain { + "OUTCOME UNKNOWN" + } else if h.edit.read_only { + "read only" + } else if h.edit.enabled { + "EDIT · explicit save" + } else { + "view mode" + } + )) + .style(Style::default().fg(t.text_muted)), row(1, 1), ); let per_row = bytes_per_row(area.width); @@ -65,29 +81,21 @@ pub fn draw(f: &mut Frame, app: &App, area: Rect) { height: area.height.saturating_sub(10).max(1), ..area }; - f.render_widget(Paragraph::new(rows(h, t, per_row, body.height)), body); + if let Some((review, _)) = &h.edit.review { + f.render_widget( + Paragraph::new(review_lines(review, body.height, h.edit.review_scroll)) + .wrap(Wrap { trim: false }), + body, + ); + } else { + f.render_widget(Paragraph::new(rows(h, t, per_row, body.height)), body); + } let inspector = row(area.height - 6, 4); f.render_widget( Paragraph::new(inspect(h)).style(Style::default().fg(t.secondary)), inspector, ); - let status = if let Some((kind, input)) = &h.input { - format!( - "{} › {} [Enter apply · Esc cancel]", - kind.label(), - clean(input) - ) - } else if let Some(error) = &h.error { - clean(error) - } else { - format!( - "offset 0x{:X} / {} bookmarks {}{}", - h.cursor, - h.cursor, - h.bookmarks.len(), - if h.loading { " loading…" } else { "" } - ) - }; + let status = status(h); f.render_widget( Paragraph::new(status).style(Style::default().fg(if h.error.is_some() { t.error @@ -96,23 +104,7 @@ pub fn draw(f: &mut Frame, app: &App, area: Rect) { })), row(area.height - 2, 1), ); - let hints = [ - ("hex_close", "close"), - ("hex_goto", "goto"), - ("hex_search", "find"), - ("hex_endian", "LE/BE"), - ("hex_bookmark", "bookmark"), - ("hex_search_next", "next match"), - ("hex_copy", "copy byte"), - ] - .iter() - .filter_map(|(action, label)| { - app.keymap - .label("hex", action) - .map(|key| format!("{key} {label}")) - }) - .collect::>() - .join(" · "); + let hints = hints(app); f.render_widget( Paragraph::new(hints).style(Style::default().fg(t.text_subtle)), row(area.height - 1, 1), @@ -161,7 +153,9 @@ fn rows(h: &Workspace, t: &Tokens, count: usize, height: u16) -> Vec Vec Vec> { ] } +fn review_lines( + review: &cyber_core::binary::receipts::SaveReview, + height: u16, + skip: usize, +) -> Vec> { + let draft = &review.draft; + let mut lines = vec![ + Line::from(format!("BASE SHA256 {}", draft.base_sha256)), + Line::from(format!("NEW SHA256 {}", draft.proposed_sha256)), + Line::from(format!( + "MODE {:o} UID {} GID {}", + review.permissions, review.owner_uid, review.owner_gid + )), + Line::from(crate::sidebar::clean(&review.effects)), + ]; + for range in draft + .ranges + .iter() + .skip(skip) + .take(usize::from(height).saturating_sub(5) / 2) + { + lines.push(Line::from(format!( + "- @0x{:X} [{}] {}", + range.original_offset.0, range.original_length.0, range.before_hex + ))); + lines.push(Line::from(format!( + "+ @0x{:X} [{}] {}{}", + range.draft_offset.0, + range.draft_length.0, + if range.preview_truncated { + "(preview) " + } else { + "" + }, + range.after_hex + ))); + } + lines.push(Line::from( + "Ctrl+S explicitly applies this fingerprint; Esc returns to the draft", + )); + lines +} + +fn status(h: &Workspace) -> String { + if h.edit.closing { + if h.edit.uncertain || h.loading { + "Keep retained state? K keep and close · Esc stay".into() + } else { + "Unsaved draft: K keep and close · D discard · Esc stay".into() + } + } else if let Some(high) = h.edit.nibble { + format!("Overwrite nibble {high:X}_ · complete byte to stage · Esc cancel") + } else if let Some((review, restore)) = &h.edit.review { + format!( + "{}: {} ranges · {} → {} bytes · range {} · PgUp/Dn · Ctrl+S apply · Esc cancel", + if *restore { + "RESTORE REVIEW" + } else { + "SAVE REVIEW" + }, + review.draft.range_count, + review.draft.base_length.0, + review.draft.proposed_length.0, + h.edit.review_start + h.edit.review_scroll + 1 + ) + } else if let Some((kind, input)) = &h.input { + format!( + "{} › {} [Enter apply · Esc cancel]", + kind.label(), + clean(input) + ) + } else if let Some(error) = &h.error { + clean(error) + } else if let Some(notice) = &h.edit.notice { + clean(notice) + } else if let Some((start, end)) = h.selection() { + format!( + "Selected {} bytes @0x{start:X} · Ctrl+C copies hex (4096-byte limit)", + end - start + ) + } else { + format!( + "offset 0x{:X} / {} bookmarks {}{}", + h.cursor, + h.cursor, + h.bookmarks.len(), + if h.loading { " loading…" } else { "" } + ) + } +} + +fn hints(app: &App) -> String { + let actions: &[(&str, &str)] = if app.hex.edit.enabled { + &[ + ("hex_edit", "view"), + ("hex_save", "review/save"), + ("hex_undo", "undo"), + ("hex_redo", "redo"), + ("hex_insert", "insert"), + ("hex_replace", "replace"), + ("hex_delete", "delete"), + ("hex_restore", "restore"), + ("hex_close", "close"), + ] + } else { + &[ + ("hex_close", "close"), + ("hex_goto", "goto"), + ("hex_search", "find"), + ("hex_endian", "LE/BE"), + ("hex_bookmark", "bookmark"), + ("hex_search_next", "next match"), + ("hex_copy", "copy byte"), + ("hex_edit", "edit"), + ("hex_reconcile", "reconcile"), + ] + }; + actions + .iter() + .filter(|(action, _)| !app.hex.edit.read_only || *action != "hex_edit") + .filter_map(|(action, label)| { + app.keymap + .label("hex", action) + .map(|key| format!("{key} {label}")) + }) + .collect::>() + .join(" · ") +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/cyber-tui/src/view/overlays.rs b/crates/cyber-tui/src/view/overlays.rs index 9f949391..3e91caca 100644 --- a/crates/cyber-tui/src/view/overlays.rs +++ b/crates/cyber-tui/src/view/overlays.rs @@ -474,14 +474,47 @@ pub fn file_diff(f: &mut Frame, app: &App, index: usize, scroll: u16, area: Rect )) }) .collect(); + let lines = if let Some(versions) = &change.binary { + let mut summary = vec![Line::from( + "Saved binary versions · exact byte lengths and SHA-256 fingerprints", + )]; + for version in versions { + summary.push(Line::from(format!( + "{} · {} → {} bytes · {} ranges", + version.kind, version.before_length.0, version.after_length.0, version.range_count + ))); + summary.push(Line::from(format!("- SHA256 {}", version.before_sha256))); + summary.push(Line::from(format!("+ SHA256 {}", version.after_sha256))); + summary.push(Line::from(format!( + "{} / {}", + version.document_id, version.version_id + ))); + } + if !lines.is_empty() { + summary.push(Line::from("Snapshot text diff:")); + summary.extend(lines); + } + summary + } else { + lines + }; let rect = centered(area, 110, area.height); - let title = format!( - "{} +{} -{} · ↑↓ scroll · Esc close", - change.file, change.additions, change.deletions - ); + let title = if let Some(versions) = &change.binary { + format!( + "{} · binary · {} saved changes · ↑↓ scroll · Esc close", + change.file, + versions.len() + ) + } else { + format!( + "{} +{} -{} · ↑↓ scroll · Esc close", + change.file, change.additions, change.deletions + ) + }; f.render_widget(Clear, rect); f.render_widget( Paragraph::new(lines) + .wrap(Wrap { trim: false }) .scroll((scroll, 0)) .block(frame(app, &title)), rect, diff --git a/crates/cyber-tui/src/view/sidebar.rs b/crates/cyber-tui/src/view/sidebar.rs index a6e5e4e2..b77c51fe 100644 --- a/crates/cyber-tui/src/view/sidebar.rs +++ b/crates/cyber-tui/src/view/sidebar.rs @@ -871,16 +871,37 @@ fn files(app: &App, width: usize, out: &mut Out) { return; } for (i, change) in changes.iter().take(FILE_ROWS).enumerate() { - let added = format!("+{}", change.additions); - let removed = format!("-{}", change.deletions); + let added = change.binary.as_ref().map_or_else( + || format!("+{}", change.additions), + |versions| format!("{} saved", versions.len()), + ); + let removed = if change.binary.is_some() { + "binary".into() + } else { + format!("-{}", change.deletions) + }; let room = width.saturating_sub(added.len() + removed.len() + 2); let spans = vec![ Span::styled( format!("{:FF FF 80 1B" in tui.text() and "loading…" not in tui.text(), 30, tui=tui) + tui.key("\x13") # Ctrl+S reviews; a second press would apply. + wait("byte save review", lambda: "SAVE REVIEW" in tui.text() and "BASE SHA256" in tui.text(), 30, tui=tui) + if open(path, "rb").read() != payload: + raise AssertionError("review capture changed source bytes") + env.shot(tui, "binary-hex-edit.png") + else: + env.shot(tui, "binary-hex.png") tui.close() @@ -500,7 +516,7 @@ def main(): parser.add_argument("--binary", default=os.path.join(REPO, "target", "debug", "cyber")) parser.add_argument("--out", default=os.path.join(REPO, "docs", "screenshots")) parser.add_argument("--chrome", default=os.environ.get("CYBER_SHOTS_CHROME", CHROME)) - parser.add_argument("--only", choices=["hex"], help="capture just the binary hex workspace") + parser.add_argument("--only", choices=["hex", "hex-edit"], help="capture just the binary hex workspace") args = parser.parse_args() if not os.path.exists(args.chrome): sys.exit(f"Chrome not found at {args.chrome}; pass --chrome or set CYBER_SHOTS_CHROME. Skipping the capture.") @@ -509,11 +525,11 @@ def main(): os.makedirs(args.out, exist_ok=True) env = Env(os.path.abspath(args.binary), args.out, args.chrome) try: - if args.only == "hex": + if args.only in ("hex", "hex-edit"): env.machine_fixture() env.project_fixture() env.config(env.start_provider()) - capture_hex(env) + capture_hex(env, edit=args.only == "hex-edit") else: capture(env) finally: diff --git a/sdk/openapi.json b/sdk/openapi.json index cbc2307c..e762bbb0 100644 --- a/sdk/openapi.json +++ b/sdk/openapi.json @@ -8459,6 +8459,16 @@ }, "patch": { "type": "string" + }, + "binary": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/components/schemas/BinaryVersion" + }, + "description": "Acknowledged binary source versions, independent of Git snapshot limits.\nThese are saved references, not a synthesized text patch or net byte diff." } }, "required": [ @@ -8470,6 +8480,74 @@ ], "description": "One file of a diff between two snapshots." }, + "BinaryVersion": { + "type": "object", + "additionalProperties": false, + "properties": { + "version_id": { + "type": "string" + }, + "document_id": { + "type": "string" + }, + "operation_id": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "path": { + "type": "string" + }, + "before_sha256": { + "type": "string" + }, + "after_sha256": { + "type": "string" + }, + "before_length": { + "$ref": "#/components/schemas/Offset" + }, + "after_length": { + "$ref": "#/components/schemas/Offset" + }, + "original_bytes": { + "$ref": "#/components/schemas/Offset" + }, + "proposed_bytes": { + "$ref": "#/components/schemas/Offset" + }, + "range_count": { + "type": "integer", + "format": "uint", + "minimum": 0 + }, + "created_ms": { + "type": "integer", + "format": "int64" + } + }, + "required": [ + "version_id", + "document_id", + "operation_id", + "kind", + "path", + "before_sha256", + "after_sha256", + "before_length", + "after_length", + "original_bytes", + "proposed_bytes", + "range_count", + "created_ms" + ], + "description": "Shared saved-source metadata. It never contains unsaved command buffers." + }, + "Offset": { + "type": "string", + "description": "Lossless JSON decimal integer. Never accepts JSON numbers or noncanonical strings." + }, "HistoryPage": { "type": "object", "properties": { @@ -14876,10 +14954,6 @@ "within_edit_size_limit" ] }, - "Offset": { - "type": "string", - "description": "Lossless JSON decimal integer. Never accepts JSON numbers or noncanonical strings." - }, "Source2": { "type": "string", "enum": [ @@ -15575,6 +15649,91 @@ ], "description": "A Location-scoped response." }, + "Located_BinaryVersions": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/LocationInfo" + }, + "data": { + "$ref": "#/components/schemas/BinaryVersions" + } + }, + "required": [ + "location", + "data" + ], + "description": "A Location-scoped response." + }, + "BinaryVersions": { + "type": "object", + "additionalProperties": false, + "properties": { + "versions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BinaryVersion" + } + }, + "next_cursor": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "versions" + ] + }, + "Located_BinaryReview": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/LocationInfo" + }, + "data": { + "$ref": "#/components/schemas/BinaryReview" + } + }, + "required": [ + "location", + "data" + ], + "description": "A Location-scoped response." + }, + "BinarySaveRequest": { + "type": "object", + "additionalProperties": false, + "properties": { + "session_id": { + "type": "string" + }, + "operation_id": { + "type": "string" + }, + "review": { + "type": "string" + } + }, + "required": [ + "session_id", + "operation_id", + "review" + ] + }, + "BinaryReconcileRequest": { + "type": "object", + "additionalProperties": false, + "properties": { + "session_id": { + "type": "string" + } + }, + "required": [ + "session_id" + ] + }, "Data_boolean": { "type": "object", "properties": { @@ -30143,6 +30302,484 @@ } } }, + "/api/v1/files/binary/documents/{id}/versions": { + "get": { + "operationId": "v1.fs.binaryVersions", + "summary": "List acknowledged source history across Sessions without exposing private drafts", + "tags": [ + "fs" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "session_id", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "after", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "location[directory]", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "x-cyber-directory", + "in": "header", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Located_BinaryVersions" + } + } + } + }, + "default": { + "description": "Tagged error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + } + } + } + }, + "/api/v1/files/binary/documents/{id}/versions/{document}/{version}/review": { + "get": { + "operationId": "v1.fs.binaryVersionReview", + "summary": "Read an immutable saved-source byte diff under fresh source read authority", + "tags": [ + "fs" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "document", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "version", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "session_id", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "start", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "location[directory]", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "x-cyber-directory", + "in": "header", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Located_BinaryReview" + } + } + } + }, + "default": { + "description": "Tagged error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + } + } + } + }, + "/api/v1/files/binary/documents/{id}/save": { + "post": { + "operationId": "v1.fs.binarySave", + "summary": "Install a separately reviewed native binary draft under fresh authority", + "tags": [ + "fs" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "location[directory]", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "x-cyber-directory", + "in": "header", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "Idempotency-Key", + "in": "header", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Located_BinaryEdited" + } + } + } + }, + "default": { + "description": "Tagged error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BinarySaveRequest" + } + } + } + } + } + }, + "/api/v1/files/binary/documents/{id}/restore-review": { + "post": { + "operationId": "v1.fs.binaryRestoreReview", + "summary": "Review the protected preimage for Restore Last Save", + "tags": [ + "fs" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "location[directory]", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "x-cyber-directory", + "in": "header", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "Idempotency-Key", + "in": "header", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Located_BinarySaveReview" + } + } + } + }, + "default": { + "description": "Tagged error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BinaryReviewRequest" + } + } + } + } + } + }, + "/api/v1/files/binary/documents/{id}/restore": { + "post": { + "operationId": "v1.fs.binaryRestore", + "summary": "Restore a reviewed preimage without overwriting unrelated changes", + "tags": [ + "fs" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "location[directory]", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "x-cyber-directory", + "in": "header", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "Idempotency-Key", + "in": "header", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Located_BinaryEdited" + } + } + } + }, + "default": { + "description": "Tagged error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BinarySaveRequest" + } + } + } + } + } + }, + "/api/v1/files/binary/documents/{id}/reconcile": { + "post": { + "operationId": "v1.fs.binaryReconcile", + "summary": "Recheck retained native evidence without executing another exchange", + "tags": [ + "fs" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "location[directory]", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "x-cyber-directory", + "in": "header", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "Idempotency-Key", + "in": "header", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Located_BinaryEdited" + } + } + } + }, + "default": { + "description": "Tagged error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BinaryReconcileRequest" + } + } + } + } + } + }, "/api/v1/tools/computer/health": { "get": { "operationId": "v1.tool.computerHealth", diff --git a/sdk/typescript/src/binary.ts b/sdk/typescript/src/binary.ts index 9db5d047..2a198f37 100644 --- a/sdk/typescript/src/binary.ts +++ b/sdk/typescript/src/binary.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto"; import type { CyberClient } from "./client.js"; -import type { OpenedDocument, SearchResult, BinaryStatus, BinaryEdited, BinarySplice, BinarySaveReview, BinaryOperation, BinaryHistoryAction } from "./generated/types.js"; +import type { OpenedDocument, SearchResult, BinaryStatus, BinaryEdited, BinarySplice, BinarySaveReview, BinaryOperation, BinaryHistoryAction, BinaryVersions, BinaryReview } from "./generated/types.js"; import type { RequestOptions } from "./generated/operations.js"; const MAX_OFFSET = (1n << 64n) - 1n; @@ -23,10 +23,16 @@ export interface BinaryDocument { bytes(offset: bigint | string, limit?: number, options?: RequestOptions): Promise; search(hex: string, offset?: bigint | string, options?: RequestOptions): Promise; status(options?: RequestOptions): Promise; + versions(after?: string, limit?: number, options?: RequestOptions): Promise; + versionReview(documentId: string, versionId: string, start?: number, limit?: number, options?: RequestOptions): Promise; prepare(options?: RequestOptions): Promise; patch(operationId: string, batch: { revision: string; splices: BinarySplice[] }, options?: RequestOptions): Promise; history(operationId: string, command: { revision: string; action: BinaryHistoryAction }, options?: RequestOptions): Promise; review(start?: number, limit?: number, options?: RequestOptions): Promise; + save(operationId: string, review: string, options?: RequestOptions): Promise; + restoreReview(options?: RequestOptions): Promise; + restore(operationId: string, review: string, options?: RequestOptions): Promise; + reconcile(options?: RequestOptions): Promise; operation(operationId: string, options?: RequestOptions): Promise; close(options?: RequestOptions): Promise; } @@ -113,6 +119,26 @@ function binaryHandle(client: CyberClient, sessionId: string, id: string, fields byteOffset(result.scanned); return result; }, + async versions(after, limit = 64, options) { + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 64) throw new RangeError("Invalid binary history page"); + const expected = generation; + const response = await client.request("v1.fs.binaryVersions", { path: scope, query: { ...query, after, limit } }, options); + if (generation !== expected) throw new Error("Binary history response superseded"); + for (const version of response.data.versions) { + if (version.path !== metadata.path) throw new Error("Binary history source changed"); + byteOffset(version.before_length); byteOffset(version.after_length); + byteOffset(version.original_bytes); byteOffset(version.proposed_bytes); + } + return response.data; + }, + async versionReview(documentId, versionId, start = 0, limit = 128, options) { + if (!/^bin_[a-zA-Z0-9_]{1,96}$/.test(documentId) || !/^ver_[a-f0-9]{64}$/.test(versionId)) throw new TypeError("Invalid saved binary identity"); + if (!Number.isSafeInteger(start) || start < 0 || !Number.isSafeInteger(limit) || limit < 1 || limit > 128) throw new RangeError("Invalid binary review page"); + const expected = generation; + const response = await client.request("v1.fs.binaryVersionReview", { path: { ...scope, document: documentId, version: versionId }, query: { ...query, start, limit } }, options); + if (generation !== expected) throw new Error("Saved binary review superseded"); + return response.data; + }, async status(options) { const expected = ++generation; const response = await client.request("v1.fs.binaryStatus", { path: scope, query }, options); @@ -152,6 +178,33 @@ function binaryHandle(client: CyberClient, sessionId: string, id: string, fields const response = await client.request("v1.fs.binaryOperation", { path: { ...scope, operation: operationId }, query }, options); return response.data ?? null; }, + async save(operationId, review, options) { + operationID(operationId); + const expected = ++generation; + const response = await client.request("v1.fs.binarySave", { path: scope, body: { session_id: sessionId, operation_id: operationId, review } }, options); + update(response.data.status, expected); + return response.data; + }, + async restoreReview(options) { + const expected = generation; + const revision = metadata.revision; + const response = await client.request("v1.fs.binaryRestoreReview", { path: scope, body: { session_id: sessionId, revision } }, options); + if (generation !== expected || response.data.draft.revision !== revision) throw new Error("Stale binary restore review"); + return response.data; + }, + async restore(operationId, review, options) { + operationID(operationId); + const expected = ++generation; + const response = await client.request("v1.fs.binaryRestore", { path: scope, body: { session_id: sessionId, operation_id: operationId, review } }, options); + update(response.data.status, expected); + return response.data; + }, + async reconcile(options) { + const expected = ++generation; + const response = await client.request("v1.fs.binaryReconcile", { path: scope, body: { session_id: sessionId } }, options); + update(response.data.status, expected); + return response.data; + }, async close(options) { await client.request("v1.fs.binaryClose", { path: scope, query }, options); }, diff --git a/sdk/typescript/src/generated/operations.ts b/sdk/typescript/src/generated/operations.ts index 49a0a353..8de6bf66 100644 --- a/sdk/typescript/src/generated/operations.ts +++ b/sdk/typescript/src/generated/operations.ts @@ -282,6 +282,12 @@ export interface Operations { body: S.BinaryPrepareRequest; response: S.Located_BinaryStatus; }; + "v1.fs.binaryReconcile": { + path: { id: string }; + query: Record; + body: S.BinaryReconcileRequest; + response: S.Located_BinaryEdited; + }; "v1.fs.binaryRecover": { path: { id: string }; query: Record; @@ -298,12 +304,30 @@ export interface Operations { body: undefined; response: S.Located_BinaryRecoveryList; }; + "v1.fs.binaryRestore": { + path: { id: string }; + query: Record; + body: S.BinarySaveRequest; + response: S.Located_BinaryEdited; + }; + "v1.fs.binaryRestoreReview": { + path: { id: string }; + query: Record; + body: S.BinaryReviewRequest; + response: S.Located_BinarySaveReview; + }; "v1.fs.binaryReview": { path: { id: string }; query: Record; body: S.BinaryReviewRequest; response: S.Located_BinarySaveReview; }; + "v1.fs.binarySave": { + path: { id: string }; + query: Record; + body: S.BinarySaveRequest; + response: S.Located_BinaryEdited; + }; "v1.fs.binarySearch": { path: { id: string }; query: Record; @@ -318,6 +342,26 @@ export interface Operations { body: undefined; response: S.Located_BinaryStatus; }; + "v1.fs.binaryVersionReview": { + path: { id: string; document: string; version: string }; + query: { + session_id?: QueryValue; + start?: QueryValue; + limit?: QueryValue; + }; + body: undefined; + response: S.Located_BinaryReview; + }; + "v1.fs.binaryVersions": { + path: { id: string }; + query: { + session_id?: QueryValue; + after?: QueryValue; + limit?: QueryValue; + }; + body: undefined; + response: S.Located_BinaryVersions; + }; "v1.fs.content": { path: Record; query: { @@ -2217,6 +2261,18 @@ export const operations = { unwrap: false, stream: false, }, + "v1.fs.binaryReconcile": { + tag: "fs", + name: "binaryReconcile", + method: "POST", + path: "/api/v1/files/binary/documents/{id}/reconcile", + pathParams: ["id"], + query: [], + body: "required", + located: true, + unwrap: false, + stream: false, + }, "v1.fs.binaryRecover": { tag: "fs", name: "binaryRecover", @@ -2241,6 +2297,30 @@ export const operations = { unwrap: false, stream: false, }, + "v1.fs.binaryRestore": { + tag: "fs", + name: "binaryRestore", + method: "POST", + path: "/api/v1/files/binary/documents/{id}/restore", + pathParams: ["id"], + query: [], + body: "required", + located: true, + unwrap: false, + stream: false, + }, + "v1.fs.binaryRestoreReview": { + tag: "fs", + name: "binaryRestoreReview", + method: "POST", + path: "/api/v1/files/binary/documents/{id}/restore-review", + pathParams: ["id"], + query: [], + body: "required", + located: true, + unwrap: false, + stream: false, + }, "v1.fs.binaryReview": { tag: "fs", name: "binaryReview", @@ -2253,6 +2333,18 @@ export const operations = { unwrap: false, stream: false, }, + "v1.fs.binarySave": { + tag: "fs", + name: "binarySave", + method: "POST", + path: "/api/v1/files/binary/documents/{id}/save", + pathParams: ["id"], + query: [], + body: "required", + located: true, + unwrap: false, + stream: false, + }, "v1.fs.binarySearch": { tag: "fs", name: "binarySearch", @@ -2277,6 +2369,30 @@ export const operations = { unwrap: false, stream: false, }, + "v1.fs.binaryVersionReview": { + tag: "fs", + name: "binaryVersionReview", + method: "GET", + path: "/api/v1/files/binary/documents/{id}/versions/{document}/{version}/review", + pathParams: ["id", "document", "version"], + query: ["session_id", "start", "limit"], + body: "none", + located: true, + unwrap: false, + stream: false, + }, + "v1.fs.binaryVersions": { + tag: "fs", + name: "binaryVersions", + method: "GET", + path: "/api/v1/files/binary/documents/{id}/versions", + pathParams: ["id"], + query: ["session_id", "after", "limit"], + body: "none", + located: true, + unwrap: false, + stream: false, + }, "v1.fs.content": { tag: "fs", name: "content", @@ -5149,21 +5265,39 @@ export function createGroups(caller: Caller) { /** Prepare a durable private draft after manual edit admission; no source mutation (`POST /api/v1/files/binary/documents/{id}/prepare`) */ binaryPrepare: (id: string, body: Operations["v1.fs.binaryPrepare"]["body"], options?: RequestOptions): Promise => caller.call("v1.fs.binaryPrepare", { path: { id }, body }, options), + /** Recheck retained native evidence without executing another exchange (`POST /api/v1/files/binary/documents/{id}/reconcile`) */ + binaryReconcile: (id: string, body: Operations["v1.fs.binaryReconcile"]["body"], options?: RequestOptions): Promise => + caller.call("v1.fs.binaryReconcile", { path: { id }, body }, options), /** Explicitly reopen a retained private draft; never replay a save or grant mutation authority (`POST /api/v1/files/binary/documents/{id}/recover`) */ binaryRecover: (id: string, body: Operations["v1.fs.binaryRecover"]["body"], options?: RequestOptions): Promise => caller.call("v1.fs.binaryRecover", { path: { id }, body }, options), /** Discover retained manual drafts in the exact Session Location (`GET /api/v1/files/binary/recovery`) */ binaryRecovery: (query?: Operations["v1.fs.binaryRecovery"]["query"], options?: RequestOptions): Promise => caller.call("v1.fs.binaryRecovery", { query }, options), + /** Restore a reviewed preimage without overwriting unrelated changes (`POST /api/v1/files/binary/documents/{id}/restore`) */ + binaryRestore: (id: string, body: Operations["v1.fs.binaryRestore"]["body"], options?: RequestOptions): Promise => + caller.call("v1.fs.binaryRestore", { path: { id }, body }, options), + /** Review the protected preimage for Restore Last Save (`POST /api/v1/files/binary/documents/{id}/restore-review`) */ + binaryRestoreReview: (id: string, body: Operations["v1.fs.binaryRestoreReview"]["body"], options?: RequestOptions): Promise => + caller.call("v1.fs.binaryRestoreReview", { path: { id }, body }, options), /** Review original-offset byte ranges and full fingerprints; no source mutation (`POST /api/v1/files/binary/documents/{id}/review`) */ binaryReview: (id: string, body: Operations["v1.fs.binaryReview"]["body"], options?: RequestOptions): Promise => caller.call("v1.fs.binaryReview", { path: { id }, body }, options), + /** Install a separately reviewed native binary draft under fresh authority (`POST /api/v1/files/binary/documents/{id}/save`) */ + binarySave: (id: string, body: Operations["v1.fs.binarySave"]["body"], options?: RequestOptions): Promise => + caller.call("v1.fs.binarySave", { path: { id }, body }, options), /** Search live or draft bytes with bounded scanning and revision-bound continuation (`POST /api/v1/files/binary/documents/{id}/search`) */ binarySearch: (id: string, body: Operations["v1.fs.binarySearch"]["body"], options?: RequestOptions): Promise => caller.call("v1.fs.binarySearch", { path: { id }, body }, options), /** Read current binary draft status under fresh owner authority (`GET /api/v1/files/binary/documents/{id}`) */ binaryStatus: (id: string, query?: Operations["v1.fs.binaryStatus"]["query"], options?: RequestOptions): Promise => caller.call("v1.fs.binaryStatus", { path: { id }, query }, options), + /** Read an immutable saved-source byte diff under fresh source read authority (`GET /api/v1/files/binary/documents/{id}/versions/{document}/{version}/review`) */ + binaryVersionReview: (id: string, document: string, version: string, query?: Operations["v1.fs.binaryVersionReview"]["query"], options?: RequestOptions): Promise => + caller.call("v1.fs.binaryVersionReview", { path: { id, document, version }, query }, options), + /** List acknowledged source history across Sessions without exposing private drafts (`GET /api/v1/files/binary/documents/{id}/versions`) */ + binaryVersions: (id: string, query?: Operations["v1.fs.binaryVersions"]["query"], options?: RequestOptions): Promise => + caller.call("v1.fs.binaryVersions", { path: { id }, query }, options), /** A project file's text (at most 1 MiB) when the Session's rules allow read (`GET /api/v1/files/content`) */ content: (query?: Operations["v1.fs.content"]["query"], options?: RequestOptions): Promise => caller.call("v1.fs.content", { query }, options), diff --git a/sdk/typescript/src/generated/types.ts b/sdk/typescript/src/generated/types.ts index 95a857b7..8644b280 100644 --- a/sdk/typescript/src/generated/types.ts +++ b/sdk/typescript/src/generated/types.ts @@ -190,6 +190,10 @@ export type BinaryPrepareRequest = { revision: string; }; +export type BinaryReconcileRequest = { + session_id: string; +}; + export type BinaryRecoverRequest = { session_id: string; read_only?: boolean; @@ -227,6 +231,12 @@ export type BinaryReviewRequest = { limit?: number | null; }; +export type BinarySaveRequest = { + session_id: string; + operation_id: string; + review: string; +}; + export type BinarySaveReview = { draft: BinaryReview; permissions: number; @@ -262,6 +272,28 @@ export type BinaryStatus = { edit_decision: FileDecision; }; +/** Shared saved-source metadata. It never contains unsaved command buffers. */ +export type BinaryVersion = { + version_id: string; + document_id: string; + operation_id: string; + kind: string; + path: string; + before_sha256: string; + after_sha256: string; + before_length: Offset; + after_length: Offset; + original_bytes: Offset; + proposed_bytes: Offset; + range_count: number; + created_ms: number; +}; + +export type BinaryVersions = { + versions: BinaryVersion[]; + next_cursor?: string | null; +}; + /** What to check out: a message ID or a label, and whether to restore code. */ export type BranchCheckout = { message_id?: string | null; @@ -1279,6 +1311,11 @@ export type FileDiff = { additions: number; deletions: number; patch: string; + /** + * Acknowledged binary source versions, independent of Git snapshot limits. + * These are saved references, not a synthesized text patch or net byte diff. + */ + binary?: BinaryVersion[] | null; }; /** One directory entry. */ @@ -2549,6 +2586,12 @@ export type Located_BinaryRecoveryList = { data: BinaryRecoveryList; }; +/** A Location-scoped response. */ +export type Located_BinaryReview = { + location: LocationInfo; + data: BinaryReview; +}; + /** A Location-scoped response. */ export type Located_BinarySaveReview = { location: LocationInfo; @@ -2561,6 +2604,12 @@ export type Located_BinaryStatus = { data: BinaryStatus; }; +/** A Location-scoped response. */ +export type Located_BinaryVersions = { + location: LocationInfo; + data: BinaryVersions; +}; + /** A Location-scoped response. */ export type Located_BytePage = { location: LocationInfo; diff --git a/sdk/typescript/test/binary.e2e.test.ts b/sdk/typescript/test/binary.e2e.test.ts index f32c5a7b..2e06a215 100644 --- a/sdk/typescript/test/binary.e2e.test.ts +++ b/sdk/typescript/test/binary.e2e.test.ts @@ -16,7 +16,7 @@ test("binary helper reads every byte, preserves scope and releases real owner do const model = await startScriptedProvider(() => { inference++; return undefined; }); const config = join(root, "cyber-home", "config"); mkdirSync(config, { recursive: true }); - writeFileSync(join(config, "cyber.jsonc"), JSON.stringify(model.config())); + writeFileSync(join(config, "cyber.jsonc"), JSON.stringify({ ...model.config(), permissions: {read:"allow",edit:"allow"} })); writeFileSync(join(root, "models.json"), "{}"); const repo = join(root, "repo"); mkdirSync(join(repo, ".git"), { recursive: true }); @@ -48,6 +48,39 @@ test("binary helper reads every byte, preserves scope and releases real owner do }), (error: unknown) => error instanceof CyberApiError && error.status === 404); assert.deepEqual(readFileSync(source), raw); + if (process.platform === "darwin" || process.platform === "linux") { + const editSession = (await client.session.create({ title: "binary editor", mode: "accept-edits" })).data.id; + const editedPath = join(repo, "edited.bin"); + const original = Buffer.from([0, 255, 27, 128]); + writeFileSync(editedPath, original); + const editor = await client.binary.open(editSession, "edited.bin"); + const prepared = await editor.prepare(); + await editor.patch("sdk_patch", { revision: prepared.revision, splices: [ + { offset: "1", delete_length: "1", expected_hex: "ff", insert_hex: "0041" }, + ] }); + assert.deepEqual(readFileSync(editedPath), original); + const review = await editor.review(); + const saved = await editor.save("sdk_save", review.fingerprint); + assert.equal(saved.operation.outcome, "acknowledged"); + assert.deepEqual(readFileSync(editedPath), Buffer.from([0, 0, 65, 27, 128])); + const replayed = await editor.save("sdk_save", review.fingerprint); + assert.equal(replayed.operation.id, saved.operation.id); + const readerSession = (await client.session.create({title:"shared binary history"})).data.id; + const reader = await client.binary.open(readerSession,"edited.bin"); + const history = await reader.versions(); + assert.equal(history.versions.length,1); + const version = history.versions[0]!; + assert.equal(version.operation_id,"sdk_save"); + const savedReview = await reader.versionReview(version.document_id,version.version_id); + assert.equal(savedReview.ranges[0]!.before_hex,"ff"); + assert.equal(savedReview.ranges[0]!.after_hex,"0041"); + await reader.close(); + const restore = await editor.restoreReview(); + await editor.restore("sdk_restore", restore.fingerprint); + assert.deepEqual(readFileSync(editedPath), original); + await editor.close(); + } + // A replacement cannot supply bytes through an old descriptor/revision. renameSync(source, join(repo, "old.bin")); writeFileSync(source, Buffer.from([255])); diff --git a/sdk/typescript/test/binary.test.ts b/sdk/typescript/test/binary.test.ts index c1f1192f..4f5ed061 100644 --- a/sdk/typescript/test/binary.test.ts +++ b/sdk/typescript/test/binary.test.ts @@ -115,3 +115,21 @@ test("binary mutation transport failure requires explicit recovery instead of au await assert.rejects(() => client.binary.open("ses_one", "fixture.bin")); assert.equal(calls.length, 1, "lost open response allocated another document automatically"); }); + +test("binary save and restore preserve explicit review fingerprints and update metadata", async () => { + const status = { ...opened, length: "4", revision: "saved", source: "draft", read_only: false, prepared: true, + dirty: false, can_undo: false, can_redo: false, base_sha256: "a".repeat(64), pending_operation: null, edit_supported: true, edit_decision: "allow" }; + const operation = { id: "save_once", kind: "save", payload_sha256: "b".repeat(64), outcome: "acknowledged", revision: "saved", file_effect: "changed", reason: null, notification_pending: true }; + const { client, calls } = mockClient(req => json(200, { data: req.url.pathname.endsWith("/documents") ? opened : { operation, status } })); + const doc = await client.binary.open("ses_one", "fixture.bin"); + await doc.save("save_once", "review_original"); + await doc.save("save_once", "review_original"); + assert.equal(doc.metadata.revision, "saved"); + const saves = calls.filter(call => call.url.pathname.endsWith("/save")); + assert.equal(saves[0]!.body, saves[1]!.body); + assert.equal(JSON.parse(saves[0]!.body!).review, "review_original"); + await doc.restore("restore_once", "review_restore"); + assert.equal(JSON.parse(calls.at(-1)!.body!).review, "review_restore"); + await doc.reconcile(); + assert.equal(calls.at(-1)!.url.pathname.endsWith("/reconcile"), true); +});