From 94e7e2c34f57edf22763c7ebdedff561da08c8d6 Mon Sep 17 00:00:00 2001 From: Edvane Cristina Santos Daltro Date: Sun, 1 Nov 2026 18:37:26 -0400 Subject: [PATCH 1/5] Add durable binary drafts and recoverable native save core --- .github/workflows/binary-editor-proof.yml | 49 + Cargo.lock | 1 + crates/cyber-core/Cargo.toml | 1 + crates/cyber-core/src/binary/draft.rs | 901 ++++++++++++++ crates/cyber-core/src/binary/editor.rs | 1083 +++++++++++++++++ .../src/binary/editor/death_tests.rs | 205 ++++ .../cyber-core/src/binary/editor/restore.rs | 272 +++++ crates/cyber-core/src/binary/mod.rs | 6 + crates/cyber-core/src/binary/native.rs | 519 ++++++++ crates/cyber-core/src/binary/native/macos.rs | 65 + crates/cyber-core/src/binary/source.rs | 57 + crates/cyber-core/src/binary/storage.rs | 829 +++++++++++++ .../add-binary-hex-editor/implementation.md | 68 ++ 13 files changed, 4056 insertions(+) create mode 100644 .github/workflows/binary-editor-proof.yml create mode 100644 crates/cyber-core/src/binary/draft.rs create mode 100644 crates/cyber-core/src/binary/editor.rs create mode 100644 crates/cyber-core/src/binary/editor/death_tests.rs create mode 100644 crates/cyber-core/src/binary/editor/restore.rs create mode 100644 crates/cyber-core/src/binary/native.rs create mode 100644 crates/cyber-core/src/binary/native/macos.rs create mode 100644 crates/cyber-core/src/binary/storage.rs diff --git a/.github/workflows/binary-editor-proof.yml b/.github/workflows/binary-editor-proof.yml new file mode 100644 index 00000000..a7a2cd0e --- /dev/null +++ b/.github/workflows/binary-editor-proof.yml @@ -0,0 +1,49 @@ +name: Binary editor native proof +on: + pull_request: + paths: + - crates/cyber-core/src/binary/** + - crates/cyber-core/Cargo.toml + - Cargo.lock + - .github/workflows/binary-editor-proof.yml + workflow_dispatch: +permissions: + contents: read +concurrency: + group: binary-editor-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true +jobs: + native: + name: Binary storage (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, macos-15, windows-2025] + env: + CARGO_INCREMENTAL: '0' + CARGO_PROFILE_DEV_DEBUG: line-tables-only + CARGO_PROFILE_TEST_DEBUG: line-tables-only + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + with: + key: binary-editor-proof + - name: Check native core + run: cargo clippy --locked -p cyber-core --lib --tests -- -D warnings + - name: Run native storage, draft and recovery proof + shell: bash + run: | + set -euo pipefail + cargo test --locked -p cyber-core --lib binary:: -- --nocapture 2>&1 | tee binary-editor-proof.log + - name: Retain native evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: binary-editor-proof-${{ matrix.os }} + path: binary-editor-proof.log + if-no-files-found: warn diff --git a/Cargo.lock b/Cargo.lock index c1a54124..03066932 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1143,6 +1143,7 @@ dependencies = [ "jsonschema", "libc", "regex", + "rustix", "same-file", "schemars", "semver", diff --git a/crates/cyber-core/Cargo.toml b/crates/cyber-core/Cargo.toml index 9e07dc3c..8fdde2cd 100644 --- a/crates/cyber-core/Cargo.toml +++ b/crates/cyber-core/Cargo.toml @@ -36,6 +36,7 @@ ulid.workspace = true [target.'cfg(unix)'.dependencies] libc.workspace = true +rustix = { version = "=1.1.5", default-features = false, features = ["fs", "std"] } [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.61.2", features = ["Wdk_Foundation", "Wdk_Storage_FileSystem", "Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_Storage_FileSystem", "Win32_System_Com", "Win32_System_IO", "Win32_System_Threading", "Win32_UI_Shell"] } diff --git a/crates/cyber-core/src/binary/draft.rs b/crates/cyber-core/src/binary/draft.rs new file mode 100644 index 00000000..df839d6b --- /dev/null +++ b/crates/cyber-core/src/binary/draft.rs @@ -0,0 +1,901 @@ +//! Bounded piece-table drafts. The source must be a prepared immutable base, never +//! a live path. This module changes no files and grants no read/write authority. +use super::{Error, MAX_EDIT_SIZE, MAX_PAGE, Offset, Page, decode_hex}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::sync::Arc; + +pub const MAX_SPLICES: usize = 128; +pub const MAX_PATCH_BYTES: usize = 64 * 1024; +pub const MAX_HISTORY: usize = 1000; + +/// Implemented by the retained private base. Reads must return the exact range. +/// The caller establishes privacy, identity, capacity and durable ownership first. +pub trait ImmutableBase { + fn length(&self) -> u64; + fn sha256(&self) -> &str; + fn read(&self, offset: u64, length: usize) -> Result, Error>; +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct Splice { + pub offset: Offset, + pub delete_length: Offset, + pub expected_hex: String, + pub insert_hex: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct PatchBatch { + pub revision: String, + pub splices: Vec, +} + +#[derive(Clone)] +struct Edit { + at: u64, + expected: Arc<[u8]>, + inserted: Arc<[u8]>, +} +#[derive(Clone)] +struct Command { + forward: Vec, + inverse: Vec, +} +#[derive(Clone)] +enum Bytes { + Base, + Added(Arc<[u8]>), +} +#[derive(Clone)] +struct Piece { + bytes: Bytes, + offset: u64, + length: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct ChangeRange { + pub original_offset: Offset, + pub original_length: Offset, + pub draft_offset: Offset, + pub draft_length: Offset, + pub before_sha256: String, + pub after_sha256: String, + pub before_hex: String, + pub after_hex: String, + pub preview_truncated: bool, +} +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct Review { + pub revision: String, + pub base_sha256: String, + pub proposed_sha256: String, + pub base_length: Offset, + pub proposed_length: Offset, + pub original_bytes: Offset, + pub proposed_bytes: Offset, + pub range_count: usize, + pub ranges: Vec, + pub next_range: Option, + pub fingerprint: String, +} + +/// Durable encoding contains commands, not raw native paths or executable inputs. +/// Its enclosing store must authenticate/bind the base, owner, identity and journal. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DraftState { + version: u32, + nonce: String, + epoch: u64, + base_sha256: String, + base_length: Offset, + commands: Vec>, + cursor: usize, +} + +#[derive(Clone)] +pub struct Draft { + base: B, + nonce: String, + epoch: u64, + pieces: Vec, + history: Vec, + cursor: usize, + length: u64, +} + +impl Draft { + pub fn new(base: B) -> Result { + if base.length() > MAX_EDIT_SIZE { + return Err(Error::Limit); + } + if base.sha256().len() != 64 + || !base + .sha256() + .bytes() + .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()) + { + return Err(Error::Invalid); + } + let length = base.length(); + let pieces = if length == 0 { + vec![] + } else { + vec![Piece { + bytes: Bytes::Base, + offset: 0, + length, + }] + }; + Ok(Self { + base, + nonce: crate::ids::new_id("draft"), + epoch: 0, + pieces, + history: vec![], + cursor: 0, + length, + }) + } + + pub fn revision(&self) -> String { + let mut hash = Sha256::new(); + hash.update(b"cyber-binary-draft-v1\0"); + hash.update(self.base.sha256()); + hash.update(self.nonce.as_bytes()); + hash.update(self.epoch.to_le_bytes()); + format!("{:x}", hash.finalize()) + } + pub fn length(&self) -> Offset { + Offset(self.length) + } + pub fn base(&self) -> &B { + &self.base + } + pub fn can_undo(&self) -> bool { + self.cursor != 0 + } + pub fn can_redo(&self) -> bool { + self.cursor < self.history.len() + } + + fn current(&self, revision: &str) -> Result<(), Error> { + if revision != self.revision() { + return Err(Error::Changed); + } + Ok(()) + } + + fn mutation(&self, revision: &str) -> Result<(), Error> { + self.current(revision)?; + if self.epoch == u64::MAX { + return Err(Error::Limit); + } + Ok(()) + } + + pub fn page(&self, revision: &str, at: Offset, limit: usize) -> Result { + self.current(revision)?; + if limit == 0 || limit > MAX_PAGE || at.0 > self.length { + return Err(Error::Limit); + } + let length = limit.min((self.length - at.0) as usize); + let bytes = self.read(at.0, length)?; + Ok(Page { + offset: at, + eof: at.0 + length as u64 == self.length, + sha256: format!("{:x}", Sha256::digest(&bytes)), + bytes, + }) + } + + fn piece_bytes(&self, piece: &Piece, offset: u64, length: usize) -> Result, Error> { + match &piece.bytes { + Bytes::Base => { + let bytes = self.base.read(piece.offset + offset, length)?; + if bytes.len() != length { + return Err(Error::Io); + } + Ok(bytes) + } + Bytes::Added(bytes) => bytes + .get((piece.offset + offset) as usize..(piece.offset + offset) as usize + length) + .map(<[u8]>::to_vec) + .ok_or(Error::Invalid), + } + } + + /// A bounded read; base-sized allocation is never required by editing. + pub fn read(&self, at: u64, length: usize) -> Result, Error> { + if length > MAX_PAGE + || at + .checked_add(length as u64) + .is_none_or(|end| end > self.length) + { + return Err(Error::Limit); + } + let mut output = Vec::with_capacity(length); + let mut position = 0; + for piece in &self.pieces { + let end = position + piece.length; + if end > at && output.len() < length { + let skip = at.saturating_sub(position); + let take = (length - output.len()).min((piece.length - skip) as usize); + output.extend_from_slice(&self.piece_bytes(piece, skip, take)?); + } + position = end; + if output.len() == length { + break; + } + } + if output.len() != length { + return Err(Error::Io); + } + Ok(output) + } + + pub fn patch(&mut self, batch: &PatchBatch) -> Result { + self.mutation(&batch.revision)?; + if self.cursor >= MAX_HISTORY { + return Err(Error::Limit); + } + let edits = decode(&batch.splices, self.length)?; + for edit in &edits { + if self.read(edit.at, edit.expected.len())?.as_slice() != edit.expected.as_ref() { + return Err(Error::Changed); + } + } + let (pieces, length) = self.splice(&edits)?; + let inverse = inverse(&edits)?; + // All validation and allocation precedes this one in-memory commit. + self.pieces = pieces; + self.length = length; + self.history.truncate(self.cursor); + self.history.push(Command { + forward: edits, + inverse, + }); + self.cursor += 1; + self.epoch += 1; + Ok(self.revision()) + } + + pub fn undo(&mut self, revision: &str) -> Result { + self.mutation(revision)?; + let command = self + .history + .get(self.cursor.checked_sub(1).ok_or(Error::Invalid)?) + .ok_or(Error::Invalid)?; + let (pieces, length) = self.splice(&command.inverse)?; + self.pieces = pieces; + self.length = length; + self.cursor -= 1; + self.epoch += 1; + Ok(self.revision()) + } + pub fn redo(&mut self, revision: &str) -> Result { + self.mutation(revision)?; + let command = self.history.get(self.cursor).ok_or(Error::Invalid)?; + let (pieces, length) = self.splice(&command.forward)?; + self.pieces = pieces; + self.length = length; + self.cursor += 1; + self.epoch += 1; + Ok(self.revision()) + } + + fn slice(&self, from: u64, to: u64, output: &mut Vec) { + let mut position = 0; + for piece in &self.pieces { + let end = position + piece.length; + let start = from.max(position); + let finish = to.min(end); + if finish > start { + output.push(Piece { + bytes: piece.bytes.clone(), + offset: piece.offset + start - position, + length: finish - start, + }); + } + position = end; + if position >= to { + break; + } + } + } + + fn splice(&self, edits: &[Edit]) -> Result<(Vec, u64), Error> { + let mut pieces = Vec::new(); + let mut last = 0; + for edit in edits { + self.slice(last, edit.at, &mut pieces); + if !edit.inserted.is_empty() { + pieces.push(Piece { + bytes: Bytes::Added(edit.inserted.clone()), + offset: 0, + length: edit.inserted.len() as u64, + }); + } + last = edit.at + edit.expected.len() as u64; + } + self.slice(last, self.length, &mut pieces); + let length = pieces.iter().map(|piece| piece.length).sum(); + if length > MAX_EDIT_SIZE { + return Err(Error::Limit); + } + Ok((pieces, length)) + } + + /// Bounded streaming for review and replacement preparation. This does not + /// install a file; callers still need native admission and a durable save journal. + pub fn write_to( + &self, + writer: &mut dyn std::io::Write, + cancelled: impl Fn() -> bool, + ) -> Result { + let mut hash = Sha256::new(); + let mut at = 0; + while at < self.length { + if cancelled() { + return Err(Error::Cancelled); + } + let bytes = self.read( + at, + (self.length - at).min(super::DEFAULT_PAGE as u64) as usize, + )?; + writer.write_all(&bytes).map_err(|_| Error::Io)?; + hash.update(&bytes); + at += bytes.len() as u64; + } + Ok(format!("{:x}", hash.finalize())) + } + + fn range_hash( + &self, + original: bool, + at: u64, + length: u64, + cancelled: &impl Fn() -> bool, + ) -> Result { + let mut hash = Sha256::new(); + let mut done = 0; + while done < length { + if cancelled() { + return Err(Error::Cancelled); + } + let size = (length - done).min(super::DEFAULT_PAGE as u64) as usize; + let bytes = if original { + self.base.read(at + done, size)? + } else { + self.read(at + done, size)? + }; + if bytes.len() != size { + return Err(Error::Io); + } + hash.update(&bytes); + done += size as u64; + } + Ok(format!("{:x}", hash.finalize())) + } + + fn ranges(&self) -> Vec<(u64, u64, u64, u64)> { + let (mut original, mut after, mut start) = (0, 0, 0); + let mut ranges = Vec::new(); + for piece in &self.pieces { + if matches!(piece.bytes, Bytes::Base) { + if piece.offset > original || after > start { + ranges.push((original, piece.offset - original, start, after - start)); + } + original = piece.offset + piece.length; + after += piece.length; + start = after; + } else { + after += piece.length; + } + } + if original < self.base.length() || start < self.length { + ranges.push(( + original, + self.base.length() - original, + start, + self.length - start, + )); + } + ranges + } + + /// Original coordinates and fingerprints cover all ranges; previews are bounded + /// and pageable. Revisiting a page does not alter the review identity. + pub fn review( + &self, + revision: &str, + start: usize, + limit: usize, + cancelled: impl Fn() -> bool, + ) -> Result { + self.current(revision)?; + if limit == 0 || limit > MAX_SPLICES { + return Err(Error::Limit); + } + if self.range_hash(true, 0, self.base.length(), &cancelled)? != self.base.sha256() { + return Err(Error::Changed); + } + let proposed_sha256 = self.write_to(&mut std::io::sink(), &cancelled)?; + let mut proof = Sha256::new(); + proof.update(b"cyber-binary-review-v1\0"); + proof.update(revision); + proof.update(self.base.sha256()); + proof.update(&proposed_sha256); + let (mut original_bytes, mut proposed_bytes, mut count) = (0, 0, 0); + let mut ranges = Vec::new(); + for (before, removed, after, inserted) in self.ranges() { + let before_sha256 = self.range_hash(true, before, removed, &cancelled)?; + let after_sha256 = self.range_hash(false, after, inserted, &cancelled)?; + if removed == inserted && before_sha256 == after_sha256 { + continue; + } + for number in [before, removed, after, inserted] { + proof.update(number.to_le_bytes()); + } + proof.update(&before_sha256); + proof.update(&after_sha256); + if count >= start && ranges.len() < limit { + let before_hex = encode_hex(&self.base.read(before, removed.min(256) as usize)?); + let after_hex = encode_hex(&self.read(after, inserted.min(256) as usize)?); + ranges.push(ChangeRange { + original_offset: Offset(before), + original_length: Offset(removed), + draft_offset: Offset(after), + draft_length: Offset(inserted), + before_sha256, + after_sha256, + before_hex, + after_hex, + preview_truncated: removed > 256 || inserted > 256, + }); + } + original_bytes += removed; + proposed_bytes += inserted; + count += 1; + } + if start > count { + return Err(Error::Invalid); + } + Ok(Review { + revision: revision.into(), + base_sha256: self.base.sha256().into(), + proposed_sha256, + base_length: Offset(self.base.length()), + proposed_length: Offset(self.length), + original_bytes: Offset(original_bytes), + proposed_bytes: Offset(proposed_bytes), + range_count: count, + next_range: (start + ranges.len() < count).then_some(start + ranges.len()), + ranges, + fingerprint: format!("{:x}", proof.finalize()), + }) + } + + pub fn snapshot(&self) -> DraftState { + DraftState { + version: 1, + nonce: self.nonce.clone(), + epoch: self.epoch, + base_sha256: self.base.sha256().into(), + base_length: Offset(self.base.length()), + cursor: self.cursor, + commands: self + .history + .iter() + .map(|command| { + command + .forward + .iter() + .map(|edit| Splice { + offset: Offset(edit.at), + delete_length: Offset(edit.expected.len() as u64), + expected_hex: encode_hex(&edit.expected), + insert_hex: encode_hex(&edit.inserted), + }) + .collect() + }) + .collect(), + } + } + + pub fn resume(base: B, state: DraftState) -> Result { + if state.version != 1 + || state.base_sha256 != base.sha256() + || state.base_length.0 != base.length() + || state.commands.len() > MAX_HISTORY + || state.cursor > state.commands.len() + || state.nonce.len() > 128 + || state.nonce.is_empty() + || state.epoch < state.commands.len() as u64 + { + return Err(Error::Invalid); + } + let mut draft = Self::new(base)?; + for splices in state.commands { + draft.patch(&PatchBatch { + revision: draft.revision(), + splices, + })?; + } + while draft.cursor > state.cursor { + draft.undo(&draft.revision())?; + } + draft.nonce = state.nonce; + draft.epoch = state.epoch; + Ok(draft) + } +} + +pub fn encode_hex(bytes: &[u8]) -> String { + const DIGITS: &[u8; 16] = b"0123456789abcdef"; + let mut output = String::with_capacity(bytes.len() * 2); + for byte in bytes { + output.push(DIGITS[(byte >> 4) as usize] as char); + output.push(DIGITS[(byte & 15) as usize] as char); + } + output +} + +/// Canonical payload identity ignores hex letter case and independent splice order. +/// Current-document bounds and revision remain checked when applying the batch. +pub fn canonical(batch: &PatchBatch) -> Result { + let decoded = decode(&batch.splices, MAX_EDIT_SIZE)?; + Ok(PatchBatch { + revision: batch.revision.clone(), + splices: decoded + .iter() + .map(|edit| Splice { + offset: Offset(edit.at), + delete_length: Offset(edit.expected.len() as u64), + expected_hex: encode_hex(&edit.expected), + insert_hex: encode_hex(&edit.inserted), + }) + .collect(), + }) +} + +fn decode(splices: &[Splice], length: u64) -> Result, Error> { + if splices.is_empty() || splices.len() > MAX_SPLICES { + return Err(Error::Limit); + } + let mut budget = 0usize; + let mut result = Vec::with_capacity(splices.len()); + for splice in splices { + budget = budget + .checked_add(splice.expected_hex.len()) + .and_then(|n| n.checked_add(splice.insert_hex.len())) + .ok_or(Error::Limit)?; + if budget > MAX_PATCH_BYTES * 2 { + return Err(Error::Limit); + } + let expected = decode_hex(&splice.expected_hex, MAX_PATCH_BYTES)?; + let inserted = decode_hex(&splice.insert_hex, MAX_PATCH_BYTES)?; + if splice.delete_length.0 != expected.len() as u64 + || splice + .offset + .0 + .checked_add(splice.delete_length.0) + .is_none_or(|end| end > length) + || (expected.is_empty() && inserted.is_empty()) + { + return Err(Error::Invalid); + } + result.push(Edit { + at: splice.offset.0, + expected: expected.into(), + inserted: inserted.into(), + }); + } + result.sort_by_key(|edit| edit.at); + for pair in result.windows(2) { + if pair[0].at == pair[1].at || pair[0].at + pair[0].expected.len() as u64 > pair[1].at { + return Err(Error::Invalid); + } + } + Ok(result) +} + +fn inverse(edits: &[Edit]) -> Result, Error> { + let mut shift = 0i64; + let mut result = Vec::with_capacity(edits.len()); + for edit in edits { + let at = edit.at.checked_add_signed(shift).ok_or(Error::Invalid)?; + result.push(Edit { + at, + expected: edit.inserted.clone(), + inserted: edit.expected.clone(), + }); + shift += edit.inserted.len() as i64 - edit.expected.len() as i64; + } + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + struct Base { + bytes: Vec, + digest: String, + } + impl Base { + fn new(bytes: Vec) -> Self { + let digest = format!("{:x}", Sha256::digest(&bytes)); + Self { bytes, digest } + } + } + impl ImmutableBase for Base { + fn length(&self) -> u64 { + self.bytes.len() as u64 + } + fn sha256(&self) -> &str { + &self.digest + } + fn read(&self, at: u64, length: usize) -> Result, Error> { + self.bytes + .get(at as usize..at as usize + length) + .map(<[u8]>::to_vec) + .ok_or(Error::Io) + } + } + fn edit(at: u64, expected: &[u8], inserted: &[u8]) -> Splice { + Splice { + offset: Offset(at), + delete_length: Offset(expected.len() as u64), + expected_hex: encode_hex(expected), + insert_hex: encode_hex(inserted), + } + } + fn patch(d: &mut Draft, splices: Vec) -> Result { + d.patch(&PatchBatch { + revision: d.revision(), + splices, + }) + } + fn bytes(d: &Draft) -> Vec { + d.read(0, d.length().0 as usize).unwrap() + } + + #[test] + fn review_refuses_base_corruption_even_when_only_inserted_bytes_would_be_read() { + let mut base = Base::new(b"original".to_vec()); + base.bytes.fill(0); // Keep the claimed digest and length unchanged. + let mut draft = Draft::new(base).unwrap(); + patch(&mut draft, vec![edit(0, &[0; 8], b"replacement")]).unwrap(); + assert!(matches!( + draft.review(&draft.revision(), 0, 128, || false), + Err(Error::Changed) + )); + } + + #[test] + fn a_batch_uses_original_coordinates_and_undo_redo_are_atomic_and_fenced() { + let mut d = Draft::new(Base::new((0..=255).collect())).unwrap(); + let original = d.revision(); + let changed = patch( + &mut d, + vec![ + edit(250, &[250, 251, 252, 253, 254, 255], &[]), + edit(1, &[1, 2], &[255, 0, 128, 27]), + edit(0, &[], b"head"), + ], + ) + .unwrap(); + let mut expected = b"head".to_vec(); + expected.push(0); + expected.extend([255, 0, 128, 27]); + expected.extend(3..250); + assert_eq!(bytes(&d), expected); + assert_eq!(d.length().0, 256); + assert!(d.page(&original, Offset(0), 1).is_err()); + let undone = d.undo(&changed).unwrap(); + assert_eq!(bytes(&d), (0..=255).collect::>()); + assert_ne!(undone, original); + assert!(!d.can_undo()); + assert!(d.can_redo()); + d.redo(&undone).unwrap(); + assert_eq!(bytes(&d), expected); + let state = d.snapshot(); + let restored = Draft::resume(Base::new((0..=255).collect()), state).unwrap(); + assert_eq!(restored.revision(), d.revision()); + assert_eq!(bytes(&restored), expected); + } + #[test] + fn an_invalid_later_splice_changes_neither_bytes_revision_nor_history() { + let mut d = Draft::new(Base::new(b"abcdef".to_vec())).unwrap(); + let revision = d.revision(); + for splices in [ + vec![edit(0, b"a", b"A"), edit(4, b"wrong", b"E")], + vec![edit(0, b"ab", b"A"), edit(1, b"b", b"B")], + vec![edit(0, b"", b"A"), edit(0, b"", b"B")], + vec![Splice { + offset: Offset(u64::MAX), + ..edit(0, b"a", b"A") + }], + vec![Splice { + expected_hex: "0".into(), + ..edit(0, b"a", b"A") + }], + vec![Splice { + insert_hex: "gg".into(), + ..edit(0, b"a", b"A") + }], + vec![Splice { + delete_length: Offset(2), + ..edit(0, b"a", b"A") + }], + vec![edit(0, b"", b"")], + vec![], + vec![edit(0, b"", b"X"); MAX_SPLICES + 1], + vec![edit(0, b"", &vec![0; MAX_PATCH_BYTES + 1])], + ] { + assert!(patch(&mut d, splices).is_err()); + assert_eq!(d.revision(), revision); + assert_eq!(bytes(&d), b"abcdef"); + assert!(!d.can_undo()); + assert!(!d.can_redo()); + } + assert!( + d.patch(&PatchBatch { + revision: "old".into(), + splices: vec![edit(0, b"a", b"A")] + }) + .is_err() + ); + assert_eq!(d.revision(), revision); + } + #[test] + fn adjacent_deletes_and_insertions_round_trip_without_coordinate_drift() { + let mut d = Draft::new(Base::new(b"abcd".to_vec())).unwrap(); + patch( + &mut d, + vec![ + edit(0, b"a", b""), + edit(1, b"b", b""), + edit(2, b"", b"XY"), + edit(3, b"d", b"!"), + ], + ) + .unwrap(); + assert_eq!(bytes(&d), b"XYc!"); + d.undo(&d.revision()).unwrap(); + assert_eq!(bytes(&d), b"abcd"); + d.redo(&d.revision()).unwrap(); + assert_eq!(bytes(&d), b"XYc!"); + } + #[test] + fn empty_eof_history_branch_and_snapshot_keep_lossless_state() { + let mut d = Draft::new(Base::new(vec![])).unwrap(); + patch(&mut d, vec![edit(0, b"", &[0, 255, 128])]).unwrap(); + let page = d.page(&d.revision(), Offset(3), 1).unwrap(); + assert!(page.eof && page.bytes.is_empty()); + d.undo(&d.revision()).unwrap(); + let state: DraftState = + serde_json::from_slice(&serde_json::to_vec(&d.snapshot()).unwrap()).unwrap(); + let mut resumed = Draft::resume(Base::new(vec![]), state).unwrap(); + assert_eq!(resumed.revision(), d.revision()); + resumed.redo(&resumed.revision()).unwrap(); + assert_eq!(bytes(&resumed), [0, 255, 128]); + resumed.undo(&resumed.revision()).unwrap(); + patch(&mut resumed, vec![edit(0, b"", b"branch")]).unwrap(); + assert!(!resumed.can_redo()); + assert_eq!(bytes(&resumed), b"branch"); + let mut state = resumed.snapshot(); + state.base_sha256 = "00".repeat(32); + assert!(Draft::resume(Base::new(vec![]), state).is_err()); + } + #[test] + fn deterministic_edit_history_matches_a_byte_vector_reference() { + let mut seed = 0x123456789abcdefu64; + let mut next = || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed + }; + let base: Vec = (0..=255).collect(); + let mut d = Draft::new(Base::new(base.clone())).unwrap(); + let mut states = vec![base]; + let mut cursor = 0; + for _ in 0..400 { + let action = next() % 5; + if action == 0 && d.can_undo() { + d.undo(&d.revision()).unwrap(); + cursor -= 1; + } else if action == 1 && d.can_redo() { + d.redo(&d.revision()).unwrap(); + cursor += 1; + } else { + let mut reference = states[cursor].clone(); + let at = (next() as usize) % (reference.len() + 1); + let delete = (next() as usize % 8).min(reference.len() - at); + let insert: Vec = (0..(1 + next() % 8)).map(|_| next() as u8).collect(); + patch( + &mut d, + vec![edit(at as u64, &reference[at..at + delete], &insert)], + ) + .unwrap(); + reference.splice(at..at + delete, insert); + states.truncate(cursor + 1); + states.push(reference); + cursor += 1; + } + assert_eq!(bytes(&d), states[cursor]); + } + } + #[test] + fn review_pages_cover_original_coordinates_and_share_one_complete_fingerprint() { + let mut d = Draft::new(Base::new(b"abcdef".to_vec())).unwrap(); + patch(&mut d, vec![edit(0, b"a", b"XY"), edit(4, b"e", b"Z")]).unwrap(); + let first = d.review(&d.revision(), 0, 1, || false).unwrap(); + let second = d.review(&d.revision(), 1, 1, || false).unwrap(); + assert_eq!(first.fingerprint, second.fingerprint); + assert_eq!(first.range_count, 2); + assert_eq!(first.next_range, Some(1)); + assert_eq!(second.next_range, None); + assert_eq!(first.ranges[0].original_offset.0, 0); + assert_eq!(first.ranges[0].draft_length.0, 2); + assert_eq!(second.ranges[0].original_offset.0, 4); + assert_eq!(second.ranges[0].draft_offset.0, 5); + assert_eq!( + first.proposed_sha256, + format!("{:x}", Sha256::digest(b"XYbcdZf")) + ); + assert_eq!(first.original_bytes.0, 2); + assert_eq!(first.proposed_bytes.0, 3); + assert!(matches!( + d.review(&d.revision(), 0, 1, || true), + Err(Error::Cancelled) + )); + let prior = first.fingerprint; + d.undo(&d.revision()).unwrap(); + assert_ne!( + d.review(&d.revision(), 0, 1, || false).unwrap().fingerprint, + prior + ); + } + #[test] + fn unchanged_overwrites_are_not_fabricated_changes_and_large_previews_stay_bounded() { + let mut d = Draft::new(Base::new(vec![0; 1024])).unwrap(); + patch(&mut d, vec![edit(0, &[0], &[0])]).unwrap(); + let clean = d.review(&d.revision(), 0, 128, || false).unwrap(); + assert_eq!(clean.range_count, 0); + assert_eq!(clean.base_sha256, clean.proposed_sha256); + patch(&mut d, vec![edit(0, &vec![0; 512], &vec![255; 512])]).unwrap(); + let review = d.review(&d.revision(), 0, 128, || false).unwrap(); + assert_eq!(review.range_count, 1); + assert!(review.ranges[0].preview_truncated); + assert_eq!(review.ranges[0].before_hex.len(), 512); + assert_eq!(review.ranges[0].after_hex.len(), 512); + let mut output = Vec::new(); + assert_eq!( + d.write_to(&mut output, || false).unwrap(), + review.proposed_sha256 + ); + assert_eq!(output.len(), 1024); + assert_eq!(&output[..512], &[255; 512]); + } + #[test] + fn history_capacity_refuses_without_silently_discarding_undo() { + let mut d = Draft::new(Base::new(vec![])).unwrap(); + for _ in 0..MAX_HISTORY { + let end = d.length().0; + patch(&mut d, vec![edit(end, b"", b"x")]).unwrap(); + } + let revision = d.revision(); + assert!(patch(&mut d, vec![edit(0, b"", b"y")]).is_err()); + assert_eq!(d.revision(), revision); + d.undo(&revision).unwrap(); + patch(&mut d, vec![edit(0, b"", b"y")]).unwrap(); + assert!(!d.can_redo()); + } +} diff --git a/crates/cyber-core/src/binary/editor.rs b/crates/cyber-core/src/binary/editor.rs new file mode 100644 index 00000000..177f2306 --- /dev/null +++ b/crates/cyber-core/src/binary/editor.rs @@ -0,0 +1,1083 @@ +//! Durable document operations and recoverable native save. Callers retain fresh +//! owner/read/edit/Mode authority and the checkout mutation lease around commit. +//! A native intent is never automatically re-executed after interruption. +use super::{ + Document, Error, Offset, + draft::{Draft, DraftState, ImmutableBase, PatchBatch, Review}, + native::{self, Target}, + storage::{Base, Store}, +}; +use cap_fs_ext::{FollowSymlinks, OpenOptionsFollowExt}; +use cap_std::fs::OpenOptions; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::fs::File; +use std::io::Write; +use std::path::PathBuf; + +#[path = "editor/restore.rs"] +mod restore; + +const MAX_OPERATIONS: usize = 4096; +const EFFECTS: &str = "Permissions, executable bits, owner/group, supported extended attributes and ACL are preserved. Native replacement changes file identity and updates modification time. Save checkpoints draft undo/redo; Restore Last Save uses the retained preimage. External checksums, signatures and irreversible observer effects are not repaired. Uncooperative writers can leave an unknown outcome; evidence is retained."; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Owner { + pub session_id: String, + pub directory: PathBuf, +} +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum Outcome { + Acknowledged, + Conflict, + Unknown, +} +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum FileEffect { + None, + Changed, + Unknown, +} +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct Operation { + pub id: String, + pub kind: String, + pub payload_sha256: String, + pub outcome: Outcome, + pub revision: String, + pub file_effect: FileEffect, + pub reason: Option, + pub notification_pending: bool, +} +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct SaveReview { + pub draft: Review, + pub permissions: u32, + pub owner_uid: u32, + pub owner_gid: u32, + pub metadata_sha256: String, + pub effects: String, + pub fingerprint: String, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct BaseRef { + name: String, + length: Offset, + sha256: String, +} +impl BaseRef { + fn of(base: &Base) -> Self { + Self { + name: base.name().into(), + length: Offset(base.length()), + sha256: base.sha256().into(), + } + } + fn open(&self, store: &Store) -> Result { + store.open_base(&self.name, self.length.0, &self.sha256) + } +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct LastSave { + preimage: BaseRef, + installed_sha256: String, + operation: String, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Intent { + operation: Operation, + prior: Target, + after: Target, + new_base: BaseRef, + after_draft: DraftState, + stage: String, +} +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Record { + version: u32, + owner: Owner, + target: Target, + base: BaseRef, + draft: DraftState, + operations: Vec, + pending: Option, + last_save: Option, +} +pub struct Editor { + store: Store, + draft: Draft, + record: Record, +} +pub struct PreparedSave { + intent: Intent, + after: Draft, + expected_revision: String, +} +pub enum SavePreparation { + Existing(Operation), + Ready(Box), +} + +fn signature(value: &T) -> Result { + let bytes = serde_json::to_vec(value).map_err(|_| Error::Invalid)?; + Ok(format!("{:x}", Sha256::digest(bytes))) +} +fn operation_id(value: &str) -> Result<(), Error> { + if value.is_empty() + || value.len() > 100 + || !value + .bytes() + .all(|c| c.is_ascii_alphanumeric() || c == b'_' || c == b'-') + { + return Err(Error::Invalid); + } + Ok(()) +} +struct Counter(u64); +impl Write for Counter { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0 = self + .0 + .checked_add(bytes.len() as u64) + .ok_or_else(|| std::io::Error::other("binary record limit"))?; + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} +fn persist(store: &mut Store, record: &Record) -> Result<(), Error> { + let mut count = Counter(0); + serde_json::to_writer(&mut count, record).map_err(|_| Error::Io)?; + store.record("document.json", count.0, |writer| { + serde_json::to_writer(writer, record).map_err(|_| Error::Io) + }) +} +fn stage_file(store: &Store, name: &str) -> Result { + if name.len() > 100 + || !name.starts_with("stage_") + || !name.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'_') + { + return Err(Error::Invalid); + } + let mut options = OpenOptions::new(); + options.read(true).follow(FollowSymlinks::No); + let file = store + .directory() + .open_with(name, &options) + .map_err(|_| Error::File)? + .into_std(); + native::metadata(&file)?; + Ok(file) +} +impl Editor { + pub fn create( + mut store: Store, + owner: Owner, + document: &mut Document, + cancelled: impl Fn() -> bool, + ) -> Result { + if owner.session_id.is_empty() || !owner.directory.is_absolute() { + return Err(Error::Invalid); + } + // Never replace an existing document, including one with unresolved evidence. + if store.directory().symlink_metadata("document.json").is_ok() { + return Err(Error::Changed); + } + let (base, target) = document.prepare(&store, cancelled)?; + if target.root != owner.directory { + return Err(Error::File); + } + let draft = Draft::new(base)?; + let record = Record { + version: 1, + owner, + target, + base: BaseRef::of(draft.base()), + draft: draft.snapshot(), + operations: vec![], + pending: None, + last_save: None, + }; + persist(&mut store, &record)?; + Ok(Self { + store, + draft, + record, + }) + } + pub fn resume(store: Store, owner: &Owner) -> Result { + let record: Record = serde_json::from_slice(&store.read_record("document.json")?) + .map_err(|_| Error::Invalid)?; + if record.version != 1 + || &record.owner != owner + || record.operations.len() > MAX_OPERATIONS + || record.target.root != owner.directory + { + return Err(Error::File); + } + let draft = Draft::resume(record.base.open(&store)?, record.draft.clone())?; + Ok(Self { + store, + draft, + record, + }) + } + pub fn draft(&self) -> &Draft { + &self.draft + } + pub fn target(&self) -> &Target { + &self.record.target + } + pub fn owner(&self) -> &Owner { + &self.record.owner + } + pub fn unresolved(&self) -> bool { + self.record.pending.is_some() + } + fn available(&self) -> Result<(), Error> { + self.store.validate()?; + if self.unresolved() { + return Err(Error::Changed); + } + Ok(()) + } + pub fn operation(&self, id: &str) -> Option { + self.record + .operations + .iter() + .find(|op| op.id == id) + .cloned() + .or_else(|| { + self.record + .pending + .as_ref() + .filter(|pending| pending.operation.id == id) + .map(|pending| { + let mut op = pending.operation.clone(); + op.outcome = Outcome::Unknown; + op.reason = Some("native_outcome_requires_reconciliation".into()); + op + }) + }) + } + fn prior(&self, id: &str, payload: &str) -> Result, Error> { + operation_id(id)?; + if let Some(op) = self.operation(id) { + if op.payload_sha256 != payload { + return Err(Error::Changed); + } + return Ok(Some(op)); + } + if self.record.operations.len() >= MAX_OPERATIONS { + return Err(Error::Limit); + } + Ok(None) + } + pub fn patch(&mut self, id: &str, batch: &PatchBatch) -> Result { + let batch = super::draft::canonical(batch)?; + let payload = signature(&("patch-v1", &batch))?; + if let Some(prior) = self.prior(id, &payload)? { + return Ok(prior); + } + self.available()?; + let mut candidate = self.draft.clone(); + candidate.patch(&batch)?; + self.commit_draft(id, "patch", payload, candidate) + } + pub fn history(&mut self, id: &str, revision: &str, redo: bool) -> Result { + let kind = if redo { "redo" } else { "undo" }; + let payload = signature(&(kind, revision))?; + if let Some(prior) = self.prior(id, &payload)? { + return Ok(prior); + } + self.available()?; + let mut candidate = self.draft.clone(); + if redo { + candidate.redo(revision)?; + } else { + candidate.undo(revision)?; + } + self.commit_draft(id, kind, payload, candidate) + } + fn commit_draft( + &mut self, + id: &str, + kind: &str, + payload: String, + candidate: Draft, + ) -> Result { + let operation = Operation { + id: id.into(), + kind: kind.into(), + payload_sha256: payload, + outcome: Outcome::Acknowledged, + revision: candidate.revision(), + file_effect: FileEffect::None, + reason: None, + notification_pending: false, + }; + self.record.draft = candidate.snapshot(); + self.record.operations.push(operation.clone()); + if let Err(error) = persist(&mut self.store, &self.record) { + self.record.operations.pop(); + self.record.draft = self.draft.snapshot(); + return Err(error); + } + self.draft = candidate; + Ok(operation) + } + pub fn review( + &self, + start: usize, + limit: usize, + cancelled: impl Fn() -> bool, + ) -> Result { + self.store.validate()?; + let draft = self + .draft + .review(&self.draft.revision(), start, limit, cancelled)?; + let meta = &self.record.target.metadata; + let fingerprint = signature(&( + "save-review-v1", + &draft.fingerprint, + &self.record.target, + EFFECTS, + ))?; + Ok(SaveReview { + draft, + permissions: meta.mode, + owner_uid: meta.uid, + owner_gid: meta.gid, + metadata_sha256: meta.evidence.clone(), + effects: EFFECTS.into(), + fingerprint, + }) + } + /// Caller may release the blocking worker after preparation, refresh all + /// authority asynchronously, then commit while retaining its checkout lease. + pub fn prepare_save( + &mut self, + id: &str, + review: &str, + cancelled: impl Fn() -> bool, + ) -> Result { + let payload = signature(&("save-v1", review))?; + if let Some(prior) = self.prior(id, &payload)? { + return Ok(SavePreparation::Existing(prior)); + } + self.available()?; + let current = self.review(0, 128, &cancelled)?; + if current.fingerprint != review { + return Err(Error::Changed); + } + let target = self.record.target.clone(); + let (_parent, source) = match target.validate() { + Ok(source) => source, + Err(Error::Changed | Error::File) => { + return self + .conflict(id, "save", payload, "source_binding_changed") + .map(SavePreparation::Existing); + } + Err(error) => return Err(error), + }; + if native::content(&source, &cancelled)? != self.draft.base().sha256() { + return self + .conflict(id, "save", payload, "source_content_changed") + .map(SavePreparation::Existing); + } + if current.draft.range_count == 0 { + let checkpoint = Draft::new(self.draft.base().clone())?; + return self + .commit_draft(id, "save", payload, checkpoint) + .map(SavePreparation::Existing); + } + let base = self.store.base_cancellable( + self.draft.length().0, + |writer| self.draft.write_to(writer, &cancelled), + &cancelled, + )?; + if base.sha256() != current.draft.proposed_sha256 { + return Err(Error::Changed); + } + let (stage_name, mut stage) = self.store.stage(base.length())?; + let digest = self.draft.write_to(&mut stage, &cancelled)?; + if digest != base.sha256() { + return Err(Error::Changed); + } + native::copy_metadata(&source, &stage)?; + native::sync(&stage)?; + self.store.sync()?; + let after = Target { + root: target.root.clone(), + relative: target.relative.clone(), + parents: target.parents.clone(), + file: native::id(&stage)?, + length: Offset(base.length()), + metadata: native::metadata(&stage)?, + observed: String::new(), // The installed change stamp is captured after exchange. + }; + let after_draft = Draft::new(base)?; + let operation = Operation { + id: id.into(), + kind: "save".into(), + payload_sha256: payload, + outcome: Outcome::Unknown, + revision: after_draft.revision(), + file_effect: FileEffect::Unknown, + reason: None, + notification_pending: false, + }; + let intent = Intent { + operation, + prior: target, + after, + new_base: BaseRef::of(after_draft.base()), + after_draft: after_draft.snapshot(), + stage: stage_name, + }; + Ok(SavePreparation::Ready(Box::new(PreparedSave { + intent, + after: after_draft, + expected_revision: self.draft.revision(), + }))) + } + fn conflict( + &mut self, + id: &str, + kind: &str, + payload: String, + reason: &str, + ) -> Result { + let operation = Operation { + id: id.into(), + kind: kind.into(), + payload_sha256: payload, + outcome: Outcome::Conflict, + revision: self.draft.revision(), + file_effect: FileEffect::None, + reason: Some(reason.into()), + notification_pending: false, + }; + self.record.operations.push(operation.clone()); + if let Err(error) = persist(&mut self.store, &self.record) { + self.record.operations.pop(); + return Err(error); + } + Ok(operation) + } + /// After durable intent, every failure preserves evidence and returns unknown. + /// No response loss or restart can cause an automatic second exchange. + pub fn commit_save(&mut self, prepared: PreparedSave) -> Result { + self.commit_save_cancellable(prepared, || false) + } + /// Cancellation is honored before intent publication. After publication the + /// caller must retain its leases until outcome settlement finishes. + pub fn commit_save_cancellable( + &mut self, + prepared: PreparedSave, + cancelled: impl Fn() -> bool, + ) -> Result { + if let Some(prior) = self.prior( + &prepared.intent.operation.id, + &prepared.intent.operation.payload_sha256, + )? { + return Ok(prior); + } + self.available()?; + if cancelled() { + return Err(Error::Cancelled); + } + if self.draft.revision() != prepared.expected_revision { + return self.conflict( + &prepared.intent.operation.id, + &prepared.intent.operation.kind, + prepared.intent.operation.payload_sha256.clone(), + "draft_changed_since_preparation", + ); + } + if prepared.intent.prior.validate().is_err() { + return self.conflict( + &prepared.intent.operation.id, + &prepared.intent.operation.kind, + prepared.intent.operation.payload_sha256.clone(), + "source_changed_since_preparation", + ); + } + let (_, source) = prepared.intent.prior.validate()?; + if native::content(&source, &cancelled)? != self.record.base.sha256 { + return self.conflict( + &prepared.intent.operation.id, + &prepared.intent.operation.kind, + prepared.intent.operation.payload_sha256.clone(), + "source_content_changed_since_preparation", + ); + } + self.store.validate()?; + if cancelled() { + return Err(Error::Cancelled); + } + self.record.pending = Some(prepared.intent); + persist(&mut self.store, &self.record)?; + let intent = self.record.pending.as_ref().ok_or(Error::Invalid)?; + let _effect = native::exchange(self.store.directory(), &intent.stage, &intent.prior); + // Even an error may follow a native effect. Reconcile by identities and + // content rather than retrying or attempting an unreviewed rollback. + self.settle(Some(prepared.after)) + } + pub fn reconcile(&mut self) -> Result { + self.settle(None) + } + fn settle(&mut self, after: Option>) -> Result { + self.store.validate()?; + let intent = self.record.pending.as_ref().ok_or(Error::Invalid)?; + let mut unknown = intent.operation.clone(); + unknown.outcome = Outcome::Unknown; + unknown.reason = Some("native_outcome_unknown_evidence_retained".into()); + let verified = (|| { + let installed_target = Target::capture(&intent.after.root, &intent.after.relative)?; + if installed_target.file != intent.after.file + || installed_target.parents != intent.after.parents + || installed_target.metadata != intent.after.metadata + || installed_target.length != intent.after.length + { + return Err(Error::Changed); + } + let (parent, installed) = installed_target.validate()?; + let displaced = stage_file(&self.store, &intent.stage)?; + let displaced_stamp = native::generation(&displaced)?; + if native::id(&displaced)? != intent.prior.file + || native::metadata(&displaced)? != intent.prior.metadata + || native::content(&installed, || false)? != intent.new_base.sha256 + || native::content(&displaced, || false)? != self.record.base.sha256 + { + return Err(Error::Changed); + } + native::sync(&installed)?; + parent + .try_clone() + .map_err(|_| Error::Io)? + .into_std_file() + .sync_all() + .map_err(|_| Error::Io)?; + self.store.sync()?; + installed_target.validate()?; + if native::generation(&displaced)? != displaced_stamp { + return Err(Error::Changed); + } + Ok(installed_target) + })(); + let installed_target = match verified { + Ok(target) => target, + Err(_) => return Ok(unknown), + }; + let draft = match after { + Some(draft) => draft, + None => Draft::resume( + intent.new_base.open(&self.store)?, + intent.after_draft.clone(), + )?, + }; + let mut operation = intent.operation.clone(); + operation.outcome = Outcome::Acknowledged; + operation.file_effect = FileEffect::Changed; + operation.reason = None; + operation.notification_pending = true; + // Build the next durable record without discarding the prior intent if a + // record write fails after the file was installed. + let next = Record { + version: 1, + owner: self.record.owner.clone(), + target: installed_target, + base: intent.new_base.clone(), + draft: draft.snapshot(), + operations: self + .record + .operations + .iter() + .cloned() + .chain(Some(operation.clone())) + .collect(), + pending: None, + last_save: Some(LastSave { + preimage: self.record.base.clone(), + installed_sha256: intent.new_base.sha256.clone(), + operation: operation.id.clone(), + }), + }; + if persist(&mut self.store, &next).is_err() { + return Ok(unknown); + } + self.record = next; + self.draft = draft; + Ok(operation) + } + /// 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.store.validate()?; + let operation = self + .record + .operations + .iter_mut() + .find(|op| { + op.id == id + && op.outcome == Outcome::Acknowledged + && op.file_effect == FileEffect::Changed + }) + .ok_or(Error::Invalid)?; + operation.notification_pending = false; + persist(&mut self.store, &self.record) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::binary::{ + draft::{Splice, encode_hex}, + storage::Store, + }; + use std::path::Path; + fn setup() -> (tempfile::TempDir, Editor, PathBuf, Owner) { + let root = tempfile::tempdir().unwrap(); + let data = root.path().join("data"); + let project = root.path().join("project"); + std::fs::create_dir(&data).unwrap(); + std::fs::create_dir(&project).unwrap(); + let path = project.join("bytes"); + std::fs::write(&path, b"original").unwrap(); + let owner = Owner { + session_id: "ses_one".into(), + directory: project.canonicalize().unwrap(), + }; + let mut document = Document::open(&owner.directory, "bytes").unwrap(); + let editor = Editor::create( + Store::open(&data, "bin_one").unwrap(), + owner.clone(), + &mut document, + || false, + ) + .unwrap(); + (root, editor, path, owner) + } + fn patch(editor: &mut Editor, id: &str) -> Operation { + let batch = PatchBatch { + revision: editor.draft().revision(), + splices: vec![Splice { + offset: Offset(0), + delete_length: Offset(8), + expected_hex: encode_hex(b"original"), + insert_hex: encode_hex(&[0, 255, 128, 27]), + }], + }; + editor.patch(id, &batch).unwrap() + } + fn ready(editor: &mut Editor, id: &str) -> Box { + let review = editor.review(0, 128, || false).unwrap(); + match editor + .prepare_save(id, &review.fingerprint, || false) + .unwrap() + { + SavePreparation::Ready(ready) => ready, + _ => panic!("new save must prepare"), + } + } + #[test] + fn creating_a_document_cannot_bind_a_different_owner_location() { + let (root, _editor, path, mut owner) = setup(); + let mut document = Document::open(&owner.directory, "bytes").unwrap(); + owner.directory = root.path().canonicalize().unwrap(); + let result = Editor::create( + Store::open(&root.path().join("data"), "bin_wrong_location").unwrap(), + owner, + &mut document, + || false, + ); + assert!(matches!(result, Err(Error::File))); + assert_eq!(std::fs::read(path).unwrap(), b"original"); + } + + #[test] + fn cancellation_before_intent_leaves_source_and_receipts_unchanged() { + let (_root, mut editor, path, _owner) = setup(); + patch(&mut editor, "patch_cancel"); + let prepared = ready(&mut editor, "save_cancel"); + let identity = editor.target().file.clone(); + assert!(matches!( + editor.commit_save_cancellable(*prepared, || true), + Err(Error::Cancelled) + )); + assert!(!editor.unresolved()); + assert!(editor.operation("save_cancel").is_none()); + assert_eq!(editor.target().file, identity); + assert_eq!(std::fs::read(&path).unwrap(), b"original"); + let prepared = ready(&mut editor, "save_cancel"); + assert_eq!( + editor.commit_save(*prepared).unwrap().outcome, + Outcome::Acknowledged + ); + assert_eq!(std::fs::read(path).unwrap(), [0, 255, 128, 27]); + } + + #[test] + fn equivalent_patch_encodings_reuse_the_durable_receipt() { + let (_root, mut editor, _path, _owner) = setup(); + let mut batch = PatchBatch { + revision: editor.draft().revision(), + splices: vec![ + Splice { + offset: Offset(0), + delete_length: Offset(1), + expected_hex: "6F".into(), + insert_hex: "FF".into(), + }, + Splice { + offset: Offset(7), + delete_length: Offset(1), + expected_hex: "6C".into(), + insert_hex: "AA".into(), + }, + ], + }; + let first = editor.patch("canonical_patch", &batch).unwrap(); + batch.splices.reverse(); + for splice in &mut batch.splices { + splice.expected_hex.make_ascii_lowercase(); + splice.insert_hex.make_ascii_lowercase(); + } + let retry = editor.patch("canonical_patch", &batch).unwrap(); + assert_eq!(retry.revision, first.revision); + assert_eq!(retry.payload_sha256, first.payload_sha256); + assert_eq!( + editor.draft().read(0, 8).unwrap(), + [255, 114, 105, 103, 105, 110, 97, 170] + ); + } + + #[test] + fn native_save_is_durable_and_retry_returns_the_original_receipt_without_another_exchange() { + let (root, mut editor, path, owner) = setup(); + patch(&mut editor, "patch_one"); + assert_eq!(std::fs::read(&path).unwrap(), b"original"); + let review = editor.review(0, 128, || false).unwrap(); + let ready = ready(&mut editor, "save_one"); + let receipt = editor.commit_save(*ready).unwrap(); + assert_eq!(receipt.outcome, Outcome::Acknowledged); + assert!(receipt.file_effect == FileEffect::Changed && receipt.notification_pending); + assert_eq!(std::fs::read(&path).unwrap(), [0, 255, 128, 27]); + let identity = editor.target().file.clone(); + match editor + .prepare_save("save_one", &review.fingerprint, || false) + .unwrap() + { + SavePreparation::Existing(prior) => assert_eq!(prior.revision, receipt.revision), + _ => panic!("save retry prepared another exchange"), + } + assert_eq!(editor.target().file, identity); + assert!( + editor + .prepare_save("save_one", "other_payload", || false) + .is_err() + ); + drop(editor); + let editor = Editor::resume( + Store::open(&root.path().join("data"), "bin_one").unwrap(), + &owner, + ) + .unwrap(); + assert_eq!( + editor.operation("save_one").unwrap().outcome, + Outcome::Acknowledged + ); + assert!(!editor.unresolved()); + assert_eq!(editor.draft().read(0, 4).unwrap(), [0, 255, 128, 27]); + } + #[test] + fn dirty_commands_and_history_survive_restart_and_operation_ids_cannot_change_payload() { + let (root, mut editor, path, owner) = setup(); + let prior = editor.draft().revision(); + let batch = PatchBatch { + revision: prior.clone(), + splices: vec![Splice { + offset: Offset(0), + delete_length: Offset(1), + expected_hex: "6f".into(), + insert_hex: "4f".into(), + }], + }; + let applied = editor.patch("patch_one", &batch).unwrap(); + assert_eq!( + editor.patch("patch_one", &batch).unwrap().revision, + applied.revision + ); + let different = PatchBatch { + revision: prior, + splices: vec![Splice { + offset: Offset(0), + delete_length: Offset(1), + expected_hex: "6f".into(), + insert_hex: "58".into(), + }], + }; + assert!(editor.patch("patch_one", &different).is_err()); + drop(editor); + let mut editor = Editor::resume( + Store::open(&root.path().join("data"), "bin_one").unwrap(), + &owner, + ) + .unwrap(); + assert_eq!(editor.draft().read(0, 8).unwrap(), b"Original"); + let revision = editor.draft().revision(); + let undo = editor.history("undo_one", &revision, false).unwrap(); + assert_eq!(editor.draft().read(0, 8).unwrap(), b"original"); + editor.history("redo_one", &undo.revision, true).unwrap(); + assert_eq!(editor.draft().read(0, 8).unwrap(), b"Original"); + assert_eq!(std::fs::read(path).unwrap(), b"original"); + } + #[test] + fn equal_size_equal_mtime_external_bytes_conflict_without_a_write_or_losing_the_draft() { + let (_root, mut editor, path, _owner) = setup(); + patch(&mut editor, "patch_one"); + let review = editor.review(0, 128, || false).unwrap(); + let modified = std::fs::metadata(&path).unwrap().modified().unwrap(); + std::fs::write(&path, b"external").unwrap(); + std::fs::File::open(&path) + .unwrap() + .set_modified(modified) + .unwrap(); + let result = editor + .prepare_save("save_conflict", &review.fingerprint, || false) + .unwrap(); + match result { + SavePreparation::Existing(op) => { + assert_eq!(op.outcome, Outcome::Conflict); + assert_eq!(op.file_effect, FileEffect::None); + } + _ => panic!("stale bytes were accepted"), + } + assert_eq!(std::fs::read(path).unwrap(), b"external"); + assert_eq!(editor.draft().read(0, 4).unwrap(), [0, 255, 128, 27]); + } + #[test] + fn unchanged_save_checkpoints_without_replacing_the_file_or_emitting_a_change() { + let (_root, mut editor, path, _owner) = setup(); + let before = editor.target().file.clone(); + let review = editor.review(0, 128, || false).unwrap(); + let SavePreparation::Existing(op) = editor + .prepare_save("no_change", &review.fingerprint, || false) + .unwrap() + else { + panic!("unchanged content created a replacement") + }; + assert_eq!(op.outcome, Outcome::Acknowledged); + assert_eq!(op.file_effect, FileEffect::None); + assert!(!op.notification_pending); + assert_eq!(editor.target().file, before); + assert_eq!(std::fs::read(path).unwrap(), b"original"); + } + #[test] + fn failed_receipt_flush_after_native_effect_is_unknown_until_durable_reopen() { + let (root, mut editor, path, owner) = setup(); + patch(&mut editor, "patch_one"); + let review = editor.review(0, 128, || false).unwrap(); + let prepared = ready(&mut editor, "save_flush"); + editor.store.fail_after_publication = Some(editor.store.publications + 2); + assert_eq!( + editor.commit_save(*prepared).unwrap().outcome, + Outcome::Unknown + ); + assert!(editor.store.validate().is_err()); + assert!(editor.unresolved()); + let identity = Target::capture(&owner.directory, Path::new("bytes")) + .unwrap() + .file; + assert_eq!(std::fs::read(&path).unwrap(), [0, 255, 128, 27]); + let SavePreparation::Existing(receipt) = editor + .prepare_save("save_flush", &review.fingerprint, || false) + .unwrap() + else { + panic!("uncertain retry repeated a native effect") + }; + assert_eq!(receipt.outcome, Outcome::Unknown); + drop(editor); + let resumed = Editor::resume( + Store::open(&root.path().join("data"), "bin_one").unwrap(), + &owner, + ) + .unwrap(); + assert_eq!( + resumed.operation("save_flush").unwrap().outcome, + Outcome::Acknowledged + ); + assert_eq!(resumed.target().file, identity); + } + #[test] + fn failed_intent_flush_fences_the_editor_without_changing_the_target() { + let (root, mut editor, path, owner) = setup(); + patch(&mut editor, "patch_one"); + let prepared = ready(&mut editor, "save_flush"); + editor.store.fail_after_publication = Some(editor.store.publications + 1); + assert!(editor.commit_save(*prepared).is_err()); + assert!(editor.store.validate().is_err()); + assert_eq!(std::fs::read(&path).unwrap(), b"original"); + drop(editor); + let mut resumed = Editor::resume( + Store::open(&root.path().join("data"), "bin_one").unwrap(), + &owner, + ) + .unwrap(); + assert_eq!(resumed.reconcile().unwrap().outcome, Outcome::Unknown); + assert_eq!(std::fs::read(path).unwrap(), b"original"); + } + #[test] + fn a_same_size_same_mtime_change_after_preparation_is_refused_before_exchange() { + let (_root, mut editor, path, _owner) = setup(); + patch(&mut editor, "patch_one"); + let prepared = ready(&mut editor, "save_race"); + let modified = std::fs::metadata(&path).unwrap().modified().unwrap(); + std::fs::write(&path, b"external").unwrap(); + File::open(&path).unwrap().set_modified(modified).unwrap(); + assert_eq!( + editor.commit_save(*prepared).unwrap().outcome, + Outcome::Conflict + ); + assert_eq!(std::fs::read(path).unwrap(), b"external"); + assert!(!editor.unresolved()); + } + #[test] + fn equivalent_hex_case_and_independent_splice_order_share_one_operation_identity() { + let (_root, mut editor, _path, _owner) = setup(); + let revision = editor.draft().revision(); + let first = PatchBatch { + revision: revision.clone(), + splices: vec![ + crate::binary::draft::Splice { + offset: Offset(0), + delete_length: Offset(1), + expected_hex: "6F".into(), + insert_hex: "FF".into(), + }, + crate::binary::draft::Splice { + offset: Offset(7), + delete_length: Offset(1), + expected_hex: "6C".into(), + insert_hex: "EE".into(), + }, + ], + }; + let result = editor.patch("canonical_patch", &first).unwrap(); + let equivalent = PatchBatch { + revision, + splices: first + .splices + .iter() + .rev() + .cloned() + .map(|mut splice| { + splice.expected_hex.make_ascii_lowercase(); + splice.insert_hex.make_ascii_lowercase(); + splice + }) + .collect(), + }; + assert_eq!( + editor + .patch("canonical_patch", &equivalent) + .unwrap() + .revision, + result.revision + ); + assert_eq!(editor.record.operations.len(), 1); + } + #[test] + fn source_aliases_and_wrong_session_cannot_prepare_or_resume_mutation() { + let (root, editor, path, owner) = setup(); + drop(editor); + let store = Store::open(&root.path().join("data"), "bin_one").unwrap(); + assert!( + Editor::resume( + store, + &Owner { + session_id: "ses_other".into(), + directory: owner.directory.clone() + } + ) + .is_err() + ); + let alias = owner.directory.join("alias"); + std::os::unix::fs::symlink(&path, &alias).unwrap(); + let mut doc = Document::open(&owner.directory, "alias").unwrap(); + let store = Store::open(&root.path().join("data"), "bin_alias").unwrap(); + assert!(Editor::create(store, owner, &mut doc, || false).is_err()); + assert_eq!(std::fs::read(path).unwrap(), b"original"); + } + #[test] + fn durable_intent_after_exchange_recovers_acknowledgement_without_repeating_effect() { + let (root, mut editor, path, owner) = setup(); + patch(&mut editor, "patch_one"); + let ready = ready(&mut editor, "save_cut"); + editor.record.pending = Some(ready.intent); + persist(&mut editor.store, &editor.record).unwrap(); + let intent = editor.record.pending.as_ref().unwrap(); + native::exchange(editor.store.directory(), &intent.stage, &intent.prior).unwrap(); + let installed = Target::capture(&owner.directory, Path::new("bytes")) + .unwrap() + .file; + drop(editor); + let mut resumed = Editor::resume( + Store::open(&root.path().join("data"), "bin_one").unwrap(), + &owner, + ) + .unwrap(); + assert_eq!( + resumed.operation("save_cut").unwrap().outcome, + Outcome::Unknown + ); + assert_eq!(resumed.reconcile().unwrap().outcome, Outcome::Acknowledged); + assert_eq!(resumed.target().file, installed); + assert_eq!(std::fs::read(path).unwrap(), [0, 255, 128, 27]); + } + #[test] + fn an_unexecuted_or_uncertain_intent_preserves_evidence_and_is_never_automatically_retried() { + let (root, mut editor, path, owner) = setup(); + patch(&mut editor, "patch_one"); + let ready = ready(&mut editor, "save_cut"); + let stage = ready.intent.stage.clone(); + editor.record.pending = Some(ready.intent); + persist(&mut editor.store, &editor.record).unwrap(); + drop(editor); + let mut resumed = Editor::resume( + Store::open(&root.path().join("data"), "bin_one").unwrap(), + &owner, + ) + .unwrap(); + assert_eq!(resumed.reconcile().unwrap().outcome, Outcome::Unknown); + assert!(resumed.unresolved()); + assert_eq!(std::fs::read(path).unwrap(), b"original"); + assert!( + root.path() + .join("data/binary/bin_one") + .join(stage) + .is_file() + ); + let review = resumed.review(0, 128, || false).unwrap(); + assert!( + resumed + .prepare_save("another_save", &review.fingerprint, || false) + .is_err() + ); + } +} + +#[cfg(test)] +#[path = "editor/death_tests.rs"] +mod death_tests; diff --git a/crates/cyber-core/src/binary/editor/death_tests.rs b/crates/cyber-core/src/binary/editor/death_tests.rs new file mode 100644 index 00000000..81296b9a --- /dev/null +++ b/crates/cyber-core/src/binary/editor/death_tests.rs @@ -0,0 +1,205 @@ +//! Real process death cuts. Only the exact ignored child test reads these fixture +//! variables; the production editor has no environment-triggered crash behavior. +use super::*; +use crate::binary::draft::{PatchBatch, Splice}; +use std::path::Path; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +const ROOT: &str = "CYBER_BINARY_TEST_DEATH_ROOT"; +const CUT: &str = "CYBER_BINARY_TEST_DEATH_CUT"; +struct OwnedChild(Child); +impl Drop for OwnedChild { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} +fn owner(root: &Path) -> Owner { + Owner { + session_id: "ses_death".into(), + directory: root.join("project").canonicalize().unwrap(), + } +} +fn prepare(root: &Path) -> (Owner, String) { + std::fs::create_dir(root.join("data")).unwrap(); + std::fs::create_dir(root.join("project")).unwrap(); + std::fs::create_dir(root.join("home")).unwrap(); + std::fs::write(root.join("project/bytes"), b"original").unwrap(); + let owner = owner(root); + let mut document = Document::open(&owner.directory, "bytes").unwrap(); + let mut editor = Editor::create( + Store::open(&root.join("data"), "bin_death").unwrap(), + owner.clone(), + &mut document, + || false, + ) + .unwrap(); + editor + .patch( + "patch_death", + &PatchBatch { + revision: editor.draft().revision(), + splices: vec![Splice { + offset: Offset(0), + delete_length: Offset(8), + expected_hex: crate::binary::draft::encode_hex(b"original"), + insert_hex: "00ff801b".into(), + }], + }, + ) + .unwrap(); + let review = editor.review(0, 128, || false).unwrap(); + (owner, review.fingerprint) +} +fn kill_at(root: &Path, cut: &str) { + let mut child = OwnedChild( + Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "binary::editor::death_tests::death_child", + "--ignored", + "--nocapture", + "--test-threads=1", + ]) + .env_clear() + .env("PATH", std::env::var_os("PATH").unwrap_or_default()) + .env("HOME", root.join("home")) + .env(ROOT, root) + .env(CUT, cut) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::inherit()) + .spawn() + .unwrap(), + ); + let barrier = root.join("ready"); + let deadline = Instant::now() + Duration::from_secs(20); + while !barrier.exists() { + assert!( + child.0.try_wait().unwrap().is_none(), + "death child exited before {cut}" + ); + assert!(Instant::now() < deadline, "death child never reached {cut}"); + std::thread::sleep(Duration::from_millis(10)); + } + child.0.kill().unwrap(); + child.0.wait().unwrap(); +} + +#[test] +#[ignore = "run by native_process_death_retains_drafts_and_never_replays_exchange"] +fn death_child() { + let Some(root) = std::env::var_os(ROOT) else { + return; + }; + let root = PathBuf::from(root); + let cut = std::env::var(CUT).unwrap(); + let mut editor = Editor::resume( + Store::open(&root.join("data"), "bin_death").unwrap(), + &owner(&root), + ) + .unwrap(); + let review = editor.review(0, 128, || false).unwrap(); + let SavePreparation::Ready(ready) = editor + .prepare_save("death_save", &review.fingerprint, || false) + .unwrap() + else { + panic!("new operation") + }; + match cut.as_str() { + "prepared" => {} + "intent" | "exchange" => { + editor.record.pending = Some(ready.intent); + persist(&mut editor.store, &editor.record).unwrap(); + if cut == "exchange" { + let intent = editor.record.pending.as_ref().unwrap(); + native::exchange(editor.store.directory(), &intent.stage, &intent.prior).unwrap(); + } + } + "acknowledged" => { + assert_eq!( + editor.commit_save(*ready).unwrap().outcome, + Outcome::Acknowledged + ); + } + _ => panic!("unknown death cut"), + } + let marker = File::create(root.join("ready")).unwrap(); + marker.sync_all().unwrap(); + loop { + std::thread::park(); + } +} + +fn verify_death_cut(cut: &str) { + let root = tempfile::tempdir().unwrap(); + let (owner, review) = prepare(root.path()); + kill_at(root.path(), cut); + let mut editor = Editor::resume( + Store::open(&root.path().join("data"), "bin_death").unwrap(), + &owner, + ) + .unwrap(); + let source = std::fs::read(root.path().join("project/bytes")).unwrap(); + if cut == "prepared" { + assert!(!editor.unresolved()); + assert!(editor.operation("death_save").is_none()); + assert_eq!(source, b"original"); + assert_eq!(editor.draft().read(0, 4).unwrap(), [0, 255, 128, 27]); + return; + } + if cut == "intent" { + assert_eq!(editor.reconcile().unwrap().outcome, Outcome::Unknown); + assert_eq!(source, b"original"); + return; + } + let identity = Target::capture(&owner.directory, Path::new("bytes")) + .unwrap() + .file; + if cut == "exchange" { + assert_eq!(editor.reconcile().unwrap().outcome, Outcome::Acknowledged); + } + assert_eq!(source, [0, 255, 128, 27]); + assert_eq!( + editor.operation("death_save").unwrap().outcome, + Outcome::Acknowledged + ); + let SavePreparation::Existing(receipt) = editor + .prepare_save("death_save", &review, || false) + .unwrap() + else { + panic!("recovery repeated a native effect") + }; + assert_eq!(receipt.outcome, Outcome::Acknowledged); + assert_eq!(editor.target().file, identity); +} +#[test] +fn native_process_death_retains_drafts_and_never_replays_exchange() { + for cut in ["prepared", "intent", "exchange", "acknowledged"] { + verify_death_cut(cut); + } +} +#[test] +fn native_crash_recovery_preserves_an_external_post_exchange_change_and_its_evidence() { + let root = tempfile::tempdir().unwrap(); + let (owner, _) = prepare(root.path()); + kill_at(root.path(), "exchange"); + std::fs::write(root.path().join("project/bytes"), b"external").unwrap(); + let mut editor = Editor::resume( + Store::open(&root.path().join("data"), "bin_death").unwrap(), + &owner, + ) + .unwrap(); + assert_eq!(editor.reconcile().unwrap().outcome, Outcome::Unknown); + assert!(editor.unresolved()); + assert_eq!( + std::fs::read(root.path().join("project/bytes")).unwrap(), + b"external" + ); + let intent = editor.record.pending.as_ref().unwrap(); + assert_eq!( + editor.store.directory().read(&intent.stage).unwrap(), + b"original" + ); +} diff --git a/crates/cyber-core/src/binary/editor/restore.rs b/crates/cyber-core/src/binary/editor/restore.rs new file mode 100644 index 00000000..df921850 --- /dev/null +++ b/crates/cyber-core/src/binary/editor/restore.rs @@ -0,0 +1,272 @@ +//! Restore is a separately reviewed native operation. It does not use Session +//! history or silently replace an owner's unsaved draft. +use super::*; +use crate::binary::draft::{ChangeRange, encode_hex}; + +impl Editor { + pub fn restore_review(&self, cancelled: impl Fn() -> bool) -> Result { + self.available()?; + let current = self + .draft + .review(&self.draft.revision(), 0, 128, &cancelled)?; + if current.range_count != 0 { + return Err(Error::Changed); + } + let last = self.record.last_save.as_ref().ok_or(Error::Invalid)?; + let preimage = last.preimage.open(&self.store)?; + let changed = preimage.sha256() != self.draft.base().sha256(); + let original_length = self.draft.length(); + let proposed_length = Offset(preimage.length()); + let ranges = if changed { + vec![ChangeRange { + original_offset: Offset(0), + original_length, + draft_offset: Offset(0), + draft_length: proposed_length, + before_sha256: current.base_sha256.clone(), + after_sha256: preimage.sha256().into(), + before_hex: encode_hex(&self.draft.read(0, original_length.0.min(256) as usize)?), + after_hex: encode_hex(&preimage.read(0, preimage.length().min(256) as usize)?), + preview_truncated: original_length.0 > 256 || preimage.length() > 256, + }] + } else { + vec![] + }; + let difference = signature(&( + "restore-diff-v1", + ¤t.revision, + ¤t.base_sha256, + preimage.sha256(), + &last.operation, + ))?; + let effects = format!( + "Restore Last Save replaces the current acknowledged content with its retained preimage. {EFFECTS}" + ); + let fingerprint = signature(&( + "restore-review-v1", + &difference, + &self.record.target, + &effects, + ))?; + let meta = &self.record.target.metadata; + Ok(SaveReview { + draft: Review { + revision: current.revision, + base_sha256: current.base_sha256, + proposed_sha256: preimage.sha256().into(), + base_length: original_length, + proposed_length, + original_bytes: Offset(if changed { original_length.0 } else { 0 }), + proposed_bytes: Offset(if changed { preimage.length() } else { 0 }), + range_count: ranges.len(), + ranges, + next_range: None, + fingerprint: difference, + }, + permissions: meta.mode, + owner_uid: meta.uid, + owner_gid: meta.gid, + metadata_sha256: meta.evidence.clone(), + effects, + fingerprint, + }) + } + pub fn prepare_restore( + &mut self, + id: &str, + review: &str, + cancelled: impl Fn() -> bool, + ) -> Result { + let payload = signature(&("restore-v1", review))?; + if let Some(prior) = self.prior(id, &payload)? { + return Ok(SavePreparation::Existing(prior)); + } + self.available()?; + let current = self.restore_review(&cancelled)?; + if current.fingerprint != review { + return Err(Error::Changed); + } + let target = self.record.target.clone(); + let (_parent, source) = match target.validate() { + Ok(source) => source, + Err(Error::Changed | Error::File) => { + return self + .conflict(id, "restore", payload, "source_binding_changed") + .map(SavePreparation::Existing); + } + Err(error) => return Err(error), + }; + let last = self.record.last_save.as_ref().ok_or(Error::Invalid)?; + if native::content(&source, &cancelled)? != last.installed_sha256 { + return self + .conflict(id, "restore", payload, "source_content_changed") + .map(SavePreparation::Existing); + } + let base = last.preimage.open(&self.store)?; + let after_draft = Draft::new(base)?; + let (stage_name, mut stage) = self.store.stage(after_draft.length().0)?; + if after_draft.write_to(&mut stage, &cancelled)? != current.draft.proposed_sha256 { + return Err(Error::Changed); + } + native::copy_metadata(&source, &stage)?; + native::sync(&stage)?; + self.store.sync()?; + let after = Target { + root: target.root.clone(), + relative: target.relative.clone(), + parents: target.parents.clone(), + file: native::id(&stage)?, + length: after_draft.length(), + metadata: native::metadata(&stage)?, + observed: String::new(), + }; + let operation = Operation { + id: id.into(), + kind: "restore".into(), + payload_sha256: payload, + outcome: Outcome::Unknown, + revision: after_draft.revision(), + file_effect: FileEffect::Unknown, + reason: None, + notification_pending: false, + }; + let intent = Intent { + operation, + prior: target, + after, + new_base: BaseRef::of(after_draft.base()), + after_draft: after_draft.snapshot(), + stage: stage_name, + }; + Ok(SavePreparation::Ready(Box::new(PreparedSave { + intent, + after: after_draft, + expected_revision: self.draft.revision(), + }))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::binary::{ + draft::{PatchBatch, Splice}, + storage::Store, + }; + fn saved() -> (tempfile::TempDir, Editor, Owner) { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir(root.path().join("data")).unwrap(); + std::fs::create_dir(root.path().join("project")).unwrap(); + let owner = Owner { + session_id: "ses_restore".into(), + directory: root.path().join("project").canonicalize().unwrap(), + }; + std::fs::write(owner.directory.join("bytes"), [0, 255, 128, 27]).unwrap(); + let mut doc = Document::open(&owner.directory, "bytes").unwrap(); + let mut editor = Editor::create( + Store::open(&root.path().join("data"), "bin_restore").unwrap(), + owner.clone(), + &mut doc, + || false, + ) + .unwrap(); + editor + .patch( + "patch_one", + &PatchBatch { + revision: editor.draft().revision(), + splices: vec![Splice { + offset: Offset(1), + delete_length: Offset(2), + expected_hex: "ff80".into(), + insert_hex: "61626364".into(), + }], + }, + ) + .unwrap(); + let review = editor.review(0, 128, || false).unwrap(); + let SavePreparation::Ready(ready) = editor + .prepare_save("save_one", &review.fingerprint, || false) + .unwrap() + else { + panic!("save preparation") + }; + assert_eq!( + editor.commit_save(*ready).unwrap().outcome, + Outcome::Acknowledged + ); + (root, editor, owner) + } + #[test] + fn retained_binary_preimage_restores_after_restart_with_a_fresh_review_and_stable_receipt() { + let (root, editor, owner) = saved(); + assert_eq!( + std::fs::read(owner.directory.join("bytes")).unwrap(), + [0, b'a', b'b', b'c', b'd', 27] + ); + drop(editor); + let mut editor = Editor::resume( + Store::open(&root.path().join("data"), "bin_restore").unwrap(), + &owner, + ) + .unwrap(); + let review = editor.restore_review(|| false).unwrap(); + assert_eq!(review.draft.base_length.0, 6); + assert_eq!(review.draft.proposed_length.0, 4); + let SavePreparation::Ready(ready) = editor + .prepare_restore("restore_one", &review.fingerprint, || false) + .unwrap() + else { + panic!("restore preparation") + }; + let restored = editor.commit_save(*ready).unwrap(); + assert_eq!(restored.outcome, Outcome::Acknowledged); + assert_eq!(restored.kind, "restore"); + assert_eq!( + std::fs::read(owner.directory.join("bytes")).unwrap(), + [0, 255, 128, 27] + ); + let identity = editor.target().file.clone(); + let SavePreparation::Existing(receipt) = editor + .prepare_restore("restore_one", &review.fingerprint, || false) + .unwrap() + else { + panic!("retry repeated restore") + }; + assert_eq!(receipt.revision, restored.revision); + assert_eq!(editor.target().file, identity); + } + #[test] + fn restore_never_discards_a_dirty_draft_or_overwrites_external_bytes() { + let (_root, mut editor, owner) = saved(); + let review = editor.restore_review(|| false).unwrap(); + std::fs::write(owner.directory.join("bytes"), b"external").unwrap(); + let SavePreparation::Existing(op) = editor + .prepare_restore("restore_conflict", &review.fingerprint, || false) + .unwrap() + else { + panic!("external bytes accepted") + }; + assert_eq!(op.outcome, Outcome::Conflict); + assert_eq!( + std::fs::read(owner.directory.join("bytes")).unwrap(), + b"external" + ); + editor + .patch( + "patch_dirty", + &PatchBatch { + revision: editor.draft().revision(), + splices: vec![Splice { + offset: Offset(0), + delete_length: Offset(1), + expected_hex: "00".into(), + insert_hex: "ff".into(), + }], + }, + ) + .unwrap(); + assert!(editor.restore_review(|| false).is_err()); + assert_eq!(editor.draft().read(0, 1).unwrap(), [255]); + } +} diff --git a/crates/cyber-core/src/binary/mod.rs b/crates/cyber-core/src/binary/mod.rs index 9d11fd85..d072aabf 100644 --- a/crates/cyber-core/src/binary/mod.rs +++ b/crates/cyber-core/src/binary/mod.rs @@ -2,7 +2,13 @@ //! Authorization belongs to the caller; the source pins regular-file and directory //! identities across that authorization wait and every later read. +pub mod draft; +#[cfg(any(target_os = "macos", target_os = "linux"))] +pub mod editor; +#[cfg(any(target_os = "macos", target_os = "linux"))] +pub mod native; mod source; +pub mod storage; pub use source::Document; use schemars::JsonSchema; diff --git a/crates/cyber-core/src/binary/native.rs b/crates/cyber-core/src/binary/native.rs new file mode 100644 index 00000000..f1d38299 --- /dev/null +++ b/crates/cyber-core/src/binary/native.rs @@ -0,0 +1,519 @@ +//! Same-filesystem native replacement primitives. The save journal must be durable +//! before exchange; these functions alone never authorize or acknowledge a save. +use super::{Error, MAX_EDIT_SIZE, Offset}; +use cap_fs_ext::{DirExt, FollowSymlinks, OpenOptionsFollowExt}; +use cap_std::fs::{Dir, OpenOptions}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::ffi::CString; +use std::fs::File; +#[cfg(target_os = "macos")] +use std::os::macos::fs::MetadataExt as _; +use std::os::unix::fs::MetadataExt; +use std::path::{Component, Path, PathBuf}; + +#[cfg(target_os = "macos")] +#[allow(unsafe_code)] // Bounded owned ACL/copyfile-state allocations, no borrowed raw pointers. +mod macos; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Metadata { + pub mode: u32, + pub uid: u32, + pub gid: u32, + pub evidence: String, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Target { + pub root: PathBuf, + pub relative: PathBuf, + pub parents: Vec, + pub file: String, + pub length: Offset, + pub metadata: Metadata, + pub observed: String, +} + +pub(super) fn id(file: &File) -> Result { + let m = file.metadata().map_err(|_| Error::Io)?; + let mut hash = Sha256::new(); + hash.update(b"cyber-native-file-id-v1\0"); + hash.update(m.dev().to_le_bytes()); + hash.update(m.ino().to_le_bytes()); + Ok(format!("{:x}", hash.finalize())) +} +pub(super) fn attributes(file: &File) -> Result)>, Error> { + let mut names = vec![0u8; 65536]; + let size = rustix::fs::flistxattr(file, names.as_mut_slice()).map_err(|_| Error::File)?; + names.truncate(size); + let mut result = vec![]; + let mut total = 0usize; + for name in names.split(|b| *b == 0).filter(|part| !part.is_empty()) { + let name = CString::new(name).map_err(|_| Error::File)?; + // Privileged labels/capabilities are not silently recreated by ordinary edits. + #[cfg(target_os = "linux")] + if name.as_bytes().starts_with(b"security.") || name.as_bytes().starts_with(b"trusted.") { + return Err(Error::File); + } + if result.len() >= 128 { + return Err(Error::Limit); + } + let mut value = vec![0u8; 1024 * 1024]; + let size = rustix::fs::fgetxattr(file, name.as_c_str(), value.as_mut_slice()) + .map_err(|_| Error::File)?; + total = total.checked_add(size).ok_or(Error::Limit)?; + if total > 1024 * 1024 { + return Err(Error::Limit); + } + value.truncate(size); + value.shrink_to_fit(); + result.push((name, value)); + } + result.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes())); + Ok(result) +} +#[cfg(target_os = "macos")] +pub(super) fn private_acl(file: &File) -> Result<(), Error> { + let acl = macos::acl(file)?; + let text = std::str::from_utf8(&acl) + .map_err(|_| Error::File)? + .trim_end_matches('\0'); + if text + .lines() + .any(|line| !line.trim().is_empty() && !line.starts_with("!#acl ")) + { + return Err(Error::File); + } + Ok(()) +} + +pub fn metadata(file: &File) -> Result { + let m = file.metadata().map_err(|_| Error::Io)?; + if !m.is_file() || m.nlink() != 1 || m.mode() & 0o7000 != 0 || m.permissions().readonly() { + return Err(Error::File); + } + let mut hash = Sha256::new(); + hash.update(m.mode().to_le_bytes()); + hash.update(m.uid().to_le_bytes()); + hash.update(m.gid().to_le_bytes()); + #[cfg(target_os = "macos")] + { + hash.update(m.st_flags().to_le_bytes()); + hash.update(macos::acl(file)?); + } + for (name, value) in attributes(file)? { + hash.update((name.as_bytes().len() as u64).to_le_bytes()); + hash.update(name.as_bytes()); + hash.update((value.len() as u64).to_le_bytes()); + hash.update(value); + } + Ok(Metadata { + mode: m.mode() & 0o777, + uid: m.uid(), + gid: m.gid(), + evidence: format!("{:x}", hash.finalize()), + }) +} +pub fn copy_metadata(source: &File, destination: &File) -> Result<(), Error> { + let before = metadata(source)?; + #[cfg(target_os = "macos")] + macos::copy_metadata(source, destination)?; + #[cfg(target_os = "linux")] + { + rustix::fs::fchown( + destination, + Some(rustix::fs::Uid::from_raw(before.uid)), + Some(rustix::fs::Gid::from_raw(before.gid)), + ) + .map_err(|_| Error::Io)?; + rustix::fs::fchmod(destination, rustix::fs::Mode::from_raw_mode(before.mode)) + .map_err(|_| Error::Io)?; + for (name, value) in attributes(source)? { + rustix::fs::fsetxattr( + destination, + name.as_c_str(), + &value, + rustix::fs::XattrFlags::empty(), + ) + .map_err(|_| Error::Io)?; + } + } + if metadata(source)? != before || metadata(destination)? != before { + return Err(Error::Changed); + } + // A committed edit has a new modification time, including same-size edits. + // This touches only the private replacement, never the original inode. + destination + .set_modified(std::time::SystemTime::now()) + .map_err(|_| Error::Io)?; + Ok(()) +} + +/// Open canonical components without following aliases. Directory handles retain +/// routing authority; returned IDs are persisted so restart cannot rebind a draft. +fn open(root: &Path, relative: &Path, write: bool) -> Result<(Dir, File, Vec), Error> { + if !root.is_absolute() { + return Err(Error::Invalid); + } + let mut dir = + Dir::open_ambient_dir(root, cap_std::ambient_authority()).map_err(|_| Error::File)?; + let mut ids = vec![id(&dir + .try_clone() + .map_err(|_| Error::Io)? + .into_std_file())?]; + let mut parts = relative.components().peekable(); + while let Some(part) = parts.next() { + let Component::Normal(name) = part else { + return Err(Error::File); + }; + if parts.peek().is_some() { + dir = dir.open_dir_nofollow(name).map_err(|_| Error::File)?; + ids.push(id(&dir + .try_clone() + .map_err(|_| Error::Io)? + .into_std_file())?); + } else { + let observed = dir.symlink_metadata(name).map_err(|_| Error::File)?; + if !observed.is_file() || observed.file_type().is_symlink() { + return Err(Error::File); + } + let mut options = OpenOptions::new(); + options.read(true).write(write).follow(FollowSymlinks::No); + use cap_std::fs::OpenOptionsExt; + options.custom_flags(libc::O_NONBLOCK); + let file = dir + .open_with(name, &options) + .map_err(|_| Error::File)? + .into_std(); + metadata(&file)?; + return Ok((dir, file, ids)); + } + } + Err(Error::File) +} +impl Target { + pub fn capture(root: &Path, relative: &Path) -> Result { + let (_, file, parents) = open(root, relative, true)?; + let m = file.metadata().map_err(|_| Error::Io)?; + if m.len() > MAX_EDIT_SIZE { + return Err(Error::Limit); + } + Ok(Self { + root: root.into(), + relative: relative.into(), + parents, + file: id(&file)?, + length: Offset(m.len()), + metadata: metadata(&file)?, + observed: generation(&file)?, + }) + } + pub fn validate(&self) -> Result<(Dir, File), Error> { + let (parent, file, ids) = open(&self.root, &self.relative, true)?; + if ids != self.parents + || id(&file)? != self.file + || metadata(&file)? != self.metadata + || file.metadata().map_err(|_| Error::Io)?.len() != self.length.0 + || generation(&file)? != self.observed + { + return Err(Error::Changed); + } + Ok((parent, file)) + } + pub fn same_filesystem(&self, private: &Dir) -> Result<(), Error> { + let (_, file) = self.validate()?; + if file.metadata().map_err(|_| Error::Io)?.dev() + != private + .try_clone() + .map_err(|_| Error::Io)? + .into_std_file() + .metadata() + .map_err(|_| Error::Io)? + .dev() + { + return Err(Error::File); + } + Ok(()) + } + pub fn name(&self) -> Result<&std::ffi::OsStr, Error> { + self.relative.file_name().ok_or(Error::Invalid) + } +} + +pub(super) fn generation(file: &File) -> Result { + let m = file.metadata().map_err(|_| Error::Io)?; + let mut hash = Sha256::new(); + hash.update(b"cyber-native-generation-v1\0"); + hash.update(m.len().to_le_bytes()); + hash.update(m.mtime().to_le_bytes()); + hash.update(m.mtime_nsec().to_le_bytes()); + hash.update(m.ctime().to_le_bytes()); + hash.update(m.ctime_nsec().to_le_bytes()); + Ok(format!("{:x}", hash.finalize())) +} + +/// Exact bounded content evidence through a retained regular-file descriptor. +pub fn content(file: &File, cancelled: impl Fn() -> bool) -> Result { + use std::os::unix::fs::FileExt; + if cancelled() { + return Err(Error::Cancelled); + } + let before = file.metadata().map_err(|_| Error::Io)?; + if !before.is_file() || before.len() > MAX_EDIT_SIZE { + return Err(Error::File); + } + let mut hash = Sha256::new(); + let mut at = 0; + let mut bytes = vec![0u8; super::DEFAULT_PAGE]; + while at < before.len() { + if cancelled() { + return Err(Error::Cancelled); + } + let size = (before.len() - at).min(bytes.len() as u64) as usize; + file.read_exact_at(&mut bytes[..size], at) + .map_err(|_| Error::Changed)?; + hash.update(&bytes[..size]); + at += size as u64; + } + let after = file.metadata().map_err(|_| Error::Io)?; + if ( + before.len(), + before.mtime(), + before.mtime_nsec(), + before.ctime(), + before.ctime_nsec(), + ) != ( + after.len(), + after.mtime(), + after.mtime_nsec(), + after.ctime(), + after.ctime_nsec(), + ) { + return Err(Error::Changed); + } + Ok(format!("{:x}", hash.finalize())) +} + +pub(super) fn sync(file: &File) -> Result<(), Error> { + file.sync_all().map_err(|_| Error::Io)?; + #[cfg(target_os = "macos")] + rustix::fs::fcntl_fullfsync(file).map_err(|_| Error::Io)?; + Ok(()) +} + +pub fn exchange(private: &Dir, stage: &str, target: &Target) -> Result<(), Error> { + if stage.is_empty() + || stage.len() > 100 + || stage == "." + || stage == ".." + || !stage + .bytes() + .all(|c| c.is_ascii_alphanumeric() || c == b'_' || c == b'-' || c == b'.') + { + return Err(Error::Invalid); + } + let (parent, _file) = target.validate()?; + target.same_filesystem(private)?; + rustix::fs::renameat_with( + private, + stage, + &parent, + target.name()?, + rustix::fs::RenameFlags::EXCHANGE, + ) + .map_err(|_| Error::Io) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::fs::{PermissionsExt, symlink}; + fn setup() -> (tempfile::TempDir, Target, Dir) { + let root = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join("target"), b"before").unwrap(); + std::fs::set_permissions( + root.path().join("target"), + std::fs::Permissions::from_mode(0o750), + ) + .unwrap(); + std::fs::create_dir(root.path().join("private")).unwrap(); + std::fs::set_permissions( + root.path().join("private"), + std::fs::Permissions::from_mode(0o700), + ) + .unwrap(); + let private = + Dir::open_ambient_dir(root.path().join("private"), cap_std::ambient_authority()) + .unwrap(); + let target = Target::capture(root.path(), Path::new("target")).unwrap(); + (root, target, private) + } + #[test] + fn exchange_installs_exact_new_inode_preserves_displaced_bytes_and_metadata() { + let (root, target, private) = setup(); + let (_, original) = target.validate().unwrap(); + #[cfg(target_os = "macos")] + let attribute = "com.cybercode.binary-test"; + #[cfg(target_os = "linux")] + let attribute = "user.cybercode-binary-test"; + rustix::fs::fsetxattr( + &original, + attribute, + b"preserved", + rustix::fs::XattrFlags::empty(), + ) + .unwrap(); + let target = Target::capture(root.path(), Path::new("target")).unwrap(); + std::fs::write(root.path().join("private/replacement"), b"after!").unwrap(); + let staged = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(root.path().join("private/replacement")) + .unwrap(); + copy_metadata(&original, &staged).unwrap(); + staged.sync_all().unwrap(); + let installed = id(&staged).unwrap(); + exchange(&private, "replacement", &target).unwrap(); + let (parent, now, _) = open(root.path(), Path::new("target"), false).unwrap(); + assert_eq!(id(&now).unwrap(), installed); + assert_eq!(metadata(&now).unwrap(), target.metadata); + assert_eq!(parent.read("target").unwrap(), b"after!"); + assert_eq!(private.read("replacement").unwrap(), b"before"); + assert!(target.validate().is_err()); + assert_eq!(id(&original).unwrap(), target.file); + } + #[test] + fn symlinks_hardlinks_readonly_and_parent_replacement_do_not_widen_mutation() { + let (root, target, private) = setup(); + symlink(root.path().join("target"), root.path().join("alias")).unwrap(); + assert!(Target::capture(root.path(), Path::new("alias")).is_err()); + std::fs::hard_link(root.path().join("target"), root.path().join("hardlink")).unwrap(); + assert!(Target::capture(root.path(), Path::new("target")).is_err()); + std::fs::remove_file(root.path().join("hardlink")).unwrap(); + std::fs::set_permissions( + root.path().join("target"), + std::fs::Permissions::from_mode(0o444), + ) + .unwrap(); + assert!(Target::capture(root.path(), Path::new("target")).is_err()); + std::fs::set_permissions( + root.path().join("target"), + std::fs::Permissions::from_mode(0o750), + ) + .unwrap(); + std::fs::write(root.path().join("private/replacement"), b"after!").unwrap(); + std::fs::rename( + root.path().join("target"), + root.path().join("external-original"), + ) + .unwrap(); + std::fs::write(root.path().join("target"), b"external").unwrap(); + assert!(exchange(&private, "replacement", &target).is_err()); + assert_eq!( + std::fs::read(root.path().join("target")).unwrap(), + b"external" + ); + assert_eq!(private.read("replacement").unwrap(), b"after!"); + } + #[test] + fn moved_parent_is_refused_instead_of_reopening_the_new_route() { + let (root, _, private) = setup(); + std::fs::create_dir(root.path().join("nested")).unwrap(); + std::fs::write(root.path().join("nested/target"), b"before").unwrap(); + let target = Target::capture(root.path(), Path::new("nested/target")).unwrap(); + std::fs::write(root.path().join("private/replacement"), b"after!").unwrap(); + std::fs::rename(root.path().join("nested"), root.path().join("moved")).unwrap(); + std::fs::create_dir(root.path().join("nested")).unwrap(); + std::fs::write(root.path().join("nested/target"), b"external").unwrap(); + assert!(exchange(&private, "replacement", &target).is_err()); + assert_eq!( + std::fs::read(root.path().join("moved/target")).unwrap(), + b"before" + ); + assert_eq!( + std::fs::read(root.path().join("nested/target")).unwrap(), + b"external" + ); + } + #[cfg(target_os = "linux")] + #[test] + fn native_linux_posix_acl_is_fingerprinted_and_preserved_without_data_copy() { + let (root, target, _private) = setup(); + let before = target.metadata; + let source = File::open(root.path().join("target")).unwrap(); + // Linux POSIX ACL xattr, version 2: owner, named user, group, mask, other. + let mut acl = 2u32.to_le_bytes().to_vec(); + let named = if source.metadata().unwrap().uid() == 65534 { + 65533 + } else { + 65534 + }; + for (tag, permissions, uid) in [ + (1u16, 6u16, u32::MAX), + (2, 4, named), + (4, 0, u32::MAX), + (16, 4, u32::MAX), + (32, 0, u32::MAX), + ] { + acl.extend(tag.to_le_bytes()); + acl.extend(permissions.to_le_bytes()); + acl.extend(uid.to_le_bytes()); + } + rustix::fs::fsetxattr( + &source, + "system.posix_acl_access", + &acl, + rustix::fs::XattrFlags::empty(), + ) + .unwrap(); + let target = Target::capture(root.path(), Path::new("target")).unwrap(); + assert_ne!(target.metadata.evidence, before.evidence); + let (_, source) = target.validate().unwrap(); + std::fs::write(root.path().join("private/replacement"), b"changed data").unwrap(); + let destination = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(root.path().join("private/replacement")) + .unwrap(); + copy_metadata(&source, &destination).unwrap(); + assert_eq!(metadata(&destination).unwrap(), target.metadata); + let mut actual = vec![0; acl.len()]; + let size = rustix::fs::fgetxattr(&destination, "system.posix_acl_access", &mut actual[..]) + .unwrap(); + assert_eq!(&actual[..size], acl); + assert_eq!( + std::fs::read(root.path().join("private/replacement")).unwrap(), + b"changed data" + ); + } + + #[cfg(target_os = "macos")] + #[test] + fn native_mac_extended_acl_is_fingerprinted_and_preserved_without_data_copy() { + let (root, target, _private) = setup(); + let before = target.metadata; + let status = std::process::Command::new("/bin/chmod") + .args(["+a", "everyone allow read"]) + .arg(root.path().join("target")) + .status() + .unwrap(); + assert!(status.success()); + let target = Target::capture(root.path(), Path::new("target")).unwrap(); + assert_ne!(target.metadata.evidence, before.evidence); + let (_, source) = target.validate().unwrap(); + std::fs::write(root.path().join("private/replacement"), b"changed data").unwrap(); + let destination = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(root.path().join("private/replacement")) + .unwrap(); + copy_metadata(&source, &destination).unwrap(); + assert_eq!(metadata(&destination).unwrap(), target.metadata); + assert_eq!( + std::fs::read(root.path().join("private/replacement")).unwrap(), + b"changed data" + ); + } +} diff --git a/crates/cyber-core/src/binary/native/macos.rs b/crates/cyber-core/src/binary/native/macos.rs new file mode 100644 index 00000000..3fb1d9d4 --- /dev/null +++ b/crates/cyber-core/src/binary/native/macos.rs @@ -0,0 +1,65 @@ +//! Small owned wrappers for macOS ACL evidence and metadata-only copying. +//! No caller pointer is accepted; all native allocations are freed on every path. +use super::*; +use std::ffi::{c_char, c_int, c_uint, c_void}; +use std::os::fd::AsRawFd; + +unsafe extern "C" { + fn acl_get_fd_np(fd: c_int, kind: c_uint) -> *mut c_void; + fn acl_to_text(acl: *mut c_void, length: *mut isize) -> *mut c_char; + fn acl_free(object: *mut c_void) -> c_int; +} +struct Allocation(*mut c_void); +impl Drop for Allocation { + fn drop(&mut self) { + unsafe { + acl_free(self.0); + } + } +} + +pub(super) fn acl(file: &File) -> Result, Error> { + // ACL_TYPE_EXTENDED from the platform sys/acl.h. The File owns a live descriptor. + let raw = unsafe { acl_get_fd_np(file.as_raw_fd(), 0x100) }; + if raw.is_null() { + let error = std::io::Error::last_os_error(); + if error.raw_os_error() == Some(libc::ENOENT) { + return Ok(vec![]); + } + return Err(Error::Io); + } + let _acl = Allocation(raw); + let mut length = 0isize; + // The ACL remains owned and valid until after this bounded text copy. + let text = unsafe { acl_to_text(raw, &mut length) }; + if text.is_null() { + return Err(Error::Io); + } + let _text = Allocation(text.cast()); + if !(0..=65536).contains(&length) { + return Err(Error::Limit); + } + // acl_to_text allocated at least the reported length; no unbounded C-string scan. + Ok(unsafe { std::slice::from_raw_parts(text.cast::(), length as usize) }.to_vec()) +} + +pub(super) fn copy_metadata(source: &File, destination: &File) -> Result<(), Error> { + struct State(rustix::fs::copyfile_state_t); + impl Drop for State { + fn drop(&mut self) { + let _ = unsafe { rustix::fs::copyfile_state_free(self.0) }; + } + } + let state = State(rustix::fs::copyfile_state_alloc().map_err(|_| Error::Io)?); + // This state was allocated above, is exclusively owned, and is freed only after + // this synchronous call. METADATA copies no file data or caller buffers. + unsafe { + rustix::fs::fcopyfile( + source, + destination, + state.0, + rustix::fs::CopyfileFlags::METADATA, + ) + } + .map_err(|_| Error::Io) +} diff --git a/crates/cyber-core/src/binary/source.rs b/crates/cyber-core/src/binary/source.rs index 999f431e..31f5603b 100644 --- a/crates/cyber-core/src/binary/source.rs +++ b/crates/cyber-core/src/binary/source.rs @@ -102,6 +102,63 @@ impl Document { Ok(doc) } + /// Preparation requires the caller's fresh read+edit admission. It refuses + /// mutation aliases and creates an immutable private base, never changing the target. + #[cfg(any(target_os = "macos", target_os = "linux"))] + pub fn prepare( + &mut self, + store: &super::storage::Store, + cancelled: impl Fn() -> bool, + ) -> Result<(super::storage::Base, super::native::Target), Error> { + if cancelled() { + return Err(Error::Cancelled); + } + self.validate()?; + if Path::new(&self.requested) != self.relative { + return Err(Error::File); + } + let target = super::native::Target::capture(&self.root, &self.relative)?; + if super::native::id(self.identity.as_file())? != target.file { + return Err(Error::Changed); + } + let parents: Result, _> = self + .directories + .iter() + .map(|handle| super::native::id(handle.as_file())) + .collect(); + if parents? != target.parents { + return Err(Error::Changed); + } + target.same_filesystem(store.directory())?; + let base = store.base_cancellable( + self.length, + |writer| { + let mut hash = Sha256::new(); + let mut at = 0; + while at < self.length { + if cancelled() { + return Err(Error::Cancelled); + } + let size = (self.length - at).min(DEFAULT_PAGE as u64) as usize; + let bytes = self.read_at(at, size)?; + if bytes.len() != size { + return Err(Error::Changed); + } + writer.write_all(&bytes).map_err(|_| Error::Io)?; + hash.update(&bytes); + at += size as u64; + } + self.validate()?; + target.validate()?; + Ok(format!("{:x}", hash.finalize())) + }, + &cancelled, + )?; + self.validate()?; + target.validate()?; + Ok((base, target)) + } + pub fn path(&self) -> PathBuf { self.root.join(&self.relative) } diff --git a/crates/cyber-core/src/binary/storage.rs b/crates/cyber-core/src/binary/storage.rs new file mode 100644 index 00000000..557478a8 --- /dev/null +++ b/crates/cyber-core/src/binary/storage.rs @@ -0,0 +1,829 @@ +//! Private, pinned draft storage. Atomic publication failures fence the store; +//! reopening requires recovery instead of assuming an interrupted write rolled back. +use super::{DEFAULT_PAGE, Error, MAX_EDIT_SIZE, draft::ImmutableBase}; +use cap_fs_ext::DirExt; +#[cfg(not(windows))] +use cap_fs_ext::{FollowSymlinks, OpenOptionsFollowExt}; +use cap_std::fs::Dir; +#[cfg(not(windows))] +use cap_std::fs::OpenOptions; +use sha2::{Digest, Sha256}; +use std::fs::File; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +pub const MAX_STORAGE: u64 = 2 * 1024 * 1024 * 1024; +const MAX_RECORD: u64 = 256 * 1024 * 1024; + +pub struct Store { + data_path: PathBuf, + data: Dir, + root: Dir, + dir: Arc, + id: String, + _lock: File, + fenced: bool, + #[cfg(test)] + pub(super) fail_after_publication: Option, + #[cfg(test)] + pub(super) publications: u64, +} + +fn component(value: &str) -> Result<(), Error> { + if value.is_empty() + || value.len() > 100 + || !value + .bytes() + .all(|c| c.is_ascii_alphanumeric() || c == b'_' || c == b'-' || c == b'.') + || value == "." + || value == ".." + { + return Err(Error::Invalid); + } + Ok(()) +} +#[cfg(not(windows))] +fn identity(dir: &Dir) -> Result { + same_file::Handle::from_file(dir.try_clone().map_err(|_| Error::Io)?.into_std_file()) + .map_err(|_| Error::Io) +} + +#[cfg(windows)] +fn identity(dir: &Dir) -> Result { + crate::memory::windows::identity(&dir.try_clone().map_err(|_| Error::Io)?.into_std_file()) + .map_err(|_| Error::File) +} + +#[cfg(unix)] +fn private(dir: &Dir, file: &File, directory: bool) -> Result<(), Error> { + use std::os::unix::fs::MetadataExt; + let owner = dir + .try_clone() + .map_err(|_| Error::Io)? + .into_std_file() + .metadata() + .map_err(|_| Error::Io)?; + let m = file.metadata().map_err(|_| Error::Io)?; + #[cfg(target_os = "macos")] + super::native::private_acl(file)?; + if m.uid() != owner.uid() + || m.mode() & 0o077 != 0 + || m.is_dir() != directory + || (!directory && (!m.is_file() || m.nlink() != 1)) + { + return Err(Error::File); + } + Ok(()) +} +#[cfg(windows)] +fn private(_dir: &Dir, file: &File, directory: bool) -> Result<(), Error> { + crate::memory::windows::verify_private(file).map_err(|_| Error::File)?; + if file.metadata().map_err(|_| Error::Io)?.is_dir() != directory { + return Err(Error::File); + } + Ok(()) +} +#[cfg(not(any(unix, windows)))] +fn private(_dir: &Dir, _file: &File, _directory: bool) -> Result<(), Error> { + Err(Error::File) +} + +fn child(parent: &Dir, name: &str) -> Result { + component(name)?; + #[cfg(windows)] + { + use crate::memory::{MemoryStorageError, windows as native}; + let owner = parent.try_clone().map_err(|_| Error::Io)?.into_std_file(); + match native::open_private_directory(&owner, name, native::Access::DataWrite) { + Ok(file) => Ok(Dir::from_std_file(file)), + Err(MemoryStorageError::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => { + let created = + native::create_private_directory(&owner, name).map_err(|_| Error::File)?; + native::verify_private(&created).map_err(|_| Error::File)?; + let expected = native::identity(&created).map_err(|_| Error::File)?; + drop(created); + let file = native::open_private_directory(&owner, name, native::Access::DataWrite) + .map_err(|_| Error::File)?; + if native::identity(&file).map_err(|_| Error::File)? != expected { + return Err(Error::Changed); + } + Ok(Dir::from_std_file(file)) + } + Err(_) => Err(Error::File), + } + } + #[cfg(not(windows))] + { + let mut builder = cap_std::fs::DirBuilder::new(); + #[cfg(unix)] + { + use cap_std::fs::DirBuilderExt; + builder.mode(0o700); + } + match parent.create_dir_with(name, &builder) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(_) => return Err(Error::Io), + } + let dir = parent.open_dir_nofollow(name).map_err(|_| Error::File)?; + private( + parent, + &dir.try_clone().map_err(|_| Error::Io)?.into_std_file(), + true, + )?; + Ok(dir) + } +} +fn create(dir: &Dir, name: &str) -> Result { + component(name)?; + #[cfg(windows)] + { + return crate::memory::windows::create_private_file( + &dir.try_clone().map_err(|_| Error::Io)?.into_std_file(), + name, + ) + .map_err(|_| Error::Io); + } + #[cfg(not(windows))] + { + let mut options = OpenOptions::new(); + options + .read(true) + .write(true) + .create_new(true) + .follow(FollowSymlinks::No); + #[cfg(unix)] + { + use cap_std::fs::OpenOptionsExt; + options.mode(0o600); + } + let file = dir + .open_with(name, &options) + .map_err(|_| Error::Io)? + .into_std(); + private(dir, &file, false)?; + Ok(file) + } +} +fn read_file(dir: &Dir, name: &str) -> Result { + component(name)?; + #[cfg(windows)] + { + return crate::memory::windows::open_private_file( + &dir.try_clone().map_err(|_| Error::Io)?.into_std_file(), + name, + crate::memory::windows::Access::Read, + ) + .map_err(|_| Error::File); + } + #[cfg(not(windows))] + { + let mut options = OpenOptions::new(); + options.read(true).follow(FollowSymlinks::No); + #[cfg(unix)] + { + use cap_std::fs::OpenOptionsExt; + options.custom_flags(libc::O_NONBLOCK); + } + let file = dir + .open_with(name, &options) + .map_err(|_| Error::File)? + .into_std(); + private(dir, &file, false)?; + Ok(file) + } +} +fn flush_file(file: &File) -> Result<(), Error> { + file.sync_all().map_err(|_| Error::Io)?; + #[cfg(target_os = "macos")] + rustix::fs::fcntl_fullfsync(file).map_err(|_| Error::Io)?; + Ok(()) +} +fn flush_dir(dir: &Dir) -> Result<(), Error> { + let file = dir.try_clone().map_err(|_| Error::Io)?.into_std_file(); + #[cfg(windows)] + { + return crate::memory::windows::sync_private(&file).map_err(|_| Error::Io); + } + #[cfg(not(windows))] + { + file.sync_all().map_err(|_| Error::Io) + } +} + +impl Store { + pub(super) fn directory(&self) -> &Dir { + &self.dir + } + #[cfg(any(target_os = "macos", target_os = "linux"))] + pub(super) fn stage(&self, length: u64) -> Result<(String, File), Error> { + self.validate()?; + if length > MAX_EDIT_SIZE { + return Err(Error::Limit); + } + self.capacity(length)?; + let name = crate::ids::new_id("stage"); + let file = create(&self.dir, &name)?; + Ok((name, file)) + } + #[cfg(any(target_os = "macos", target_os = "linux"))] + pub(super) fn sync(&self) -> Result<(), Error> { + self.validate()?; + flush_dir(&self.dir) + } + + pub fn open(data: &Path, id: &str) -> Result { + component(id)?; + if !data.is_absolute() { + return Err(Error::Invalid); + } + let data_path = data.canonicalize().map_err(|_| Error::File)?; + let data = Dir::open_ambient_dir(&data_path, cap_std::ambient_authority()) + .map_err(|_| Error::File)?; + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if data + .try_clone() + .map_err(|_| Error::Io)? + .into_std_file() + .metadata() + .map_err(|_| Error::Io)? + .mode() + & 0o022 + != 0 + { + return Err(Error::File); + } + } + let root = child(&data, "binary")?; + let dir = child(&root, id)?; + #[cfg(windows)] + let lock = { + let owner = dir.try_clone().map_err(|_| Error::Io)?.into_std_file(); + match crate::memory::windows::create_private_file(&owner, "lock") { + Ok(created) => drop(created), + Err(crate::memory::MemoryStorageError::Io(e)) + if e.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(_) => return Err(Error::File), + } + crate::memory::windows::open_pinned_private_file(&owner, "lock") + .map_err(|_| Error::File)? + }; + #[cfg(not(windows))] + let lock = match create(&dir, "lock") { + Ok(f) => f, + Err(_) => { + let mut options = OpenOptions::new(); + options.read(true).write(true).follow(FollowSymlinks::No); + let file = dir + .open_with("lock", &options) + .map_err(|_| Error::File)? + .into_std(); + private(&dir, &file, false)?; + file + } + }; + lock.try_lock().map_err(|_| Error::Limit)?; + flush_dir(&dir)?; + flush_dir(&root)?; + flush_dir(&data)?; + let store = Self { + data_path, + data, + root, + dir: Arc::new(dir), + id: id.into(), + _lock: lock, + fenced: false, + #[cfg(test)] + fail_after_publication: None, + #[cfg(test)] + publications: 0, + }; + store.validate()?; + Ok(store) + } + pub fn validate(&self) -> Result<(), Error> { + if self.fenced { + return Err(Error::Changed); + } + let now = Dir::open_ambient_dir(&self.data_path, cap_std::ambient_authority()) + .map_err(|_| Error::Changed)?; + if identity(&now)? != identity(&self.data)? { + return Err(Error::Changed); + } + let root = now + .open_dir_nofollow("binary") + .map_err(|_| Error::Changed)?; + let dir = root + .open_dir_nofollow(&self.id) + .map_err(|_| Error::Changed)?; + if identity(&root)? != identity(&self.root)? || identity(&dir)? != identity(&self.dir)? { + return Err(Error::Changed); + } + private( + &self.data, + &root.try_clone().map_err(|_| Error::Io)?.into_std_file(), + true, + )?; + private( + &root, + &dir.try_clone().map_err(|_| Error::Io)?.into_std_file(), + true, + )?; + Ok(()) + } + fn capacity(&self, additional: u64) -> Result<(), Error> { + let mut used = 0u64; + for (count, entry) in self.dir.entries().map_err(|_| Error::Io)?.enumerate() { + if count >= 4096 { + return Err(Error::Limit); + } + let entry = entry.map_err(|_| Error::Io)?; + let metadata = self + .dir + .symlink_metadata(entry.file_name()) + .map_err(|_| Error::Io)?; + if !metadata.is_file() || metadata.file_type().is_symlink() { + return Err(Error::File); + } + used = used.checked_add(metadata.len()).ok_or(Error::Limit)?; + } + if used.checked_add(additional).is_none_or(|n| n > MAX_STORAGE) { + return Err(Error::Limit); + } + #[cfg(unix)] + { + let stat = rustix::fs::fstatvfs(self.dir.as_ref()).map_err(|_| Error::Io)?; + let available = stat.f_bavail.saturating_mul(stat.f_frsize); + if available < additional.saturating_add(1024 * 1024) { + return Err(Error::Limit); + } + } + Ok(()) + } + /// Publishes one private record. An error after rename fences further writes; + /// callers must reopen/reconcile the durable record before continuing. + pub fn record( + &mut self, + name: &str, + limit: u64, + write: impl FnOnce(&mut dyn Write) -> Result<(), Error>, + ) -> Result<(), Error> { + self.validate()?; + component(name)?; + if !name.ends_with(".json") { + return Err(Error::Invalid); + } + if let Ok(metadata) = self.dir.symlink_metadata(name) { + if !metadata.is_file() || metadata.file_type().is_symlink() { + return Err(Error::File); + } + let _existing = read_file(&self.dir, name)?; + } + if limit > MAX_RECORD { + return Err(Error::Limit); + } + self.capacity(limit)?; + let temporary = format!("record-{}", crate::ids::new_id("tmp")); + let mut file = create(&self.dir, &temporary)?; + write(&mut Limited { + writer: &mut file, + remaining: limit, + })?; + if file.metadata().map_err(|_| Error::Io)?.len() > MAX_RECORD { + return Err(Error::Limit); + } + flush_file(&file)?; + self.validate()?; + self.dir + .rename(&temporary, &self.dir, name) + .map_err(|_| Error::Io)?; + // The namespace effect may already be durable even if the next flush fails. + self.fenced = true; + #[cfg(test)] + { + self.publications += 1; + if self.fail_after_publication == Some(self.publications) { + return Err(Error::Io); + } + } + flush_dir(&self.dir)?; + self.fenced = false; + self.validate() + } + pub fn read_record(&self, name: &str) -> Result, Error> { + self.validate()?; + let file = read_file(&self.dir, name)?; + if file.metadata().map_err(|_| Error::Io)?.len() > MAX_RECORD { + return Err(Error::Limit); + } + let mut bytes = Vec::new(); + file.take(MAX_RECORD + 1) + .read_to_end(&mut bytes) + .map_err(|_| Error::Io)?; + if bytes.len() as u64 > MAX_RECORD { + return Err(Error::Limit); + } + self.validate()?; + Ok(bytes) + } + /// Captures into a newly created private object, then retains read-only access. + /// The producer must check source authority/identity before and after streaming. + pub fn base( + &self, + length: u64, + copy: impl FnOnce(&mut dyn Write) -> Result, + ) -> Result { + self.base_cancellable(length, copy, || false) + } + pub fn base_cancellable( + &self, + length: u64, + copy: impl FnOnce(&mut dyn Write) -> Result, + cancelled: impl Fn() -> bool, + ) -> Result { + if cancelled() { + return Err(Error::Cancelled); + } + self.validate()?; + if length > MAX_EDIT_SIZE { + return Err(Error::Limit); + } + self.capacity(length)?; + let name = crate::ids::new_id("base"); + let mut file = create(&self.dir, &name)?; + let digest = copy(&mut Limited { + writer: &mut file, + remaining: length, + })?; + if file.metadata().map_err(|_| Error::Io)?.len() != length { + return Err(Error::Changed); + } + flush_file(&file)?; + flush_dir(&self.dir)?; + drop(file); + let base = self.open_base_cancellable(&name, length, &digest, cancelled)?; + self.validate()?; + Ok(base) + } + pub fn open_base(&self, name: &str, length: u64, digest: &str) -> Result { + self.open_base_cancellable(name, length, digest, || false) + } + pub fn open_base_cancellable( + &self, + name: &str, + length: u64, + digest: &str, + cancelled: impl Fn() -> bool, + ) -> Result { + if cancelled() { + return Err(Error::Cancelled); + } + self.validate()?; + if length > MAX_EDIT_SIZE { + return Err(Error::Limit); + } + let file = read_file(&self.dir, name)?; + if file.metadata().map_err(|_| Error::Io)?.len() != length { + return Err(Error::Changed); + } + #[cfg(windows)] + let file = { + let expected = crate::memory::windows::identity(&file).map_err(|_| Error::File)?; + drop(file); + crate::memory::windows::freeze_private_file( + &self.dir.try_clone().map_err(|_| Error::Io)?.into_std_file(), + name, + expected, + ) + .map_err(|_| Error::File)? + }; + let before = generation(&file)?; + let mut observed = Sha256::new(); + let mut at = 0; + while at < length { + if cancelled() { + return Err(Error::Cancelled); + } + let take = (length - at).min(DEFAULT_PAGE as u64) as usize; + let bytes = positioned(&file, at, take)?; + observed.update(&bytes); + at += take as u64; + } + if format!("{:x}", observed.finalize()) != digest { + return Err(Error::Changed); + } + let observed = generation(&file)?; + if before != observed { + return Err(Error::Changed); + } + Ok(Base { + inner: Arc::new(BaseInner { + file, + _dir: self.dir.clone(), + name: name.into(), + length, + digest: digest.into(), + generation: observed, + }), + }) + } +} +fn positioned(file: &File, offset: u64, length: usize) -> Result, Error> { + let mut bytes = vec![0; length]; + let mut done = 0; + while done < length { + #[cfg(unix)] + let count = { + use std::os::unix::fs::FileExt; + file.read_at(&mut bytes[done..], offset + done as u64) + }; + #[cfg(windows)] + let count = { + use std::os::windows::fs::FileExt; + file.seek_read(&mut bytes[done..], offset + done as u64) + }; + #[cfg(not(any(unix, windows)))] + let count: std::io::Result = Err(std::io::ErrorKind::Unsupported.into()); + let count = count.map_err(|_| Error::Io)?; + if count == 0 { + return Err(Error::Changed); + } + done += count; + } + Ok(bytes) +} +#[derive(Clone)] +pub struct Base { + inner: Arc, +} +struct BaseInner { + file: File, + _dir: Arc, + name: String, + length: u64, + digest: String, + generation: String, +} +impl Base { + pub fn name(&self) -> &str { + &self.inner.name + } +} +impl ImmutableBase for Base { + fn length(&self) -> u64 { + self.inner.length + } + fn sha256(&self) -> &str { + &self.inner.digest + } + fn read(&self, offset: u64, length: usize) -> Result, Error> { + if length > super::MAX_PAGE + || offset + .checked_add(length as u64) + .is_none_or(|n| n > self.length()) + { + return Err(Error::Limit); + } + if generation(&self.inner.file)? != self.inner.generation { + return Err(Error::Changed); + } + let bytes = positioned(&self.inner.file, offset, length)?; + if generation(&self.inner.file)? != self.inner.generation { + return Err(Error::Changed); + } + Ok(bytes) + } +} + +fn generation(file: &File) -> Result { + let m = file.metadata().map_err(|_| Error::Io)?; + let mut hash = Sha256::new(); + hash.update(m.len().to_le_bytes()); + hash.update(format!("{:?}", m.modified().map_err(|_| Error::Io)?)); + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + hash.update(m.ctime().to_le_bytes()); + hash.update(m.ctime_nsec().to_le_bytes()); + hash.update(m.mode().to_le_bytes()); + hash.update(m.nlink().to_le_bytes()); + } + Ok(format!("{:x}", hash.finalize())) +} +struct Limited<'a> { + writer: &'a mut File, + remaining: u64, +} +impl Write for Limited<'_> { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + if bytes.len() as u64 > self.remaining { + return Err(std::io::Error::other("binary storage limit")); + } + let written = self.writer.write(bytes)?; + self.remaining -= written as u64; + Ok(written) + } + fn flush(&mut self) -> std::io::Result<()> { + self.writer.flush() + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn capture(store: &Store, bytes: &[u8]) -> Base { + store + .base(bytes.len() as u64, |writer| { + writer.write_all(bytes).map_err(|_| Error::Io)?; + Ok(format!("{:x}", Sha256::digest(bytes))) + }) + .unwrap() + } + #[test] + fn private_base_survives_store_reopen_and_feeds_a_recoverable_draft() { + let data = tempfile::tempdir().unwrap(); + let mut store = Store::open(data.path(), "bin_test").unwrap(); + let base = capture(&store, &(0..=255).collect::>()); + let name = base.name().to_owned(); + let digest = base.sha256().to_owned(); + let mut draft = crate::binary::draft::Draft::new(base).unwrap(); + draft + .patch(&crate::binary::draft::PatchBatch { + revision: draft.revision(), + splices: vec![crate::binary::draft::Splice { + offset: super::super::Offset(0), + delete_length: super::super::Offset(2), + expected_hex: "0001".into(), + insert_hex: "ff0080".into(), + }], + }) + .unwrap(); + let state = draft.snapshot(); + let revision = draft.revision(); + store + .record("state.json", 4096, |writer| { + serde_json::to_writer(writer, &state).map_err(|_| Error::Io) + }) + .unwrap(); + drop(draft); + drop(store); + let store = Store::open(data.path(), "bin_test").unwrap(); + let base = store.open_base(&name, 256, &digest).unwrap(); + let restored = crate::binary::draft::Draft::resume( + base, + serde_json::from_slice(&store.read_record("state.json").unwrap()).unwrap(), + ) + .unwrap(); + assert_eq!(restored.revision(), revision); + assert_eq!(restored.read(0, 4).unwrap(), [255, 0, 128, 2]); + assert!(store.open_base(&name, 256, &"00".repeat(32)).is_err()); + } + #[test] + fn cancellation_interrupts_private_base_verification_between_chunks() { + let data = tempfile::tempdir().unwrap(); + let store = Store::open(data.path(), "bin_cancel").unwrap(); + let base = capture(&store, &vec![42; DEFAULT_PAGE * 3]); + let checks = std::cell::Cell::new(0); + let result = store.open_base_cancellable(base.name(), base.length(), base.sha256(), || { + checks.set(checks.get() + 1); + checks.get() == 3 + }); + assert!(matches!(result, Err(Error::Cancelled))); + assert_eq!(checks.get(), 3); + // Cancellation does not poison the stored base or lose its evidence. + let reopened = store + .open_base(base.name(), base.length(), base.sha256()) + .unwrap(); + assert_eq!(reopened.read(DEFAULT_PAGE as u64, 4).unwrap(), [42; 4]); + } + + #[test] + fn exclusive_owner_and_bounded_records_keep_the_last_durable_state() { + let data = tempfile::tempdir().unwrap(); + let mut store = Store::open(data.path(), "bin_test").unwrap(); + assert!(Store::open(data.path(), "bin_test").is_err()); + store + .record("state.json", 3, |w| { + w.write_all(b"one").map_err(|_| Error::Io) + }) + .unwrap(); + assert!( + store + .record("state.json", 2, |w| w + .write_all(b"too much") + .map_err(|_| Error::Io)) + .is_err() + ); + assert_eq!(store.read_record("state.json").unwrap(), b"one"); + store + .record("state.json", 3, |w| { + w.write_all(b"two").map_err(|_| Error::Io) + }) + .unwrap(); + assert_eq!(store.read_record("state.json").unwrap(), b"two"); + drop(store); + assert!(Store::open(data.path(), "bin_test").is_ok()); + } + #[test] + fn invalid_names_and_capture_evidence_are_refused_before_admission() { + let data = tempfile::tempdir().unwrap(); + for name in ["..", "", "nested/path", "/outside"] { + assert!(Store::open(data.path(), name).is_err()); + } + let store = Store::open(data.path(), "bin_test").unwrap(); + assert!( + store + .base(MAX_EDIT_SIZE + 1, |_| panic!("oversized capture started")) + .is_err() + ); + assert!( + store + .base(1, |w| { + w.write_all(b"oversized").map_err(|_| Error::Io)?; + Ok("00".repeat(32)) + }) + .is_err() + ); + assert!( + store + .base(1, |w| { + w.write_all(b"x").map_err(|_| Error::Io)?; + Ok("00".repeat(32)) + }) + .is_err() + ); + } + #[cfg(unix)] + #[test] + fn private_namespace_links_permissions_and_base_mutation_are_not_repaired() { + use std::os::unix::fs::{PermissionsExt, symlink}; + let data = tempfile::tempdir().unwrap(); + let root = data.path().join("binary"); + std::fs::create_dir(&root).unwrap(); + std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert!(Store::open(data.path(), "bin_test").is_err()); + assert_eq!( + std::fs::metadata(&root).unwrap().permissions().mode() & 0o777, + 0o755 + ); + std::fs::remove_dir(&root).unwrap(); + let outside = tempfile::tempdir().unwrap(); + symlink(outside.path(), &root).unwrap(); + assert!(Store::open(data.path(), "bin_test").is_err()); + std::fs::remove_file(&root).unwrap(); + let store = Store::open(data.path(), "bin_test").unwrap(); + let base = capture(&store, b"original"); + let path = root.join("bin_test").join(base.name()); + std::fs::write(&path, b"changed!").unwrap(); + assert!(matches!(base.read(0, 8), Err(Error::Changed))); + let retained = data.path().join("retained"); + std::fs::rename(&root, &retained).unwrap(); + std::fs::create_dir(&root).unwrap(); + assert!(store.validate().is_err()); + assert_eq!( + std::fs::read(retained.join("bin_test").join(base.name())).unwrap(), + b"changed!" + ); + } + #[cfg(target_os = "macos")] + #[test] + fn native_mac_extended_private_directory_acl_is_refused_without_repair() { + use std::os::unix::fs::PermissionsExt; + let data = tempfile::tempdir().unwrap(); + let root = data.path().join("binary"); + std::fs::create_dir(&root).unwrap(); + std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).unwrap(); + assert!( + std::process::Command::new("/bin/chmod") + .args(["+a", "everyone allow list,search"]) + .arg(&root) + .status() + .unwrap() + .success() + ); + assert!(Store::open(data.path(), "bin_test").is_err()); + assert!(super::super::native::private_acl(&File::open(&root).unwrap()).is_err()); + assert!(!root.join("bin_test").exists()); + } + #[cfg(windows)] + #[test] + fn native_windows_private_base_excludes_writers_until_owner_release() { + let data = tempfile::tempdir().unwrap(); + let store = Store::open(data.path(), "bin_test").unwrap(); + let base = capture(&store, b"original"); + let path = data + .path() + .join("binary") + .join("bin_test") + .join(base.name()); + assert!(std::fs::OpenOptions::new().write(true).open(&path).is_err()); + assert_eq!(base.read(0, 8).unwrap(), b"original"); + drop(base); + std::fs::write(&path, b"changed!").unwrap(); + assert_eq!(std::fs::read(path).unwrap(), b"changed!"); + } +} diff --git a/openspec/changes/add-binary-hex-editor/implementation.md b/openspec/changes/add-binary-hex-editor/implementation.md index d8b786a6..c4924640 100644 --- a/openspec/changes/add-binary-hex-editor/implementation.md +++ b/openspec/changes/add-binary-hex-editor/implementation.md @@ -174,3 +174,71 @@ run; temporary diagnostic instrumentation was restored. No origin is assigned to that failure and no TLS behavior or acceptance assertion is changed by this fix. The reported CI failure and its navigation negative control are corrected; local full-suite results do not claim all browser acceptance passed. + + +## Follow-up increment: draft and native save core (2026-11-01) + +PR #95 merged at `9d3e708a`. The unchanged merged-main baseline selected nine +`cyber-core binary::` tests on macOS arm64; all passed. The follow-up adds core +primitives only. Production owner routes and the CLI/TUI remain read-only until +fresh authority, private-root admission, recovery and integration are wired. + +The core piece table stages expected-byte overwrite/insert/delete batches in +original coordinates, with monotonic revisions, atomic undo/redo, bounded history +and restart replay. Reviews hash both private base and proposed content, page +original-offset ranges and disclose metadata/native replacement effects. Equivalent +hex case and independent splice order share one operation payload identity. + +Private storage retains pinned directory/file identities, private permissions/ACLs, +exclusive document locks, bounded records and disk budgets. Private base creation +and verification stream in bounded chunks and honor cancellation. Atomic record +publication flushes file and directory; uncertain publication fences further IO +until reopen. Existing unsafe directories are refused rather than repaired. + +Native save preparation verifies live identity, metadata and full source digest, +streams a private replacement and persists an intent before one native exchange. +Commit rechecks the source content after preparation. Acknowledgement requires +installed and displaced identities, full content proofs and durable namespace +flushes. A response loss or pending intent never automatically re-executes exchange. +Unknown outcomes preserve evidence. Restore Last Save reviews a separate retained +preimage and refuses dirty drafts or externally changed source content. + +Core scope does not supply application authorization: callers must retain fresh +owner/read+edit/Mode admission and lifecycle/checkout leases through settlement. +It does not activate a model tool, HTTP mutations or notifications. Private-root +protection before child launch, owner/model isolation, discard/retention, snapshot +integration and the acknowledged observation outbox are still required. + +### Candidate native storage matrix + +| Platform | Core proof | Production editing admission | +| --- | --- | --- | +| macOS/APFS, same filesystem | Native exchange, metadata/xattrs/executable bits and extended ACL, process-death recovery exercised locally | Not activated; integration gates remain | +| Linux, same filesystem | Native exchange and POSIX ACL/xattr preservation implemented; native CI pending | Not activated; filesystem/platform evidence required | +| Windows | Private DACL/frozen-base and draft support implemented; native CI pending; no Windows replacement implementation | Viewer remains available; editing unsupported | +| Cross-filesystem or missing exchange/private storage guarantees | Refuse preparation; no copy-and-delete fallback | Unsupported for editing | + +Special files, symlink mutation aliases, multi-link targets, read-only targets, +privileged metadata that cannot be preserved, unsafe private ACLs and exceeded +budgets are refused. The native exchange protocol does not claim compare-and-swap +against arbitrary outside writers; failures after intent remain queryable unknown +outcomes with retained evidence. + +Local verification at this increment: `cargo test --locked -p cyber-core --lib` +passed 363 tests with one ignored child helper. The parent crash tests explicitly +execute that helper in owned subprocesses at preparation/intent/exchange/ack cuts. +The binary selection passed 45 tests before the final owner/Location regression; +the full run includes it. Strict OpenSpec validation passed all 114 items. +Formatting, diff whitespace and core Clippy checks passed. Native Linux/Windows +workflow results remain pending until CI executes; they are not inferred from +macOS. + +Code/security review used the code-review skill with the repository's existing +core/native boundary. Findings resolved during implementation: full-content +revalidation at commit after preparation, private-base integrity verification at +review, cancellation inside private verification, canonical operation payloads, +and owner/Location binding at creation/reopen. Regression tests cover these paths. +The native adapter and its owned macOS ACL allocation wrappers were inspected for +path admission, bounded allocations, identity retention and native resource disposal. +This core increment makes no production authorization or full-editor acceptance +claim; the integration gaps listed above remain explicit delivery requirements. From 8f7fe31369bebb5d54e2696033260fd8e8154a5c Mon Sep 17 00:00:00 2001 From: Edvane Cristina Santos Daltro Date: Sun, 1 Nov 2026 18:44:06 -0400 Subject: [PATCH 2/5] Protect private binary storage before child launch --- crates/cyber-app/src/lib.rs | 15 ++ crates/cyber-app/tests/files.rs | 64 ++++++++ crates/cyber-core/src/binary/storage.rs | 142 ++++++++++++++---- .../src/runtime/binary_documents.rs | 14 ++ .../add-binary-hex-editor/implementation.md | 12 ++ 5 files changed, 219 insertions(+), 28 deletions(-) diff --git a/crates/cyber-app/src/lib.rs b/crates/cyber-app/src/lib.rs index 40e023e1..54ff8af8 100644 --- a/crates/cyber-app/src/lib.rs +++ b/crates/cyber-app/src/lib.rs @@ -227,6 +227,16 @@ impl App { // Establish the private directory before Linux sandbox mount planning or // any plugin, LSP, MCP, hook or model-driven subprocess can launch. cyber_tools::plugin_host::hide_computer_artifacts(&opts.paths, &opts.home)?; + // Prepare the existing object before sandbox mount planning. Unsafe storage + // disables editing while retaining the live viewer and normal App startup. + let binary_storage = cyber_core::binary::storage::StorageRoot::prepare(&opts.paths.data) + .ok() + .map(Arc::new); + let binary_path = opts.paths.data.join("binary"); + cyber_sandbox::hide_credential_file(&opts.home, binary_path.clone()); + if let Ok(canonical) = binary_path.canonicalize() { + cyber_sandbox::hide_credential_file(&opts.home, canonical); + } if let Some(password) = &opts.password { cyber_sandbox::reserve_owner_credential(password); } @@ -348,6 +358,11 @@ impl App { interactive: opts.interactive, snapshots, }); + if let Some(storage) = binary_storage { + runtime + .configure_binary_storage(storage) + .map_err(|error| error.to_string())?; + } host.attach(runtime.clone()); // One fair scheduler of the slots of every Workflow agent of this server // (`workflows.max_concurrent`, reduced to the core count), fixed at startup like the diff --git a/crates/cyber-app/tests/files.rs b/crates/cyber-app/tests/files.rs index db3ac5d6..da9b5d3d 100644 --- a/crates/cyber-app/tests/files.rs +++ b/crates/cyber-app/tests/files.rs @@ -872,3 +872,67 @@ async fn binary_close_requires_its_session_but_no_longer_needs_a_live_location() json!({"data":true}) ); } + +#[tokio::test] +async fn binary_private_root_is_hidden_before_drafts_exist_even_with_readable_path_grants() { + use cyber_server::runtime::{Asker, Invocation, ToolHost, ToolOutcome}; + let f = Fixture::with(json!({"*": "allow"})).await; + let session = f.session().await; + let info = f._app.runtime.state(&session).await.unwrap().info; + let private = f._app.paths.data.join("binary"); + assert!(private.is_dir()); + // Created after App bootstrap, as actual draft files would be. + let secret = private.join("owner-draft.json"); + std::fs::write(&secret, "PRIVATE UNSAVED OWNER BYTES").unwrap(); + let public = f.project.join("public.txt"); + std::fs::write(&public, "public positive control").unwrap(); + let invoke = |path: &Path, call: &str| Invocation { + tool_output: Default::default(), + registration: None, + session_id: session.clone(), + directory: info.directory.clone(), + agent: info.agent.clone(), + mode: info.mode.clone(), + message_id: "msg_binary_privacy".into(), + call_id: call.into(), + name: "read".into(), + input: json!({"path": path}), + attempt: 1, + operation_key: call.into(), + asker: Asker::detached(), + rules: info.rules.clone(), + }; + let result = f + ._app + .host + .execute( + invoke(&secret, "call_private"), + tokio_util::sync::CancellationToken::new(), + ) + .await; + let ToolOutcome::Failed(reason) = result else { + panic!("private draft was not refused: {result:?}") + }; + assert!(reason.contains("Sandbox denied: read"), "{reason}"); + assert!(!reason.contains("PRIVATE UNSAVED")); + let result = f + ._app + .host + .execute( + invoke(&public, "call_public"), + tokio_util::sync::CancellationToken::new(), + ) + .await; + let ToolOutcome::Ok(output) = result else { + panic!("positive read failed: {result:?}") + }; + assert!(output.contains("public positive control")); + let mut sandbox = cyber_sandbox::SandboxConfig::resolve( + &Value::Null, + &Default::default(), + None, + &f.root.join("home"), + ); + sandbox.readable_paths.push(private.clone()); + assert!(sandbox.unreadable(&f.root.join("home")).contains(&private)); +} diff --git a/crates/cyber-core/src/binary/storage.rs b/crates/cyber-core/src/binary/storage.rs index 557478a8..4daf7ed4 100644 --- a/crates/cyber-core/src/binary/storage.rs +++ b/crates/cyber-core/src/binary/storage.rs @@ -16,6 +16,77 @@ use std::sync::Arc; pub const MAX_STORAGE: u64 = 2 * 1024 * 1024 * 1024; const MAX_RECORD: u64 = 256 * 1024 * 1024; +fn open_root(data: &Path) -> Result<(PathBuf, Dir, Dir), Error> { + if !data.is_absolute() { + return Err(Error::Invalid); + } + let data_path = data.canonicalize().map_err(|_| Error::File)?; + let data = + Dir::open_ambient_dir(&data_path, cap_std::ambient_authority()).map_err(|_| Error::File)?; + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if data + .try_clone() + .map_err(|_| Error::Io)? + .into_std_file() + .metadata() + .map_err(|_| Error::Io)? + .mode() + & 0o022 + != 0 + { + return Err(Error::File); + } + } + let root = child(&data, "binary")?; + flush_dir(&root)?; + flush_dir(&data)?; + Ok((data_path, data, root)) +} + +/// Prepared before untrusted child launch so its existing native directory can be +/// hidden by the sandbox. A later document open cannot silently bind a replacement. +pub struct StorageRoot { + data: PathBuf, + root: Dir, +} +impl StorageRoot { + pub fn prepare(data: &Path) -> Result { + let (data, _parent, root) = open_root(data)?; + Ok(Self { data, root }) + } + pub fn path(&self) -> PathBuf { + self.data.join("binary") + } + pub fn open(&self, id: &str) -> Result { + let store = Store::open(&self.data, id)?; + if identity(&store.root)? != identity(&self.root)? { + return Err(Error::Changed); + } + Ok(store) + } + /// A writable project containing private storage could rename or inspect the + /// staging directory under its filesystem grant. Such editing is unsupported. + pub fn admit_project(&self, project: &Path) -> Result<(), Error> { + let project = project.canonicalize().map_err(|_| Error::File)?; + if self.path().starts_with(&project) || project.starts_with(self.path()) { + return Err(Error::File); + } + // Validate the root binding without capturing any source content. + let root = Dir::open_ambient_dir(self.path(), cap_std::ambient_authority()) + .map_err(|_| Error::File)?; + if identity(&root)? != identity(&self.root)? { + return Err(Error::Changed); + } + private( + &root, + &root.try_clone().map_err(|_| Error::Io)?.into_std_file(), + true, + ) + } +} + pub struct Store { data_path: PathBuf, data: Dir, @@ -139,11 +210,11 @@ fn create(dir: &Dir, name: &str) -> Result { component(name)?; #[cfg(windows)] { - return crate::memory::windows::create_private_file( + crate::memory::windows::create_private_file( &dir.try_clone().map_err(|_| Error::Io)?.into_std_file(), name, ) - .map_err(|_| Error::Io); + .map_err(|_| Error::Io) } #[cfg(not(windows))] { @@ -170,12 +241,12 @@ fn read_file(dir: &Dir, name: &str) -> Result { component(name)?; #[cfg(windows)] { - return crate::memory::windows::open_private_file( + crate::memory::windows::open_private_file( &dir.try_clone().map_err(|_| Error::Io)?.into_std_file(), name, crate::memory::windows::Access::Read, ) - .map_err(|_| Error::File); + .map_err(|_| Error::File) } #[cfg(not(windows))] { @@ -204,7 +275,7 @@ fn flush_dir(dir: &Dir) -> Result<(), Error> { let file = dir.try_clone().map_err(|_| Error::Io)?.into_std_file(); #[cfg(windows)] { - return crate::memory::windows::sync_private(&file).map_err(|_| Error::Io); + crate::memory::windows::sync_private(&file).map_err(|_| Error::Io) } #[cfg(not(windows))] { @@ -213,6 +284,7 @@ fn flush_dir(dir: &Dir) -> Result<(), Error> { } impl Store { + #[cfg(any(target_os = "macos", target_os = "linux"))] pub(super) fn directory(&self) -> &Dir { &self.dir } @@ -235,29 +307,7 @@ impl Store { pub fn open(data: &Path, id: &str) -> Result { component(id)?; - if !data.is_absolute() { - return Err(Error::Invalid); - } - let data_path = data.canonicalize().map_err(|_| Error::File)?; - let data = Dir::open_ambient_dir(&data_path, cap_std::ambient_authority()) - .map_err(|_| Error::File)?; - #[cfg(unix)] - { - use std::os::unix::fs::MetadataExt; - if data - .try_clone() - .map_err(|_| Error::Io)? - .into_std_file() - .metadata() - .map_err(|_| Error::Io)? - .mode() - & 0o022 - != 0 - { - return Err(Error::File); - } - } - let root = child(&data, "binary")?; + let (data_path, data, root) = open_root(data)?; let dir = child(&root, id)?; #[cfg(windows)] let lock = { @@ -643,6 +693,42 @@ mod tests { }) .unwrap() } + #[test] + fn prepared_root_refuses_project_overlap_and_root_replacement() { + let root = tempfile::tempdir().unwrap(); + let data = root.path().join("data"); + let project = root.path().join("project"); + std::fs::create_dir(&data).unwrap(); + std::fs::create_dir(&project).unwrap(); + let storage = StorageRoot::prepare(&data).unwrap(); + storage.admit_project(&project).unwrap(); + assert!(storage.admit_project(root.path()).is_err()); + assert!(storage.admit_project(&storage.path()).is_err()); + storage.open("bin_positive").unwrap(); + #[cfg(not(windows))] + { + std::fs::rename(storage.path(), data.join("moved")).unwrap(); + let replacement = StorageRoot::prepare(&data).unwrap(); + assert!(storage.admit_project(&project).is_err()); + assert!(storage.open("bin_rebound").is_err()); + replacement.admit_project(&project).unwrap(); + replacement.open("bin_rebound").unwrap(); + } + #[cfg(windows)] + { + // Retained Windows directory handles deny rename rather than allowing + // replacement followed by a later identity refusal. + assert!(std::fs::rename(storage.path(), data.join("moved")).is_err()); + storage.open("bin_still_bound").unwrap(); + drop(storage); + std::fs::rename(data.join("binary"), data.join("moved")).unwrap(); + StorageRoot::prepare(&data) + .unwrap() + .open("bin_new_root") + .unwrap(); + } + } + #[test] fn private_base_survives_store_reopen_and_feeds_a_recoverable_draft() { let data = tempfile::tempdir().unwrap(); diff --git a/crates/cyber-server/src/runtime/binary_documents.rs b/crates/cyber-server/src/runtime/binary_documents.rs index 4ca5564a..ab253810 100644 --- a/crates/cyber-server/src/runtime/binary_documents.rs +++ b/crates/cyber-server/src/runtime/binary_documents.rs @@ -18,6 +18,7 @@ pub(crate) struct Entry { pub(crate) struct Registry( Mutex>>, std::sync::OnceLock>, + std::sync::OnceLock>, ); impl Registry { @@ -92,6 +93,19 @@ impl Registry { } impl super::Runtime { + /// Install only after bootstrap has prepared and hidden the existing root from + /// all child launches. An unconfigured runtime keeps binary editing disabled. + pub fn configure_binary_storage( + &self, + root: Arc, + ) -> Result<(), Error> { + self.inner + .binary_documents + .2 + .set(root) + .map_err(|_| Error::Changed) + } + pub(crate) fn binary_documents(&self) -> &Registry { &self.inner.binary_documents } diff --git a/openspec/changes/add-binary-hex-editor/implementation.md b/openspec/changes/add-binary-hex-editor/implementation.md index c4924640..a89f99b7 100644 --- a/openspec/changes/add-binary-hex-editor/implementation.md +++ b/openspec/changes/add-binary-hex-editor/implementation.md @@ -242,3 +242,15 @@ The native adapter and its owned macOS ACL allocation wrappers were inspected fo path admission, bounded allocations, identity retention and native resource disposal. This core increment makes no production authorization or full-editor acceptance claim; the integration gaps listed above remain explicit delivery requirements. + + +Bootstrap follow-up: the App now prepares and pins the binary private root before +any untrusted launch, registers its original/canonical paths with the existing +home-scoped unreadable-root protection, and configures the runtime only when the +private root is safe. Unsafe storage retains viewer availability. Project overlap +and a replaced prepared root are refused. A production App regression creates a +draft file after bootstrap, proves model `read` cannot disclose it even with broad +read grants/readable-path configuration, and includes a successful public-file +read control. The 16 existing production file-route tests and this new regression +passed locally; affected core/server/App Clippy passed. Initial Windows CI found +unused Unix-only access and needless Windows returns; those branches were corrected. From 38214334fad546d83f72c1918e3313f1352632b3 Mon Sep 17 00:00:00 2001 From: Edvane Cristina Santos Daltro Date: Sun, 1 Nov 2026 18:45:19 -0400 Subject: [PATCH 3/5] Flush pinned native directories through readable descriptors --- crates/cyber-core/src/binary/editor.rs | 7 +-- crates/cyber-core/src/binary/storage.rs | 51 +++++++++++++++++-- .../add-binary-hex-editor/implementation.md | 8 +++ 3 files changed, 56 insertions(+), 10 deletions(-) diff --git a/crates/cyber-core/src/binary/editor.rs b/crates/cyber-core/src/binary/editor.rs index 177f2306..4d21d8b9 100644 --- a/crates/cyber-core/src/binary/editor.rs +++ b/crates/cyber-core/src/binary/editor.rs @@ -562,12 +562,7 @@ impl Editor { return Err(Error::Changed); } native::sync(&installed)?; - parent - .try_clone() - .map_err(|_| Error::Io)? - .into_std_file() - .sync_all() - .map_err(|_| Error::Io)?; + super::storage::flush_dir(&parent)?; self.store.sync()?; installed_target.validate()?; if native::generation(&displaced)? != displaced_stamp { diff --git a/crates/cyber-core/src/binary/storage.rs b/crates/cyber-core/src/binary/storage.rs index 4daf7ed4..92bf1b40 100644 --- a/crates/cyber-core/src/binary/storage.rs +++ b/crates/cyber-core/src/binary/storage.rs @@ -271,15 +271,31 @@ fn flush_file(file: &File) -> Result<(), Error> { rustix::fs::fcntl_fullfsync(file).map_err(|_| Error::Io)?; Ok(()) } -fn flush_dir(dir: &Dir) -> Result<(), Error> { - let file = dir.try_clone().map_err(|_| Error::Io)?.into_std_file(); +pub(super) fn flush_dir(dir: &Dir) -> Result<(), Error> { #[cfg(windows)] { + let file = dir.try_clone().map_err(|_| Error::Io)?.into_std_file(); crate::memory::windows::sync_private(&file).map_err(|_| Error::Io) } - #[cfg(not(windows))] + #[cfg(unix)] + { + // cap-std directory handles can be O_PATH on Linux. They retain routing + // identity but cannot be fsynced. Open the same directory through that + // retained handle with read access, without consulting its ambient path. + let fd = rustix::fs::openat( + dir, + ".", + rustix::fs::OFlags::RDONLY + | rustix::fs::OFlags::DIRECTORY + | rustix::fs::OFlags::CLOEXEC, + rustix::fs::Mode::empty(), + ) + .map_err(|_| Error::Io)?; + File::from(fd).sync_all().map_err(|_| Error::Io) + } + #[cfg(not(any(unix, windows)))] { - file.sync_all().map_err(|_| Error::Io) + Err(Error::File) } } @@ -693,6 +709,33 @@ mod tests { }) .unwrap() } + #[cfg(target_os = "linux")] + #[test] + fn directory_flush_uses_a_readable_descriptor_from_a_retained_path_handle() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("pinned"); + std::fs::create_dir(&path).unwrap(); + let fd = rustix::fs::open( + &path, + rustix::fs::OFlags::PATH | rustix::fs::OFlags::DIRECTORY | rustix::fs::OFlags::CLOEXEC, + rustix::fs::Mode::empty(), + ) + .unwrap(); + let file = File::from(fd); + assert_eq!( + file.sync_all().unwrap_err().raw_os_error(), + Some(libc::EBADF) + ); + let pinned = Dir::from_std_file(file); + std::fs::rename(&path, root.path().join("moved")).unwrap(); + std::fs::write(&path, b"replacement is not a directory").unwrap(); + flush_dir(&pinned).unwrap(); + assert_eq!( + std::fs::read(path).unwrap(), + b"replacement is not a directory" + ); + } + #[test] fn prepared_root_refuses_project_overlap_and_root_replacement() { let root = tempfile::tempdir().unwrap(); diff --git a/openspec/changes/add-binary-hex-editor/implementation.md b/openspec/changes/add-binary-hex-editor/implementation.md index a89f99b7..137e9c39 100644 --- a/openspec/changes/add-binary-hex-editor/implementation.md +++ b/openspec/changes/add-binary-hex-editor/implementation.md @@ -254,3 +254,11 @@ read grants/readable-path configuration, and includes a successful public-file read control. The 16 existing production file-route tests and this new regression passed locally; affected core/server/App Clippy passed. Initial Windows CI found unused Unix-only access and needless Windows returns; those branches were corrected. + + +Linux native CI at the first core commit exposed `fsync` on cap-std's `O_PATH` +directory routing handle. Directory flush now obtains a readable descriptor for +`.` relative to that retained handle, including the target parent at settlement. +A Linux-only regression proves the direct descriptor fails with `EBADF`, then +flushes the pinned directory successfully after its ambient pathname is replaced. +The remote full storage/save proof must pass before Linux support is accepted. From 2f8ae42f930c02c1d9a0b04891da434ae0056ae7 Mon Sep 17 00:00:00 2001 From: Edvane Cristina Santos Daltro Date: Sun, 1 Nov 2026 18:51:43 -0400 Subject: [PATCH 4/5] Add scoped binary draft recovery and explicit discard --- crates/cyber-core/src/binary/editor.rs | 117 +++++++++- .../cyber-core/src/binary/editor/catalog.rs | 220 ++++++++++++++++++ .../src/binary/editor/death_tests.rs | 2 + .../cyber-core/src/binary/editor/restore.rs | 2 + crates/cyber-core/src/binary/source.rs | 4 + crates/cyber-core/src/binary/storage.rs | 89 ++++++- .../add-binary-hex-editor/implementation.md | 18 ++ 7 files changed, 439 insertions(+), 13 deletions(-) create mode 100644 crates/cyber-core/src/binary/editor/catalog.rs diff --git a/crates/cyber-core/src/binary/editor.rs b/crates/cyber-core/src/binary/editor.rs index 4d21d8b9..a49d7095 100644 --- a/crates/cyber-core/src/binary/editor.rs +++ b/crates/cyber-core/src/binary/editor.rs @@ -16,8 +16,11 @@ use std::fs::File; use std::io::Write; use std::path::PathBuf; +#[path = "editor/catalog.rs"] +mod catalog; #[path = "editor/restore.rs"] mod restore; +pub use catalog::{StoredDocument, StoredDocuments}; const MAX_OPERATIONS: usize = 4096; const EFFECTS: &str = "Permissions, executable bits, owner/group, supported extended attributes and ACL are preserved. Native replacement changes file identity and updates modification time. Save checkpoints draft undo/redo; Restore Last Save uses the retained preimage. External checksums, signatures and irreversible observer effects are not repaired. Uncooperative writers can leave an unknown outcome; evidence is retained."; @@ -26,7 +29,16 @@ const EFFECTS: &str = "Permissions, executable bits, owner/group, supported exte #[serde(deny_unknown_fields)] pub struct Owner { pub session_id: String, + /// Exact canonical Session Location, which can be below the project root. pub directory: PathBuf, + pub root: PathBuf, + pub scope: DraftScope, +} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum DraftScope { + Manual, + Model { operation: String }, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(rename_all = "snake_case")] @@ -185,15 +197,22 @@ impl Editor { document: &mut Document, cancelled: impl Fn() -> bool, ) -> Result { - if owner.session_id.is_empty() || !owner.directory.is_absolute() { + if owner.session_id.is_empty() + || !owner.directory.is_absolute() + || !owner.root.is_absolute() + { return Err(Error::Invalid); } + if document.root() != owner.root || !owner.directory.starts_with(&owner.root) { + return Err(Error::File); + } // Never replace an existing document, including one with unresolved evidence. if store.directory().symlink_metadata("document.json").is_ok() { return Err(Error::Changed); } + catalog::publish_header(&mut store, &owner, document)?; let (base, target) = document.prepare(&store, cancelled)?; - if target.root != owner.directory { + if target.root != owner.root { return Err(Error::File); } let draft = Draft::new(base)?; @@ -220,7 +239,7 @@ impl Editor { if record.version != 1 || &record.owner != owner || record.operations.len() > MAX_OPERATIONS - || record.target.root != owner.directory + || record.target.root != owner.root { return Err(Error::File); } @@ -293,6 +312,21 @@ impl Editor { candidate.patch(&batch)?; self.commit_draft(id, "patch", payload, candidate) } + /// Explicitly discard local commands without changing the source, preimages, + /// operation receipts or unresolved native evidence. + pub fn discard(&mut self, id: &str, revision: &str) -> Result { + let payload = signature(&("discard-v1", revision))?; + if let Some(prior) = self.prior(id, &payload)? { + return Ok(prior); + } + self.available()?; + if revision != self.draft.revision() { + return Err(Error::Changed); + } + let candidate = Draft::new(self.draft.base().clone())?; + self.commit_draft(id, "discard", payload, candidate) + } + pub fn history(&mut self, id: &str, revision: &str, redo: bool) -> Result { let kind = if redo { "redo" } else { "undo" }; let payload = signature(&(kind, revision))?; @@ -653,6 +687,8 @@ mod tests { let owner = Owner { session_id: "ses_one".into(), directory: project.canonicalize().unwrap(), + root: project.canonicalize().unwrap(), + scope: DraftScope::Manual, }; let mut document = Document::open(&owner.directory, "bytes").unwrap(); let editor = Editor::create( @@ -723,6 +759,77 @@ mod tests { assert_eq!(std::fs::read(path).unwrap(), [0, 255, 128, 27]); } + #[test] + fn explicit_discard_survives_restart_without_changing_source_or_losing_receipts() { + let (root, mut editor, path, owner) = setup(); + patch(&mut editor, "patch_discard"); + let revision = editor.draft().revision(); + assert!(editor.discard("discard_stale", "stale").is_err()); + let receipt = editor.discard("discard_one", &revision).unwrap(); + assert_eq!(receipt.file_effect, FileEffect::None); + assert!(!receipt.notification_pending); + assert!(!editor.draft().can_undo() && !editor.draft().can_redo()); + assert_eq!(editor.draft().read(0, 8).unwrap(), b"original"); + assert_eq!(std::fs::read(&path).unwrap(), b"original"); + drop(editor); + let mut editor = Editor::resume( + Store::open(&root.path().join("data"), "bin_one").unwrap(), + &owner, + ) + .unwrap(); + assert_eq!( + editor.discard("discard_one", &revision).unwrap().revision, + receipt.revision + ); + assert!(editor.operation("patch_discard").is_some()); + assert!(!editor.draft().can_undo()); + } + + #[test] + fn owner_namespace_and_exact_location_cannot_be_rebound_on_resume() { + let (root, editor, _path, owner) = setup(); + drop(editor); + let mut wrong = owner.clone(); + wrong.scope = DraftScope::Model { + operation: "call_model".into(), + }; + assert!( + Editor::resume( + Store::open(&root.path().join("data"), "bin_one").unwrap(), + &wrong + ) + .is_err() + ); + let nested = owner.root.join("nested"); + std::fs::create_dir(&nested).unwrap(); + wrong = owner.clone(); + wrong.directory = nested.clone(); + assert!( + Editor::resume( + Store::open(&root.path().join("data"), "bin_one").unwrap(), + &wrong + ) + .is_err() + ); + // A new document can bind a nested Location without confusing it with + // its project-root source routing authority. + let mut document = Document::open(&owner.root, "bytes").unwrap(); + let editor = Editor::create( + Store::open(&root.path().join("data"), "bin_nested").unwrap(), + wrong.clone(), + &mut document, + || false, + ) + .unwrap(); + assert_eq!(editor.owner().directory, nested); + drop(editor); + Editor::resume( + Store::open(&root.path().join("data"), "bin_nested").unwrap(), + &wrong, + ) + .unwrap(); + } + #[test] fn equivalent_patch_encodings_reuse_the_durable_receipt() { let (_root, mut editor, _path, _owner) = setup(); @@ -1003,7 +1110,9 @@ mod tests { store, &Owner { session_id: "ses_other".into(), - directory: owner.directory.clone() + directory: owner.directory.clone(), + root: owner.root.clone(), + scope: owner.scope.clone(), } ) .is_err() diff --git a/crates/cyber-core/src/binary/editor/catalog.rs b/crates/cyber-core/src/binary/editor/catalog.rs new file mode 100644 index 00000000..b4ad769c --- /dev/null +++ b/crates/cyber-core/src/binary/editor/catalog.rs @@ -0,0 +1,220 @@ +//! Bounded private owner index. Listing never opens source bytes or a draft base, +//! and never needs the active document's exclusive mutation lock. +use super::*; +use crate::binary::storage::{StorageRoot, small_record}; +use cap_fs_ext::DirExt; +use std::path::Component; +const HEADER_LIMIT: u64 = 16 * 1024; + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Header { + version: u32, + owner: Owner, + relative: PathBuf, +} +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct StoredDocument { + pub document_id: String, + pub path: String, + /// Presence of a journal, not a claim of successful native reconciliation. + pub record_available: bool, +} +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct StoredDocuments { + pub documents: Vec, + pub next_cursor: Option, +} +pub(super) fn publish_header( + store: &mut Store, + owner: &Owner, + document: &Document, +) -> Result<(), Error> { + if store.directory().symlink_metadata("owner.json").is_ok() { + return Err(Error::Changed); + } + let relative = document + .path() + .strip_prefix(document.root()) + .map_err(|_| Error::File)? + .to_owned(); + let header = Header { + version: 1, + owner: owner.clone(), + relative, + }; + let bytes = serde_json::to_vec(&header).map_err(|_| Error::Invalid)?; + if bytes.len() as u64 > HEADER_LIMIT { + return Err(Error::Limit); + } + store.record("owner.json", bytes.len() as u64, |writer| { + writer.write_all(&bytes).map_err(|_| Error::Io) + }) +} +impl StorageRoot { + /// Callers establish fresh owner authentication and Session/Location scope. + /// Exact scope equality keeps model invocation drafts out of manual recovery. + pub fn list( + &self, + owner: &Owner, + after: Option<&str>, + limit: usize, + ) -> Result { + if limit == 0 + || limit > 64 + || after.is_some_and(|id| { + id.len() > 100 || !id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') + }) + { + return Err(Error::Limit); + } + let root = self.directory()?; + let mut found = vec![]; + for (count, entry) in root.entries().map_err(|_| Error::Io)?.enumerate() { + if count >= 4096 { + return Err(Error::Limit); + } + let name = entry.map_err(|_| Error::Io)?.file_name(); + let id = name.to_str().ok_or(Error::File)?; + if !id.starts_with("bin_") + || id.len() > 100 + || !id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') + { + return Err(Error::File); + } + if after.is_some_and(|after| id <= after) { + continue; + } + let dir = root.open_dir_nofollow(&name).map_err(|_| Error::File)?; + crate::binary::storage::private( + root, + &dir.try_clone().map_err(|_| Error::Io)?.into_std_file(), + true, + )?; + // Missing headers represent an allocation cancelled before capturing + // plaintext. Existing invalid records are retained and fail closed. + 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.owner != owner { + continue; + } + if header.relative.as_os_str().is_empty() + || header + .relative + .components() + .any(|c| !matches!(c, Component::Normal(_))) + { + return Err(Error::File); + } + found.push(StoredDocument { + document_id: id.into(), + path: header.relative.to_str().ok_or(Error::File)?.into(), + record_available: dir.try_exists("document.json").map_err(|_| Error::Io)?, + }); + found.sort_by(|a, b| a.document_id.cmp(&b.document_id)); + found.truncate(limit + 1); + } + self.directory()?; + let next_cursor = (found.len() > limit).then(|| found[limit - 1].document_id.clone()); + found.truncate(limit); + Ok(StoredDocuments { + documents: found, + next_cursor, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn fixture() -> (tempfile::TempDir, StorageRoot, Owner) { + let root = tempfile::tempdir().unwrap(); + let data = root.path().join("data"); + let project = root.path().join("project"); + std::fs::create_dir(&data).unwrap(); + std::fs::create_dir(&project).unwrap(); + std::fs::write(project.join("bytes"), [0, 255, 128]).unwrap(); + let project = project.canonicalize().unwrap(); + let owner = Owner { + session_id: "ses_catalog".into(), + directory: project.clone(), + root: project, + scope: DraftScope::Manual, + }; + (root, StorageRoot::prepare(&data).unwrap(), owner) + } + fn create(storage: &StorageRoot, owner: &Owner, id: &str) -> Editor { + let mut doc = Document::open(&owner.root, "bytes").unwrap(); + Editor::create(storage.open(id).unwrap(), owner.clone(), &mut doc, || false).unwrap() + } + #[test] + fn recovery_listing_is_bounded_pageable_and_isolated_while_documents_are_active() { + let (_root, storage, owner) = fixture(); + let first = create(&storage, &owner, "bin_a"); + let second = create(&storage, &owner, "bin_b"); + let mut model = owner.clone(); + model.scope = DraftScope::Model { + operation: "call_model".into(), + }; + let _model = create(&storage, &model, "bin_model"); + let mut other = owner.clone(); + other.session_id = "ses_other".into(); + let _other = create(&storage, &other, "bin_other"); + let page = storage.list(&owner, None, 1).unwrap(); + assert_eq!(page.documents.len(), 1); + assert_eq!(page.documents[0].document_id, "bin_a"); + assert!(page.documents[0].record_available); + assert_eq!(page.next_cursor.as_deref(), Some("bin_a")); + let page = storage + .list(&owner, page.next_cursor.as_deref(), 1) + .unwrap(); + assert_eq!(page.documents[0].document_id, "bin_b"); + assert!(page.next_cursor.is_none()); + assert_eq!( + storage.list(&model, None, 64).unwrap().documents[0].document_id, + "bin_model" + ); + assert_eq!( + storage.list(&other, None, 64).unwrap().documents[0].document_id, + "bin_other" + ); + assert!(storage.list(&owner, None, 65).is_err()); + drop((first, second)); + Editor::resume(storage.open("bin_a").unwrap(), &owner).unwrap(); + } + #[test] + fn interrupted_preparation_remains_discoverable_without_a_claimed_journal() { + let (_root, storage, owner) = fixture(); + let mut doc = Document::open(&owner.root, "bytes").unwrap(); + assert!(matches!( + Editor::create( + storage.open("bin_cancelled").unwrap(), + owner.clone(), + &mut doc, + || true + ), + Err(Error::Cancelled) + )); + let listing = storage.list(&owner, None, 64).unwrap(); + assert_eq!(listing.documents.len(), 1); + assert_eq!(listing.documents[0].document_id, "bin_cancelled"); + assert!(!listing.documents[0].record_available); + assert!(matches!( + Editor::create( + storage.open("bin_cancelled").unwrap(), + owner.clone(), + &mut doc, + || false + ), + Err(Error::Changed) + )); + assert_eq!( + std::fs::read(owner.root.join("bytes")).unwrap(), + [0, 255, 128] + ); + } +} diff --git a/crates/cyber-core/src/binary/editor/death_tests.rs b/crates/cyber-core/src/binary/editor/death_tests.rs index 81296b9a..24166f7f 100644 --- a/crates/cyber-core/src/binary/editor/death_tests.rs +++ b/crates/cyber-core/src/binary/editor/death_tests.rs @@ -19,6 +19,8 @@ fn owner(root: &Path) -> Owner { Owner { session_id: "ses_death".into(), directory: root.join("project").canonicalize().unwrap(), + root: root.join("project").canonicalize().unwrap(), + scope: DraftScope::Manual, } } fn prepare(root: &Path) -> (Owner, String) { diff --git a/crates/cyber-core/src/binary/editor/restore.rs b/crates/cyber-core/src/binary/editor/restore.rs index df921850..37a0f5b4 100644 --- a/crates/cyber-core/src/binary/editor/restore.rs +++ b/crates/cyber-core/src/binary/editor/restore.rs @@ -160,6 +160,8 @@ mod tests { let owner = Owner { session_id: "ses_restore".into(), directory: root.path().join("project").canonicalize().unwrap(), + root: root.path().join("project").canonicalize().unwrap(), + scope: DraftScope::Manual, }; std::fs::write(owner.directory.join("bytes"), [0, 255, 128, 27]).unwrap(); let mut doc = Document::open(&owner.directory, "bytes").unwrap(); diff --git a/crates/cyber-core/src/binary/source.rs b/crates/cyber-core/src/binary/source.rs index 31f5603b..c2c83740 100644 --- a/crates/cyber-core/src/binary/source.rs +++ b/crates/cyber-core/src/binary/source.rs @@ -159,6 +159,10 @@ impl Document { Ok((base, target)) } + pub fn root(&self) -> &Path { + &self.root + } + pub fn path(&self) -> PathBuf { self.root.join(&self.relative) } diff --git a/crates/cyber-core/src/binary/storage.rs b/crates/cyber-core/src/binary/storage.rs index 92bf1b40..daae90bc 100644 --- a/crates/cyber-core/src/binary/storage.rs +++ b/crates/cyber-core/src/binary/storage.rs @@ -16,13 +16,51 @@ use std::sync::Arc; pub const MAX_STORAGE: u64 = 2 * 1024 * 1024 * 1024; const MAX_RECORD: u64 = 256 * 1024 * 1024; +fn open_data_directory(path: &Path) -> Result { + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + use windows_sys::Win32::Storage::FileSystem::{ + FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_GENERIC_READ, + FILE_GENERIC_WRITE, FILE_SHARE_READ, FILE_SHARE_WRITE, + }; + let file = std::fs::OpenOptions::new() + .access_mode(FILE_GENERIC_READ | FILE_GENERIC_WRITE) + .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) + .open(path) + .map_err(|_| Error::File)?; + crate::memory::windows::identity(&file).map_err(|_| Error::File)?; + if !file.metadata().map_err(|_| Error::Io)?.is_dir() { + return Err(Error::File); + } + Ok(Dir::from_std_file(file)) + } + #[cfg(not(windows))] + { + Dir::open_ambient_dir(path, cap_std::ambient_authority()).map_err(|_| Error::File) + } +} +fn flush_namespace(dir: &Dir) -> Result<(), Error> { + #[cfg(windows)] + { + crate::memory::windows::sync_namespace_directory( + &dir.try_clone().map_err(|_| Error::Io)?.into_std_file(), + ) + .map_err(|_| Error::Io) + } + #[cfg(not(windows))] + { + flush_dir(dir) + } +} + fn open_root(data: &Path) -> Result<(PathBuf, Dir, Dir), Error> { if !data.is_absolute() { return Err(Error::Invalid); } let data_path = data.canonicalize().map_err(|_| Error::File)?; - let data = - Dir::open_ambient_dir(&data_path, cap_std::ambient_authority()).map_err(|_| Error::File)?; + let data = open_data_directory(&data_path)?; #[cfg(unix)] { use std::os::unix::fs::MetadataExt; @@ -41,7 +79,7 @@ fn open_root(data: &Path) -> Result<(PathBuf, Dir, Dir), Error> { } let root = child(&data, "binary")?; flush_dir(&root)?; - flush_dir(&data)?; + flush_namespace(&data)?; Ok((data_path, data, root)) } @@ -59,6 +97,23 @@ impl StorageRoot { pub fn path(&self) -> PathBuf { self.data.join("binary") } + #[cfg(any(target_os = "macos", target_os = "linux"))] + pub(super) fn directory(&self) -> Result<&Dir, Error> { + let data = Dir::open_ambient_dir(&self.data, cap_std::ambient_authority()) + .map_err(|_| Error::File)?; + let root = data + .open_dir_nofollow("binary") + .map_err(|_| Error::Changed)?; + if identity(&root)? != identity(&self.root)? { + return Err(Error::Changed); + } + private( + &data, + &root.try_clone().map_err(|_| Error::Io)?.into_std_file(), + true, + )?; + Ok(&self.root) + } pub fn open(&self, id: &str) -> Result { let store = Store::open(&self.data, id)?; if identity(&store.root)? != identity(&self.root)? { @@ -127,7 +182,7 @@ fn identity(dir: &Dir) -> Result { } #[cfg(unix)] -fn private(dir: &Dir, file: &File, directory: bool) -> Result<(), Error> { +pub(super) fn private(dir: &Dir, file: &File, directory: bool) -> Result<(), Error> { use std::os::unix::fs::MetadataExt; let owner = dir .try_clone() @@ -148,7 +203,7 @@ fn private(dir: &Dir, file: &File, directory: bool) -> Result<(), Error> { Ok(()) } #[cfg(windows)] -fn private(_dir: &Dir, file: &File, directory: bool) -> Result<(), Error> { +pub(super) fn private(_dir: &Dir, file: &File, directory: bool) -> Result<(), Error> { crate::memory::windows::verify_private(file).map_err(|_| Error::File)?; if file.metadata().map_err(|_| Error::Io)?.is_dir() != directory { return Err(Error::File); @@ -156,7 +211,7 @@ fn private(_dir: &Dir, file: &File, directory: bool) -> Result<(), Error> { Ok(()) } #[cfg(not(any(unix, windows)))] -fn private(_dir: &Dir, _file: &File, _directory: bool) -> Result<(), Error> { +pub(super) fn private(_dir: &Dir, _file: &File, _directory: bool) -> Result<(), Error> { Err(Error::File) } @@ -166,7 +221,7 @@ fn child(parent: &Dir, name: &str) -> Result { { use crate::memory::{MemoryStorageError, windows as native}; let owner = parent.try_clone().map_err(|_| Error::Io)?.into_std_file(); - match native::open_private_directory(&owner, name, native::Access::DataWrite) { + match native::open_pinned_private_directory(&owner, name, true) { Ok(file) => Ok(Dir::from_std_file(file)), Err(MemoryStorageError::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => { let created = @@ -174,7 +229,7 @@ fn child(parent: &Dir, name: &str) -> Result { native::verify_private(&created).map_err(|_| Error::File)?; let expected = native::identity(&created).map_err(|_| Error::File)?; drop(created); - let file = native::open_private_directory(&owner, name, native::Access::DataWrite) + let file = native::open_pinned_private_directory(&owner, name, true) .map_err(|_| Error::File)?; if native::identity(&file).map_err(|_| Error::File)? != expected { return Err(Error::Changed); @@ -265,6 +320,22 @@ fn read_file(dir: &Dir, name: &str) -> Result { Ok(file) } } +#[cfg(any(target_os = "macos", target_os = "linux"))] +pub(super) fn small_record(dir: &Dir, name: &str, limit: u64) -> Result, Error> { + let file = read_file(dir, name)?; + if file.metadata().map_err(|_| Error::Io)?.len() > limit { + return Err(Error::Limit); + } + let mut bytes = vec![]; + file.take(limit + 1) + .read_to_end(&mut bytes) + .map_err(|_| Error::Io)?; + if bytes.len() as u64 > limit { + return Err(Error::Limit); + } + Ok(bytes) +} + fn flush_file(file: &File) -> Result<(), Error> { file.sync_all().map_err(|_| Error::Io)?; #[cfg(target_os = "macos")] @@ -354,7 +425,7 @@ impl Store { lock.try_lock().map_err(|_| Error::Limit)?; flush_dir(&dir)?; flush_dir(&root)?; - flush_dir(&data)?; + flush_namespace(&data)?; let store = Self { data_path, data, diff --git a/openspec/changes/add-binary-hex-editor/implementation.md b/openspec/changes/add-binary-hex-editor/implementation.md index 137e9c39..2d03ebe6 100644 --- a/openspec/changes/add-binary-hex-editor/implementation.md +++ b/openspec/changes/add-binary-hex-editor/implementation.md @@ -262,3 +262,21 @@ directory routing handle. Directory flush now obtains a readable descriptor for A Linux-only regression proves the direct descriptor fails with `EBADF`, then flushes the pinned directory successfully after its ambient pathname is replaced. The remote full storage/save proof must pass before Linux support is accepted. + + +Recovery follow-up adds an atomic bounded owner header before preparation, paged +restart discovery that works while documents are exclusively open, explicit manual +versus model-invocation namespaces, and separate exact Session Location/project-root +bindings (including nested Locations). Explicit discard checkpoints unsaved commands +without source mutation or deletion of receipts/preimages; unresolved intents refuse +discard. New regressions cover scope isolation, active-document listing, pagination, +interrupted preparation, restart discard and idempotent discard retry. + +At `38214334`, dedicated Linux and macOS native CI passed. Windows native execution +exposed startup-container flushing: an inherited data namespace is not a private +payload directory. The follow-up opens that selected namespace with read/write +metadata access and uses the existing native namespace flush, while private roots +use private flushes and deny-delete directory pins. Windows results at the corrected +head remain pending. Local full core tests before this Windows-specific adjustment +passed 368 tests (one child helper); the later binary selection passed 51 tests +(one child helper). These counts include the new recovery regressions. From eef133958e9c5a62169b8174db50a6e548cf2225 Mon Sep 17 00:00:00 2001 From: Edvane Cristina Santos Daltro Date: Sun, 1 Nov 2026 19:11:31 -0400 Subject: [PATCH 5/5] Coordinate editor mutations and retain binary edit ceilings --- .config/nextest.toml | 1 + .github/workflows/binary-editor-proof.yml | 6 + crates/cyber-app/tests/files.rs | 55 ++++++ crates/cyber-core/src/binary/native.rs | 29 +++ crates/cyber-server/src/lib.rs | 2 + crates/cyber-server/src/workspace.rs | 76 ++++++++ crates/cyber-snapshot/src/lib.rs | 22 +-- crates/cyber-snapshot/tests/snapshots.rs | 30 +++ crates/cyber-tools/src/host.rs | 2 +- crates/cyber-tools/src/host/files.rs | 89 +++++++-- crates/cyber-tools/src/sandboxing.rs | 31 +++ crates/cyber-tools/src/tools/fs.rs | 4 + crates/cyber-tools/src/tools/patch.rs | 3 + crates/cyber-tools/tests/workspace_edits.rs | 178 ++++++++++++++++++ .../add-binary-hex-editor/implementation.md | 35 ++++ .../changes/add-binary-hex-editor/tasks.md | 4 +- 16 files changed, 528 insertions(+), 39 deletions(-) create mode 100644 crates/cyber-server/src/workspace.rs create mode 100644 crates/cyber-tools/tests/workspace_edits.rs diff --git a/.config/nextest.toml b/.config/nextest.toml index 1c706b0a..c2b084b4 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -200,6 +200,7 @@ binary_id(cyber-account) | binary_id(cyber-tools::messaging_tools) | binary_id(cyber-tools::monitor) | binary_id(cyber-tools::notebook) +| binary_id(cyber-tools::workspace_edits) | binary_id(cyber-tools::notify) | binary_id(cyber-tools::ordered_permissions) | binary_id(cyber-tools::parent_modes) diff --git a/.github/workflows/binary-editor-proof.yml b/.github/workflows/binary-editor-proof.yml index a7a2cd0e..762e1508 100644 --- a/.github/workflows/binary-editor-proof.yml +++ b/.github/workflows/binary-editor-proof.yml @@ -40,6 +40,12 @@ jobs: run: | set -euo pipefail cargo test --locked -p cyber-core --lib binary:: -- --nocapture 2>&1 | tee binary-editor-proof.log + - name: Prove cross-filesystem refusal on actual Linux mounts + if: runner.os == 'Linux' + shell: bash + run: | + set -euo pipefail + cargo test --locked -p cyber-core --lib binary::native::tests::native_cross_filesystem_replacement_refuses_without_copy_delete_fallback -- --ignored --exact --nocapture 2>&1 | tee -a binary-editor-proof.log - name: Retain native evidence if: always() uses: actions/upload-artifact@v4 diff --git a/crates/cyber-app/tests/files.rs b/crates/cyber-app/tests/files.rs index da9b5d3d..b8ab5c8b 100644 --- a/crates/cyber-app/tests/files.rs +++ b/crates/cyber-app/tests/files.rs @@ -936,3 +936,58 @@ async fn binary_private_root_is_hidden_before_drafts_exist_even_with_readable_pa sandbox.readable_paths.push(private.clone()); assert!(sandbox.unreadable(&f.root.join("home")).contains(&private)); } + +#[tokio::test] +async fn owner_binary_edit_permission_remains_manual_in_auto_mode_without_inference() { + let f = Fixture::with(json!({"read": "allow", "edit": "ask"})).await; + let (status, session) = f + .send( + "POST", + "/sessions", + Some(json!({"model": "test/main", "mode": "auto"})), + ) + .await; + assert_eq!(status, 201, "{session}"); + let id = session["data"]["id"].as_str().unwrap(); + let turn = f + ._app + .runtime + .file_turn(Some(id), &f.project) + .await + .unwrap(); + let runtime = f._app.runtime.clone(); + let path = f.project.join("blob.bin"); + let authorize = tokio::spawn(async move { runtime.authorize_file(&turn, "edit", &path).await }); + let mut asked = vec![]; + for _ in 0..500 { + asked = pending(&f, id).await; + if !asked.is_empty() || authorize.is_finished() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert_eq!(asked.len(), 1, "manual permission was not requested"); + assert_eq!( + asked[0]["metadata"]["requires_confirmation"], true, + "{asked:?}" + ); + assert!( + f._model.requests().is_empty(), + "owner operation invoked a provider" + ); + let request = asked[0]["id"].as_str().unwrap(); + let (status, result) = f + .send( + "POST", + &format!("/sessions/{id}/permissions/{request}/reply"), + Some(json!({"reply": "once"})), + ) + .await; + assert_eq!(status, 204, "{result}"); + authorize.await.unwrap().unwrap(); + assert!(f._model.requests().is_empty()); + assert_eq!( + std::fs::read(f.project.join("blob.bin")).unwrap(), + [0, 159, 146, 150] + ); +} diff --git a/crates/cyber-core/src/binary/native.rs b/crates/cyber-core/src/binary/native.rs index f1d38299..21947b23 100644 --- a/crates/cyber-core/src/binary/native.rs +++ b/crates/cyber-core/src/binary/native.rs @@ -437,6 +437,35 @@ mod tests { b"external" ); } + #[cfg(target_os = "linux")] + #[test] + #[ignore = "requires /dev/shm on a distinct filesystem; run explicitly in native CI"] + fn native_cross_filesystem_replacement_refuses_without_copy_delete_fallback() { + let (root, target, _private) = setup(); + let staging = tempfile::Builder::new() + .prefix("cyber-binary-cross-fs-") + .tempdir_in("/dev/shm") + .unwrap(); + let private = Dir::open_ambient_dir(staging.path(), cap_std::ambient_authority()).unwrap(); + assert_ne!( + std::fs::metadata(root.path()).unwrap().dev(), + std::fs::metadata(staging.path()).unwrap().dev(), + "proof requires distinct filesystems" + ); + std::fs::write(staging.path().join("replacement"), b"after!").unwrap(); + assert!(matches!(target.same_filesystem(&private), Err(Error::File))); + assert!(matches!( + exchange(&private, "replacement", &target), + Err(Error::File) + )); + assert_eq!( + std::fs::read(root.path().join("target")).unwrap(), + b"before" + ); + assert_eq!(private.read("replacement").unwrap(), b"after!"); + target.validate().unwrap(); + } + #[cfg(target_os = "linux")] #[test] fn native_linux_posix_acl_is_fingerprinted_and_preserved_without_data_copy() { diff --git a/crates/cyber-server/src/lib.rs b/crates/cyber-server/src/lib.rs index 22d942b1..510fc800 100644 --- a/crates/cyber-server/src/lib.rs +++ b/crates/cyber-server/src/lib.rs @@ -4,3 +4,5 @@ pub mod http; pub mod runtime; pub mod worktrees; + +pub mod workspace; diff --git a/crates/cyber-server/src/workspace.rs b/crates/cyber-server/src/workspace.rs new file mode 100644 index 00000000..5c6a5cf8 --- /dev/null +++ b/crates/cyber-server/src/workspace.rs @@ -0,0 +1,76 @@ +//! Process-local coordination shared by in-process editors and snapshot operations. +//! Native Location ownership and source identity/content checks are still required: +//! this lock cannot serialize arbitrary external processes or their open file handles. +use std::collections::HashMap; +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, OnceLock, PoisonError, Weak}; +use tokio::sync::Mutex as AsyncMutex; + +type Locks = HashMap>>; +static LOCKS: OnceLock> = OnceLock::new(); + +/// Git Locations share their worktree root; non-Git Locations use their exact +/// canonical directory. Callers acquire this before any individual path lock. +pub fn mutation_lock(location: &Path) -> io::Result>> { + let root = cyber_core::project::worktree_root(location) + .unwrap_or_else(|| location.to_path_buf()) + .canonicalize()?; + let mut locks = LOCKS + .get_or_init(Mutex::default) + .lock() + .unwrap_or_else(PoisonError::into_inner); + locks.retain(|_, lock| lock.strong_count() != 0); + if let Some(lock) = locks.get(&root).and_then(Weak::upgrade) { + return Ok(lock); + } + if locks.len() >= 1024 { + return Err(io::Error::other("workspace mutation capacity exceeded")); + } + let lock = Arc::new(AsyncMutex::new(())); + locks.insert(root, Arc::downgrade(&lock)); + Ok(lock) +} + +#[cfg(test)] +mod tests { + use super::*; + #[tokio::test] + async fn separate_owners_share_one_lock_until_the_owned_worker_releases_it() { + let root = tempfile::tempdir().unwrap(); + let first = mutation_lock(root.path()).unwrap(); + let second = mutation_lock(root.path()).unwrap(); + assert!(Arc::ptr_eq(&first, &second)); + let held = first.lock_owned().await; + assert!(second.try_lock().is_err()); + let (release, wait) = tokio::sync::oneshot::channel(); + let worker = tokio::task::spawn_blocking(move || { + let _held = held; + wait.blocking_recv().unwrap(); + }); + // The native worker owns the guard independently of any request future. + assert!(second.try_lock().is_err()); + release.send(()).unwrap(); + worker.await.unwrap(); + assert!(second.try_lock().is_ok()); + let other = tempfile::tempdir().unwrap(); + assert!(!Arc::ptr_eq(&second, &mutation_lock(other.path()).unwrap())); + } + #[test] + fn nested_git_locations_share_the_checkout_lock() { + let root = tempfile::tempdir().unwrap(); + assert!( + std::process::Command::new("git") + .args(["init", "-q"]) + .arg(root.path()) + .status() + .unwrap() + .success() + ); + let nested = root.path().join("nested"); + std::fs::create_dir(&nested).unwrap(); + let a = mutation_lock(root.path()).unwrap(); + let b = mutation_lock(&nested).unwrap(); + assert!(Arc::ptr_eq(&a, &b)); + } +} diff --git a/crates/cyber-snapshot/src/lib.rs b/crates/cyber-snapshot/src/lib.rs index f9404408..c9ff0b18 100644 --- a/crates/cyber-snapshot/src/lib.rs +++ b/crates/cyber-snapshot/src/lib.rs @@ -7,9 +7,8 @@ mod git; mod restore; -use std::collections::HashMap; use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex, PoisonError}; +use std::sync::Arc; use std::time::Duration; use cyber_server::runtime::{FileDiff, RestoreError, Snapshot, Snapshots}; @@ -26,18 +25,12 @@ const DEFAULT_MAX_FILE_BYTES: u64 = 2 * 1024 * 1024; pub struct GitSnapshots { data_dir: PathBuf, config: Arc, - /// Workspace mutation locks, one per worktree. - locks: Mutex>>>, } impl GitSnapshots { /// `data_dir` is the Cyber data directory; snapshots live under `/snapshot`. pub fn new(data_dir: PathBuf, config: Arc) -> Arc { - Arc::new(Self { - data_dir, - config, - locks: Mutex::default(), - }) + Arc::new(Self { data_dir, config }) } fn root(&self) -> PathBuf { @@ -53,11 +46,6 @@ impl GitSnapshots { Shadow::locate(&self.root(), location) } - fn lock(&self, worktree: &Path) -> Arc> { - let mut locks = self.locks.lock().unwrap_or_else(PoisonError::into_inner); - Arc::clone(locks.entry(worktree.to_path_buf()).or_default()) - } - fn options(&self, location: &Path) -> (Vec, u64) { let config = (self.config)(location); let ignore = config @@ -103,7 +91,8 @@ impl Snapshots for GitSnapshots { return Ok(None); }; let (ignore, max) = self.options(Path::new(&directory)); - let lock = self.lock(&shadow.worktree); + let lock = cyber_server::workspace::mutation_lock(&shadow.worktree) + .map_err(|error| error.to_string())?; let _guard = lock.lock().await; shadow.track(&ignore, max).await.map(Some) }) @@ -154,7 +143,8 @@ impl Snapshots for GitSnapshots { let shadow = self.shadow(&directory).ok_or_else(|| { RestoreError::Failed("snapshots are unavailable for this Location".into()) })?; - let lock = self.lock(&shadow.worktree); + let lock = cyber_server::workspace::mutation_lock(&shadow.worktree) + .map_err(|error| RestoreError::Failed(error.to_string()))?; let _guard = lock.lock().await; let backups = self .root() diff --git a/crates/cyber-snapshot/tests/snapshots.rs b/crates/cyber-snapshot/tests/snapshots.rs index b6fa3dcf..1fa3bee7 100644 --- a/crates/cyber-snapshot/tests/snapshots.rs +++ b/crates/cyber-snapshot/tests/snapshots.rs @@ -221,3 +221,33 @@ async fn gc_runs_on_shadow_repositories() { r.snaps.gc().await; assert!(r.data.join("snapshot").exists()); } + +#[tokio::test] +async fn snapshot_restore_waits_for_the_shared_workspace_mutation_owner() { + use std::time::Duration; + let r = Repo::new(); + let before = r.track().await; + let original = std::fs::read(r.root.join("a.txt")).unwrap(); + std::fs::write(r.root.join("a.txt"), "changed by an editor\n").unwrap(); + let after = r.track().await; + let lock = cyber_server::workspace::mutation_lock(&r.root).unwrap(); + let held = lock.lock_owned().await; + let directory = r.dir(); + let restore = r.snaps.restore(&directory, &before, &after); + tokio::pin!(restore); + assert!( + tokio::time::timeout(Duration::from_millis(100), &mut restore) + .await + .is_err() + ); + assert_eq!( + std::fs::read(r.root.join("a.txt")).unwrap(), + b"changed by an editor\n" + ); + drop(held); + tokio::time::timeout(Duration::from_secs(5), restore) + .await + .unwrap() + .unwrap(); + assert_eq!(std::fs::read(r.root.join("a.txt")).unwrap(), original); +} diff --git a/crates/cyber-tools/src/host.rs b/crates/cyber-tools/src/host.rs index 3cb65c06..dfbb68a5 100644 --- a/crates/cyber-tools/src/host.rs +++ b/crates/cyber-tools/src/host.rs @@ -934,7 +934,7 @@ impl ToolHost for BuiltinHost { asker: cyber_server::runtime::Asker, cancel: CancellationToken, ) -> BoxFuture<'a, Result<(), String>> { - Box::pin(self.authorize_browse(turn, action, path, asker, cancel)) + Box::pin(self.authorize_browse(turn, action, path, asker, cancel, Value::Null)) } fn resolve_prompt_resources<'a>( diff --git a/crates/cyber-tools/src/host/files.rs b/crates/cyber-tools/src/host/files.rs index c04e4510..0f9cbc56 100644 --- a/crates/cyber-tools/src/host/files.rs +++ b/crates/cyber-tools/src/host/files.rs @@ -14,7 +14,11 @@ use crate::tools::ToolError; /// A synthetic call of the `read` tool (or `list`) on `path`. fn invocation(turn: &TurnContext, action: &str, path: &Path, asker: Asker) -> Invocation { - let name = if action == "list" { "list" } else { "read" }; + let name = match action { + "list" => "list", + "edit" => "edit", + _ => "read", + }; Invocation { tool_output: Default::default(), registration: None, @@ -57,11 +61,14 @@ fn decide(policy: &Policy, location: &Path, tool: &str, action: &str, path: &Pat let resource = path .strip_prefix(location) .map_or_else(|_| permissions::slash(&path), permissions::slash); + let edit = action == "edit"; let request = |action: &str, resource: String| Request { tool: Some(tool.into()), action: action.into(), resources: vec![resource], - read_only: true, + read_only: !edit, + file_edit: edit, + mutates: if edit { vec![path.clone()] } else { vec![] }, ..Request::default() }; let decision = file_decision(policy.decide(&request(action, resource))); @@ -89,20 +96,50 @@ impl BuiltinHost { self.policy_for(inv, agent).await } + async fn browse_context<'a>( + &'a self, + inv: &'a Invocation, + cancel: CancellationToken, + ) -> Result, String> { + Ok(Ctx { + skill_paths: Default::default(), + loaded_skill: Default::default(), + provenance: Default::default(), + compiler_feedback: Default::default(), + hook_decision: None, + denial: Default::default(), + host: self, + policy: self.browse_policy(inv).await?, + location: canonical(Path::new(&inv.directory)), + inv, + cancel, + }) + } + pub(crate) async fn decide_files( &self, turn: &TurnContext, action: &str, paths: &[PathBuf], ) -> Vec { + if !matches!(action, "read" | "list" | "edit") { + return vec![FileDecision::Deny; paths.len()]; + } let inv = invocation(turn, action, Path::new(&turn.directory), Asker::detached()); - let Ok(policy) = self.browse_policy(&inv).await else { + let Ok(ctx) = self.browse_context(&inv, CancellationToken::new()).await else { return vec![FileDecision::Deny; paths.len()]; }; let location = canonical(Path::new(&turn.directory)); paths .iter() - .map(|path| decide(&policy, &location, &inv.name, action, path)) + .map(|path| { + if action == "edit" + && crate::sandboxing::binary_edit_boundary(&ctx, &canonical(path)).is_err() + { + return FileDecision::Deny; + } + decide(&ctx.policy, &location, &inv.name, action, path) + }) .collect() } @@ -115,32 +152,44 @@ impl BuiltinHost { path: &Path, asker: Asker, cancel: CancellationToken, + mut metadata: Value, ) -> Result<(), String> { + if !matches!(action, "read" | "list" | "edit") { + return Err("Invalid file action".into()); + } + if action == "edit" { + // An owner editor operation has its own explicit review. A normal + // permission ask remains manual; it must not start classifier inference. + if !metadata.is_object() { + metadata = json!({}); + } + metadata["requires_confirmation"] = json!(true); + } let inv = invocation(turn, action, path, asker); - let ctx = Ctx { - skill_paths: Default::default(), - loaded_skill: Default::default(), - provenance: Default::default(), - compiler_feedback: Default::default(), - hook_decision: None, - denial: Default::default(), - host: self, - policy: self.browse_policy(&inv).await?, - location: canonical(Path::new(&inv.directory)), - inv: &inv, - cancel, - }; + let ctx = self.browse_context(&inv, cancel).await?; let path = canonical(path); let resource = ctx.resource(&path); let request = Request { action: action.into(), resources: vec![resource.clone()], - read_only: true, + read_only: action != "edit", + file_edit: action == "edit", + mutates: if action == "edit" { + vec![path.clone()] + } else { + vec![] + }, ..Request::default() }; let outcome = async { - ctx.check_external(std::slice::from_ref(&path)).await?; - ctx.authorize(request, vec![resource], Value::Null).await + if action == "edit" { + crate::sandboxing::binary_edit_boundary(&ctx, &path).map_err(ToolError::Failed)?; + ctx.check_external_writes(std::slice::from_ref(&path)) + .await?; + } else { + ctx.check_external(std::slice::from_ref(&path)).await?; + } + ctx.authorize(request, vec![resource], metadata).await }; match outcome.await { Ok(()) => Ok(()), diff --git a/crates/cyber-tools/src/sandboxing.rs b/crates/cyber-tools/src/sandboxing.rs index b547cb7e..941bbc40 100644 --- a/crates/cyber-tools/src/sandboxing.rs +++ b/crates/cyber-tools/src/sandboxing.rs @@ -89,6 +89,37 @@ fn resolve( ) } +/// In-process binary writes retain the selected sandbox filesystem ceiling. +/// Private storage IO is privileged host work; this check covers the source target. +pub(crate) fn binary_edit_boundary(ctx: &Ctx<'_>, path: &Path) -> Result<(), String> { + let (config, sources) = configuration(ctx)?; + let sandbox = resolve(ctx, &config, &sources); + if sandbox.error.is_some() || sandbox.policy == Policy::ReadOnly { + return Err("Binary editing is unavailable under the selected sandbox".into()); + } + let hidden = sandbox + .deny_read_for(&ctx.location, &ctx.host.opts.home) + .with_hidden_paths(&sandbox.unreadable(&ctx.host.opts.home)); + if hidden.denies(path) { + return Err("Sandbox denied: binary target is private".into()); + } + if sandbox.policy == Policy::WorkspaceWrite { + let writable = std::iter::once(&ctx.location).chain(sandbox.extra_writable.iter()); + let admitted = writable + .filter_map(|root| root.canonicalize().ok()) + .any(|root| { + path.starts_with(&root) + && !protected_in(&root) + .iter() + .any(|private| path.starts_with(private)) + }); + if !admitted { + return Err("Sandbox denied: binary target is not writable".into()); + } + } + Ok(()) +} + /// The Workflow Run of the Session a tool process was started for /// (`workflows` → agent() call): set for workflow agents and their subagents only. pub(crate) const WORKFLOW_RUN_ENV: &str = "CYBER_WORKFLOW_RUN_ID"; diff --git a/crates/cyber-tools/src/tools/fs.rs b/crates/cyber-tools/src/tools/fs.rs index b0ce43b6..02fcf1b6 100644 --- a/crates/cyber-tools/src/tools/fs.rs +++ b/crates/cyber-tools/src/tools/fs.rs @@ -375,6 +375,9 @@ pub(crate) async fn guarded_write( ctx.check_external_writes(&[path.to_path_buf()]).await?; authorize_edit(ctx, path, diff).await?; let origin = super::intelligence::capture(ctx, path); + let workspace = cyber_server::workspace::mutation_lock(&ctx.location) + .map_err(|error| failed(error.to_string()))?; + let workspace_guard = workspace.lock_owned().await; let lock = ctx.host.path_lock(path); let _guard = lock.lock().await; let current = tokio::fs::read(path).await.ok(); @@ -394,6 +397,7 @@ pub(crate) async fn guarded_write( ctx.host.mark_read(&ctx.inv.session_id, path); ctx.note_skill_path(path); drop(_guard); + drop(workspace_guard); Ok(super::intelligence::feedback(ctx, path, after, origin).await) } diff --git a/crates/cyber-tools/src/tools/patch.rs b/crates/cyber-tools/src/tools/patch.rs index 39cab783..585de779 100644 --- a/crates/cyber-tools/src/tools/patch.rs +++ b/crates/cyber-tools/src/tools/patch.rs @@ -483,6 +483,9 @@ async fn patch_feedback( } async fn write_one(ctx: &Ctx<'_>, change: &Change) -> Result<(), String> { + let workspace = + cyber_server::workspace::mutation_lock(&ctx.location).map_err(|error| error.to_string())?; + let _workspace = workspace.lock_owned().await; let lock = ctx.host.path_lock(&change.path); let _guard = lock.lock().await; if tokio::fs::read(&change.path).await.ok() != change.before { diff --git a/crates/cyber-tools/tests/workspace_edits.rs b/crates/cyber-tools/tests/workspace_edits.rs new file mode 100644 index 00000000..2859c646 --- /dev/null +++ b/crates/cyber-tools/tests/workspace_edits.rs @@ -0,0 +1,178 @@ +//! In-process editors participate in the same workspace mutation gate as snapshots +//! and owner binary saves; permission waits happen before acquiring it. +mod support; +use serde_json::json; +use std::time::Duration; +use support::{Fixture, ok}; + +#[tokio::test] +async fn text_write_waits_for_the_owned_workspace_guard_then_rechecks_and_writes() { + let fixture = Fixture::new(); + fixture.set_config(json!({"permissions": {"*": "allow"}})); + let lock = cyber_server::workspace::mutation_lock(&fixture.repo).unwrap(); + let held = lock.lock_owned().await; + let write = fixture.call( + "accept-edits", + "write", + json!({"path": "queued.txt", "content": "after native settlement"}), + ); + tokio::pin!(write); + assert!( + tokio::time::timeout(Duration::from_millis(100), &mut write) + .await + .is_err() + ); + assert!(!fixture.repo.join("queued.txt").exists()); + drop(held); + ok(tokio::time::timeout(Duration::from_secs(5), write) + .await + .unwrap()); + assert_eq!(fixture.read("queued.txt"), "after native settlement"); +} + +#[tokio::test] +async fn patch_waits_for_workspace_settlement_and_preserves_a_later_external_edit() { + let fixture = Fixture::new(); + fixture.set_config(json!({"permissions": {"*": "allow"}})); + fixture.write("queued.txt", "original\n"); + let lock = cyber_server::workspace::mutation_lock(&fixture.repo).unwrap(); + let held = lock.lock_owned().await; + let patch = fixture.call("accept-edits", "apply_patch", json!({"patch": "*** Begin Patch\n*** Update File: queued.txt\n@@\n-original\n+proposed\n*** End Patch"})); + tokio::pin!(patch); + assert!( + tokio::time::timeout(Duration::from_millis(100), &mut patch) + .await + .is_err() + ); + assert_eq!(fixture.read("queued.txt"), "original\n"); + fixture.write("queued.txt", "external\n"); + drop(held); + let outcome = tokio::time::timeout(Duration::from_secs(5), patch) + .await + .unwrap(); + assert!( + !matches!(outcome, cyber_server::runtime::ToolOutcome::Ok(_)), + "stale patch succeeded: {outcome:?}" + ); + assert_eq!(fixture.read("queued.txt"), "external\n"); +} + +fn turn(fixture: &Fixture, mode: &str) -> cyber_server::runtime::TurnContext { + cyber_server::runtime::TurnContext { + session_id: "ses_test".into(), + directory: fixture.repo.display().to_string(), + agent: "build".into(), + mode: mode.into(), + prefers_apply_patch: false, + rules: serde_json::Value::Null, + worktree: Default::default(), + } +} + +#[tokio::test] +async fn binary_edit_file_admission_retains_plan_sandbox_and_private_path_ceilings() { + use cyber_server::runtime::{Asker, FileDecision, ToolHost}; + use tokio_util::sync::CancellationToken; + let fixture = Fixture::new(); + fixture.set_config(json!({"permissions": {"*": "allow"}})); + fixture.write("bytes.bin", "source"); + let paths = vec![fixture.repo.join("bytes.bin")]; + let plan = turn(&fixture, "plan"); + assert_eq!( + fixture.host.file_decisions(&plan, "read", &paths).await, + vec![FileDecision::Allow] + ); + assert_eq!( + fixture.host.file_decisions(&plan, "edit", &paths).await, + vec![FileDecision::Deny] + ); + assert!( + fixture + .host + .authorize_file( + &plan, + "edit", + &paths[0], + Asker::detached(), + CancellationToken::new() + ) + .await + .is_err() + ); + let edit = turn(&fixture, "accept-edits"); + assert_eq!( + fixture.host.file_decisions(&edit, "edit", &paths).await, + vec![FileDecision::Allow] + ); + fixture + .host + .authorize_file( + &edit, + "edit", + &paths[0], + Asker::detached(), + CancellationToken::new(), + ) + .await + .unwrap(); + assert_eq!( + fixture.host.file_decisions(&edit, "bash", &paths).await, + vec![FileDecision::Deny] + ); + let private = fixture.repo.join("private"); + std::fs::create_dir(&private).unwrap(); + let home = fixture.repo.parent().unwrap().join("home"); + cyber_sandbox::hide_credential_file(&home, private.clone()); + let secret = private.join("draft.json"); + std::fs::write(&secret, "unsaved bytes").unwrap(); + assert_eq!( + fixture + .host + .file_decisions(&edit, "edit", std::slice::from_ref(&secret)) + .await, + vec![FileDecision::Deny] + ); + assert!( + fixture + .host + .authorize_file( + &edit, + "edit", + &secret, + Asker::detached(), + CancellationToken::new() + ) + .await + .is_err() + ); + let readonly = Fixture::with_policy( + "bash", + cyber_sandbox::find_helper(), + Some("read-only".into()), + ); + readonly.set_config(json!({"permissions": {"*": "allow"}})); + readonly.write("bytes.bin", "source"); + let at = readonly.repo.join("bytes.bin"); + let context = turn(&readonly, "accept-edits"); + assert_eq!( + readonly + .host + .file_decisions(&context, "edit", std::slice::from_ref(&at)) + .await, + vec![FileDecision::Deny] + ); + assert!( + readonly + .host + .authorize_file( + &context, + "edit", + &at, + Asker::detached(), + CancellationToken::new() + ) + .await + .is_err() + ); + assert_eq!(readonly.read("bytes.bin"), "source"); +} diff --git a/openspec/changes/add-binary-hex-editor/implementation.md b/openspec/changes/add-binary-hex-editor/implementation.md index 2d03ebe6..17a2bef3 100644 --- a/openspec/changes/add-binary-hex-editor/implementation.md +++ b/openspec/changes/add-binary-hex-editor/implementation.md @@ -280,3 +280,38 @@ use private flushes and deny-delete directory pins. Windows results at the corre head remain pending. Local full core tests before this Windows-specific adjustment passed 368 tests (one child helper); the later binary selection passed 51 tests (one child helper). These counts include the new recovery regressions. + + +Shared mutation admission now uses one bounded process-local workspace lock for +Git snapshot capture/restore and in-process text write/edit/patch operations. Git +Locations below the same checkout share its canonical root. This is additional to +existing per-path and native Location ownership, and will be retained by binary +save workers through settlement. It does not claim coordination with arbitrary +external processes. The lock is released before LSP diagnostic feedback. + +Local regressions passed: two retained-worker/checkout identity tests, two actual +text-write/patch serialization tests (including stale preimage refusal after a +queued external edit), and all nine real Git snapshot integration tests including +restore waiting for the shared workspace owner. Native save admission is still not +exposed by production routes. + + +The file-admission host now treats `edit` as a mutation rather than a synthetic +read. Current/ancestor Plan ceilings, declared edit rules, ownership mutation paths, +read-only sandbox profiles, hidden paths and protected workspace metadata remain +effective. Unknown file actions refuse. The new gate regression includes successful +read/edit controls and explicit Plan/private/read-only refusals without writing the +source. All three text/patch/admission tests passed; they are included in the native +Windows curated suite. Production binary writes are still not activated. + + +Dedicated native proof at `2f8ae42f` passed on Linux, macOS and Windows. This accepts +core storage/draft proof on Windows, not Windows replacement support. An additional +explicit Linux `/dev/shm` cross-filesystem refusal proof is configured for the next +CI run; its required distinct-mount negative control cannot be inferred locally. + +Owner edit authorization marks its permission review as requiring confirmation, +so an owner operation cannot invoke the Auto-mode classifier. A production App +regression verifies the exact manual permission request and zero provider requests +before/after approval. Broad core task items 3.1/3.2 now reflect implemented draft +and review engines. Save admission and all delivery/integration tasks remain open. diff --git a/openspec/changes/add-binary-hex-editor/tasks.md b/openspec/changes/add-binary-hex-editor/tasks.md index a9c7c51e..8850669f 100644 --- a/openspec/changes/add-binary-hex-editor/tasks.md +++ b/openspec/changes/add-binary-hex-editor/tasks.md @@ -20,8 +20,8 @@ staged-edit/save/model/integration requirements below. ## 3. Editing, review, save and recovery -- [ ] 3.1 Add expected-revision overwrite/insert/delete splice batches, exact preimage checks, bounded payloads and atomic draft undo/redo. -- [ ] 3.2 Add stable byte-diff review with original offsets, base/proposed fingerprints, length/changed-range totals, metadata and effect disclosure. +- [x] 3.1 Add expected-revision overwrite/insert/delete splice batches, exact preimage checks, bounded payloads and atomic draft undo/redo. +- [x] 3.2 Add stable byte-diff review with original offsets, base/proposed fingerprints, length/changed-range totals, metadata and effect disclosure. - [ ] 3.3 Implement edit permission/Mode/checkout lease gates, capacity reservation and journaled save with operation idempotency and queryable acknowledged/conflict/unknown outcomes. - [ ] 3.4 Preserve dirty drafts and unresolved receipts over disconnect/restart; add explicit discard and reviewed Restore Last Save without unguarded overwrite. - [ ] 3.5 Test stale revision/review, same-size/same-mtime edits, all-or-nothing multi-splice changes, concurrent Sessions/external writers, cancel/kill cuts, retry identity and backup integrity.