Skip to main content

prikk_object/payload/
patch.rs

1//! Patch payload types.
2//!
3//! Split into two files (DC-58): this file keeps `PatchPayload`/`PatchPurpose`/`Operation`/
4//! `OperationKind` and the text-span helpers; `patch/operations.rs` holds the seven per-kind
5//! payload structs (`CreateFile`, `DeleteNode`, …). All items stay `pub` and are re-exported here
6//! at the same path (`payload::patch::*`), so every existing caller — including the crate-root
7//! re-export at `payload.rs` — is unaffected. No behaviour change.
8
9use prikk_error::{PrikkError, Result};
10
11use crate::canonical::{is_contiguous_op_seq, is_strictly_sorted};
12use crate::payload::common::{Intent, OperationCondition, OperationConditionEntry};
13use crate::{CanonicalEncode, CanonicalWriter, WireType};
14
15mod operations;
16
17pub use operations::{
18    ChangePerm, CreateFile, CreateSymlink, DeleteNode, DeleteNodePreimage, EditText, RenamePath,
19    ReplaceBinary,
20};
21
22/// Number of bytes in a content-anchored text span hash.
23pub const TEXT_SPAN_HASH_BYTES: usize = 32;
24
25/// Compute the stable hash used by content-anchored text edit preconditions.
26#[must_use]
27pub fn text_span_hash(bytes: &[u8]) -> [u8; TEXT_SPAN_HASH_BYTES] {
28    prikk_hash::sha256(bytes)
29}
30
31/// Validate a stable content-anchor identifier.
32pub fn validate_text_anchor_id(value: &str) -> Result<()> {
33    if value.is_empty() {
34        return Err(PrikkError::CanonicalEncoding(
35            "text anchor id must not be empty".to_string(),
36        ));
37    }
38    if !value.is_ascii() {
39        return Err(PrikkError::CanonicalEncoding(
40            "text anchor id must be ASCII in v1".to_string(),
41        ));
42    }
43    if value.bytes().any(|byte| byte < 0x21 || byte == 0x7f) {
44        return Err(PrikkError::CanonicalEncoding(
45            "text anchor id must not contain whitespace or control characters".to_string(),
46        ));
47    }
48    Ok(())
49}
50
51/// `Patch` schema at and above which field 2 (`parent_patch_ids`) is retired — the opposite
52/// direction from [`crate::REF_STATE_CLOSED_SCHEMA`], which admits a field starting at its
53/// threshold rather than retiring one. Schema 1 (frozen forever, RFC 114) keeps decoding a
54/// present field 2 without inspecting it, exactly as before this schema existed, so every patch
55/// already written keeps decoding unchanged. Schema 2 refuses field 2's mere presence outright —
56/// see `decode_patch_operations` (`prikk-store`). **Field number 2 is retired, not reused**: no
57/// future schema may repurpose tag 2 for something else, since a schema-1 reader would silently
58/// misinterpret it as the old `parent_patch_ids` shape.
59pub const PATCH_PARENT_IDS_RETIRED_SCHEMA: u32 = 2;
60
61/// Patch payload.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct PatchPayload {
64    /// Operations in semantic order. `op_seq` must be contiguous from 1.
65    pub operations: Vec<Operation>,
66    /// Advisory intent.
67    pub intent: Option<Intent>,
68    /// Patch-level preconditions, sorted by key.
69    pub preconditions: Vec<OperationConditionEntry>,
70    /// Identity-bearing patch purpose. `Normal` is canonical by omission.
71    pub purpose: PatchPurpose,
72}
73
74impl PatchPayload {
75    /// Validate ordering and duplicate constraints.
76    pub fn validate(&self) -> Result<()> {
77        if self.operations.is_empty() {
78            return Err(PrikkError::CanonicalEncoding(
79                "patch operations must contain at least one operation".to_string(),
80            ));
81        }
82        let op_seq: Vec<u32> = self.operations.iter().map(|op| op.op_seq).collect();
83        if !is_contiguous_op_seq(&op_seq) {
84            return Err(PrikkError::CanonicalEncoding(
85                "patch operations must have contiguous op_seq values starting at 1".to_string(),
86            ));
87        }
88        if !is_strictly_sorted(&self.preconditions) {
89            return Err(PrikkError::CanonicalEncoding(
90                "patch preconditions must be sorted and unique".to_string(),
91            ));
92        }
93        Ok(())
94    }
95}
96
97impl CanonicalEncode for PatchPayload {
98    fn encode_canonical(&self, writer: &mut CanonicalWriter) -> Result<()> {
99        self.validate()?;
100        writer.repeated_record_list(1, &self.operations)?;
101        // Tag 2 (`parent_patch_ids`) is retired at `PATCH_PARENT_IDS_RETIRED_SCHEMA` and above --
102        // every construction site now writes that schema (or later), so tag 2 is never emitted
103        // here at all. See `PATCH_PARENT_IDS_RETIRED_SCHEMA`'s own doc.
104        if let Some(intent) = self.intent {
105            writer.field_enum_u16(3, intent.code())?;
106        }
107        writer.repeated_record(4, &self.preconditions)?;
108        if self.purpose != PatchPurpose::Normal {
109            writer.field_enum_u16(5, self.purpose.code())?;
110        }
111        Ok(())
112    }
113}
114
115/// Identity-bearing patch purpose.
116#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
117#[repr(u16)]
118pub enum PatchPurpose {
119    /// Ordinary Patch. This is the default when tag 5 is absent and must not be encoded explicitly.
120    Normal = 1,
121    /// Rollback draft Patch. This survives WAL-to-object persistence for classification.
122    RollbackDraft = 2,
123}
124
125impl PatchPurpose {
126    /// Stable numeric code.
127    #[must_use]
128    pub const fn code(self) -> u16 {
129        self as u16
130    }
131
132    /// Parse a stable code from a present tag-5 purpose field.
133    pub fn from_present_code(code: u16) -> Result<Self> {
134        match code {
135            1 => Err(PrikkError::CanonicalEncoding(
136                "PatchPurpose::Normal must be omitted, not encoded explicitly".to_string(),
137            )),
138            2 => Ok(Self::RollbackDraft),
139            other => Err(PrikkError::CanonicalEncoding(format!(
140                "unknown patch purpose code: {other}"
141            ))),
142        }
143    }
144
145    /// Decode only the top-level `PatchPayload` purpose field, validating tag order and rejecting
146    /// an explicitly encoded `Normal` default. Absence means `Normal`.
147    pub fn decode_from_patch_payload(bytes: &[u8]) -> Result<Self> {
148        let mut cursor = PatchPayloadFieldCursor::new(bytes);
149        let mut purpose = Self::Normal;
150        let mut seen_purpose = false;
151        while let Some(field) = cursor.next_field()? {
152            match field.tag {
153                1..=4 => {}
154                5 => {
155                    if seen_purpose {
156                        return Err(PrikkError::CanonicalEncoding(
157                            "duplicate PatchPurpose field".to_string(),
158                        ));
159                    }
160                    seen_purpose = true;
161                    field.require_wire(WireType::EnumU16)?;
162                    purpose = Self::from_present_code(field.read_u16()?)?;
163                }
164                other => {
165                    return Err(PrikkError::CanonicalEncoding(format!(
166                        "unknown PatchPayload field tag: {other}"
167                    )));
168                }
169            }
170        }
171        Ok(purpose)
172    }
173}
174
175struct PatchPayloadFieldCursor<'a> {
176    bytes: &'a [u8],
177    pos: usize,
178    last_tag: Option<u16>,
179}
180
181impl<'a> PatchPayloadFieldCursor<'a> {
182    const fn new(bytes: &'a [u8]) -> Self {
183        Self {
184            bytes,
185            pos: 0,
186            last_tag: None,
187        }
188    }
189
190    fn next_field(&mut self) -> Result<Option<PatchPayloadField<'a>>> {
191        if self.pos == self.bytes.len() {
192            return Ok(None);
193        }
194        let tag = u16::from_be_bytes(self.read_array::<2>()?);
195        if tag == 0 {
196            return Err(PrikkError::CanonicalEncoding(
197                "field tag 0 is reserved".to_string(),
198            ));
199        }
200        if let Some(last) = self.last_tag {
201            if tag < last {
202                return Err(PrikkError::CanonicalEncoding(format!(
203                    "field tag order violation: {tag} after {last}"
204                )));
205            }
206        }
207        self.last_tag = Some(tag);
208        let wire_type = self.read_u8()?;
209        let len = usize::try_from(u64::from_be_bytes(self.read_array::<8>()?)).map_err(|_| {
210            PrikkError::CanonicalEncoding("canonical field length does not fit usize".to_string())
211        })?;
212        let value = self.read_exact(len)?;
213        Ok(Some(PatchPayloadField {
214            tag,
215            wire_type,
216            value,
217        }))
218    }
219
220    fn read_u8(&mut self) -> Result<u8> {
221        let bytes = self.read_exact(1)?;
222        let Some(byte) = bytes.first() else {
223            return Err(PrikkError::CanonicalEncoding(
224                "unexpected empty byte".to_string(),
225            ));
226        };
227        Ok(*byte)
228    }
229
230    fn read_array<const N: usize>(&mut self) -> Result<[u8; N]> {
231        let bytes = self.read_exact(N)?;
232        let mut out = [0_u8; N];
233        out.copy_from_slice(bytes);
234        Ok(out)
235    }
236
237    fn read_exact(&mut self, len: usize) -> Result<&'a [u8]> {
238        let end = self
239            .pos
240            .checked_add(len)
241            .ok_or_else(|| PrikkError::CanonicalEncoding("canonical range overflow".to_string()))?;
242        let Some(slice) = self.bytes.get(self.pos..end) else {
243            return Err(PrikkError::CanonicalEncoding(
244                "unexpected end of canonical payload".to_string(),
245            ));
246        };
247        self.pos = end;
248        Ok(slice)
249    }
250}
251
252struct PatchPayloadField<'a> {
253    tag: u16,
254    wire_type: u8,
255    value: &'a [u8],
256}
257
258impl PatchPayloadField<'_> {
259    fn require_wire(&self, expected: WireType) -> Result<()> {
260        if self.wire_type == expected as u8 {
261            return Ok(());
262        }
263        Err(PrikkError::CanonicalEncoding(format!(
264            "field {} has wrong wire type: expected {}, got {}",
265            self.tag, expected as u8, self.wire_type
266        )))
267    }
268
269    fn read_u16(&self) -> Result<u16> {
270        if self.value.len() != 2 {
271            return Err(PrikkError::CanonicalEncoding(format!(
272                "field {} expected 2 bytes, got {}",
273                self.tag,
274                self.value.len()
275            )));
276        }
277        let mut out = [0_u8; 2];
278        out.copy_from_slice(self.value);
279        Ok(u16::from_be_bytes(out))
280    }
281}
282
283/// A single operation inside a patch.
284#[derive(Debug, Clone, PartialEq, Eq)]
285pub struct Operation {
286    /// Strict operation sequence, starting at 1 inside the patch.
287    pub op_seq: u32,
288    /// Optional stable label for UI/debugging.
289    pub op_id: Option<String>,
290    /// Inline operation preconditions.
291    pub preconditions: Vec<OperationCondition>,
292    /// Operation kind.
293    pub kind: OperationKind,
294}
295
296impl CanonicalEncode for Operation {
297    fn encode_canonical(&self, writer: &mut CanonicalWriter) -> Result<()> {
298        writer.field_u32(1, self.op_seq)?;
299        writer.field_string_opt(2, self.op_id.as_deref())?;
300        writer.repeated_record(3, &self.preconditions)?;
301        match &self.kind {
302            OperationKind::CreateFile(value) => writer.field_record(10, value)?,
303            OperationKind::DeleteNode(value) => writer.field_record(11, value)?,
304            OperationKind::EditText(value) => writer.field_record(12, value)?,
305            OperationKind::RenamePath(value) => writer.field_record(13, value)?,
306            OperationKind::ChangePerm(value) => writer.field_record(14, value)?,
307            OperationKind::CreateSymlink(value) => writer.field_record(15, value)?,
308            OperationKind::ReplaceBinary(value) => writer.field_record(16, value)?,
309        }
310        Ok(())
311    }
312}
313
314/// Operation variants.
315#[derive(Debug, Clone, PartialEq, Eq)]
316pub enum OperationKind {
317    /// Create a text or binary file.
318    CreateFile(CreateFile),
319    /// Delete a node.
320    DeleteNode(DeleteNode),
321    /// Edit text using content-anchored spans.
322    EditText(EditText),
323    /// Rename a path.
324    RenamePath(RenamePath),
325    /// Change Unix-like permissions.
326    ChangePerm(ChangePerm),
327    /// Create a symbolic link.
328    CreateSymlink(CreateSymlink),
329    /// Replace an opaque binary blob.
330    ReplaceBinary(ReplaceBinary),
331}