Skip to main content

prikk_object/payload/
patch.rs

1//! Patch payload types.
2
3use prikk_error::{PrikkError, Result};
4
5use crate::canonical::{is_contiguous_op_seq, is_strictly_sorted};
6use crate::payload::common::{Intent, OperationCondition, OperationConditionEntry};
7use crate::payload::node::{NodeId, NodeKind};
8use crate::{CanonicalEncode, CanonicalWriter, ObjectId, WireType};
9
10/// Number of bytes in a content-anchored text span hash.
11pub const TEXT_SPAN_HASH_BYTES: usize = 32;
12
13/// Compute the stable hash used by content-anchored text edit preconditions.
14#[must_use]
15pub fn text_span_hash(bytes: &[u8]) -> [u8; TEXT_SPAN_HASH_BYTES] {
16    prikk_hash::sha256(bytes)
17}
18
19/// Validate a stable content-anchor identifier.
20pub fn validate_text_anchor_id(value: &str) -> Result<()> {
21    if value.is_empty() {
22        return Err(PrikkError::CanonicalEncoding(
23            "text anchor id must not be empty".to_string(),
24        ));
25    }
26    if !value.is_ascii() {
27        return Err(PrikkError::CanonicalEncoding(
28            "text anchor id must be ASCII in v1".to_string(),
29        ));
30    }
31    if value.bytes().any(|byte| byte < 0x21 || byte == 0x7f) {
32        return Err(PrikkError::CanonicalEncoding(
33            "text anchor id must not contain whitespace or control characters".to_string(),
34        ));
35    }
36    Ok(())
37}
38
39/// Patch payload.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct PatchPayload {
42    /// Operations in semantic order. `op_seq` must be contiguous from 1.
43    pub operations: Vec<Operation>,
44    /// Parent patch IDs. Sorted ascending.
45    pub parent_patch_ids: Vec<ObjectId>,
46    /// Advisory intent.
47    pub intent: Option<Intent>,
48    /// Patch-level preconditions, sorted by key.
49    pub preconditions: Vec<OperationConditionEntry>,
50    /// Identity-bearing patch purpose. `Normal` is canonical by omission.
51    pub purpose: PatchPurpose,
52}
53
54impl PatchPayload {
55    /// Validate ordering and duplicate constraints.
56    pub fn validate(&self) -> Result<()> {
57        if self.operations.is_empty() {
58            return Err(PrikkError::CanonicalEncoding(
59                "patch operations must contain at least one operation".to_string(),
60            ));
61        }
62        let op_seq: Vec<u32> = self.operations.iter().map(|op| op.op_seq).collect();
63        if !is_contiguous_op_seq(&op_seq) {
64            return Err(PrikkError::CanonicalEncoding(
65                "patch operations must have contiguous op_seq values starting at 1".to_string(),
66            ));
67        }
68        if !is_strictly_sorted(&self.parent_patch_ids) {
69            return Err(PrikkError::CanonicalEncoding(
70                "parent_patch_ids must be sorted and unique".to_string(),
71            ));
72        }
73        if !is_strictly_sorted(&self.preconditions) {
74            return Err(PrikkError::CanonicalEncoding(
75                "patch preconditions must be sorted and unique".to_string(),
76            ));
77        }
78        Ok(())
79    }
80}
81
82impl CanonicalEncode for PatchPayload {
83    fn encode_canonical(&self, writer: &mut CanonicalWriter) -> Result<()> {
84        self.validate()?;
85        writer.repeated_record_list(1, &self.operations)?;
86        writer.repeated_object_id(2, &self.parent_patch_ids)?;
87        if let Some(intent) = self.intent {
88            writer.field_enum_u16(3, intent.code())?;
89        }
90        writer.repeated_record(4, &self.preconditions)?;
91        if self.purpose != PatchPurpose::Normal {
92            writer.field_enum_u16(5, self.purpose.code())?;
93        }
94        Ok(())
95    }
96}
97
98/// Identity-bearing patch purpose.
99#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
100#[repr(u16)]
101pub enum PatchPurpose {
102    /// Ordinary Patch. This is the default when tag 5 is absent and must not be encoded explicitly.
103    Normal = 1,
104    /// Rollback draft Patch. This survives WAL-to-object persistence for classification.
105    RollbackDraft = 2,
106}
107
108impl PatchPurpose {
109    /// Stable numeric code.
110    #[must_use]
111    pub const fn code(self) -> u16 {
112        self as u16
113    }
114
115    /// Parse a stable code from a present tag-5 purpose field.
116    pub fn from_present_code(code: u16) -> Result<Self> {
117        match code {
118            1 => Err(PrikkError::CanonicalEncoding(
119                "PatchPurpose::Normal must be omitted, not encoded explicitly".to_string(),
120            )),
121            2 => Ok(Self::RollbackDraft),
122            other => Err(PrikkError::CanonicalEncoding(format!(
123                "unknown patch purpose code: {other}"
124            ))),
125        }
126    }
127
128    /// Decode only the top-level `PatchPayload` purpose field, validating tag order and rejecting
129    /// an explicitly encoded `Normal` default. Absence means `Normal`.
130    pub fn decode_from_patch_payload(bytes: &[u8]) -> Result<Self> {
131        let mut cursor = PatchPayloadFieldCursor::new(bytes);
132        let mut purpose = Self::Normal;
133        let mut seen_purpose = false;
134        while let Some(field) = cursor.next_field()? {
135            match field.tag {
136                1..=4 => {}
137                5 => {
138                    if seen_purpose {
139                        return Err(PrikkError::CanonicalEncoding(
140                            "duplicate PatchPurpose field".to_string(),
141                        ));
142                    }
143                    seen_purpose = true;
144                    field.require_wire(WireType::EnumU16)?;
145                    purpose = Self::from_present_code(field.read_u16()?)?;
146                }
147                other => {
148                    return Err(PrikkError::CanonicalEncoding(format!(
149                        "unknown PatchPayload field tag: {other}"
150                    )));
151                }
152            }
153        }
154        Ok(purpose)
155    }
156}
157
158struct PatchPayloadFieldCursor<'a> {
159    bytes: &'a [u8],
160    pos: usize,
161    last_tag: Option<u16>,
162}
163
164impl<'a> PatchPayloadFieldCursor<'a> {
165    const fn new(bytes: &'a [u8]) -> Self {
166        Self {
167            bytes,
168            pos: 0,
169            last_tag: None,
170        }
171    }
172
173    fn next_field(&mut self) -> Result<Option<PatchPayloadField<'a>>> {
174        if self.pos == self.bytes.len() {
175            return Ok(None);
176        }
177        let tag = u16::from_be_bytes(self.read_array::<2>()?);
178        if tag == 0 {
179            return Err(PrikkError::CanonicalEncoding(
180                "field tag 0 is reserved".to_string(),
181            ));
182        }
183        if let Some(last) = self.last_tag {
184            if tag < last {
185                return Err(PrikkError::CanonicalEncoding(format!(
186                    "field tag order violation: {tag} after {last}"
187                )));
188            }
189        }
190        self.last_tag = Some(tag);
191        let wire_type = self.read_u8()?;
192        let len = usize::try_from(u64::from_be_bytes(self.read_array::<8>()?)).map_err(|_| {
193            PrikkError::CanonicalEncoding("canonical field length does not fit usize".to_string())
194        })?;
195        let value = self.read_exact(len)?;
196        Ok(Some(PatchPayloadField {
197            tag,
198            wire_type,
199            value,
200        }))
201    }
202
203    fn read_u8(&mut self) -> Result<u8> {
204        let bytes = self.read_exact(1)?;
205        let Some(byte) = bytes.first() else {
206            return Err(PrikkError::CanonicalEncoding(
207                "unexpected empty byte".to_string(),
208            ));
209        };
210        Ok(*byte)
211    }
212
213    fn read_array<const N: usize>(&mut self) -> Result<[u8; N]> {
214        let bytes = self.read_exact(N)?;
215        let mut out = [0_u8; N];
216        out.copy_from_slice(bytes);
217        Ok(out)
218    }
219
220    fn read_exact(&mut self, len: usize) -> Result<&'a [u8]> {
221        let end = self
222            .pos
223            .checked_add(len)
224            .ok_or_else(|| PrikkError::CanonicalEncoding("canonical range overflow".to_string()))?;
225        let Some(slice) = self.bytes.get(self.pos..end) else {
226            return Err(PrikkError::CanonicalEncoding(
227                "unexpected end of canonical payload".to_string(),
228            ));
229        };
230        self.pos = end;
231        Ok(slice)
232    }
233}
234
235struct PatchPayloadField<'a> {
236    tag: u16,
237    wire_type: u8,
238    value: &'a [u8],
239}
240
241impl PatchPayloadField<'_> {
242    fn require_wire(&self, expected: WireType) -> Result<()> {
243        if self.wire_type == expected as u8 {
244            return Ok(());
245        }
246        Err(PrikkError::CanonicalEncoding(format!(
247            "field {} has wrong wire type: expected {}, got {}",
248            self.tag, expected as u8, self.wire_type
249        )))
250    }
251
252    fn read_u16(&self) -> Result<u16> {
253        if self.value.len() != 2 {
254            return Err(PrikkError::CanonicalEncoding(format!(
255                "field {} expected 2 bytes, got {}",
256                self.tag,
257                self.value.len()
258            )));
259        }
260        let mut out = [0_u8; 2];
261        out.copy_from_slice(self.value);
262        Ok(u16::from_be_bytes(out))
263    }
264}
265
266/// A single operation inside a patch.
267#[derive(Debug, Clone, PartialEq, Eq)]
268pub struct Operation {
269    /// Strict operation sequence, starting at 1 inside the patch.
270    pub op_seq: u32,
271    /// Optional stable label for UI/debugging.
272    pub op_id: Option<String>,
273    /// Inline operation preconditions.
274    pub preconditions: Vec<OperationCondition>,
275    /// Operation kind.
276    pub kind: OperationKind,
277}
278
279impl CanonicalEncode for Operation {
280    fn encode_canonical(&self, writer: &mut CanonicalWriter) -> Result<()> {
281        writer.field_u32(1, self.op_seq)?;
282        writer.field_string_opt(2, self.op_id.as_deref())?;
283        writer.repeated_record(3, &self.preconditions)?;
284        match &self.kind {
285            OperationKind::CreateFile(value) => writer.field_record(10, value)?,
286            OperationKind::DeleteNode(value) => writer.field_record(11, value)?,
287            OperationKind::EditText(value) => writer.field_record(12, value)?,
288            OperationKind::RenamePath(value) => writer.field_record(13, value)?,
289            OperationKind::ChangePerm(value) => writer.field_record(14, value)?,
290            OperationKind::CreateSymlink(value) => writer.field_record(15, value)?,
291            OperationKind::ReplaceBinary(value) => writer.field_record(16, value)?,
292        }
293        Ok(())
294    }
295}
296
297/// Operation variants.
298#[derive(Debug, Clone, PartialEq, Eq)]
299pub enum OperationKind {
300    /// Create a text or binary file.
301    CreateFile(CreateFile),
302    /// Delete a node.
303    DeleteNode(DeleteNode),
304    /// Edit text using content-anchored spans.
305    EditText(EditText),
306    /// Rename a path.
307    RenamePath(RenamePath),
308    /// Change Unix-like permissions.
309    ChangePerm(ChangePerm),
310    /// Create a symbolic link.
311    CreateSymlink(CreateSymlink),
312    /// Replace an opaque binary blob.
313    ReplaceBinary(ReplaceBinary),
314}
315
316/// Create file payload (FDD-03 §9.3).
317#[derive(Debug, Clone, PartialEq, Eq)]
318pub struct CreateFile {
319    /// Repo-relative UTF-8 path (`repo_path`).
320    pub path: String,
321    /// Node identity (`bytes`, 32).
322    pub node_id: NodeId,
323    /// Initial blob ID (`object_id`).
324    pub blob_id: ObjectId,
325    /// Mode bits (`u32`).
326    pub mode: u32,
327}
328
329impl CreateFile {
330    /// Reject an all-zero `node_id`; FDD-03 §9.3 forbids the reserved value in any
331    /// persisted node-bearing operation, and the encoder produces identity bytes.
332    pub fn validate(&self) -> Result<()> {
333        if self.node_id.is_zero() {
334            return Err(PrikkError::CanonicalEncoding(
335                "CreateFile node_id must be nonzero".to_string(),
336            ));
337        }
338        Ok(())
339    }
340}
341
342impl CanonicalEncode for CreateFile {
343    fn encode_canonical(&self, writer: &mut CanonicalWriter) -> Result<()> {
344        self.validate()?;
345        writer.field_repo_path(1, &self.path)?;
346        writer.field_bytes(2, self.node_id.as_bytes())?;
347        writer.field_object_id(3, &self.blob_id)?;
348        writer.field_u32(4, self.mode)?;
349        Ok(())
350    }
351}
352
353/// Discriminated deletion preimage (FDD-03 §9.3).
354#[derive(Debug, Clone, PartialEq, Eq)]
355pub enum DeleteNodePreimage {
356    /// File or binary node: blob + mode preimage.
357    File {
358        /// Previous blob ID (`object_id`).
359        old_blob_id: ObjectId,
360        /// Previous mode bits (`u32`).
361        old_mode: u32,
362    },
363    /// Symlink node: target preimage.
364    Symlink {
365        /// Previous symlink target (`utf8`).
366        old_target: String,
367    },
368}
369
370/// Delete a node (FDD-03 §9.3; the wire tag is retained as `delete_file`). The
371/// preimage is discriminated by `old_node_kind`: text/binary file nodes carry
372/// `old_blob_id` + `old_mode`; symlink nodes carry `old_target`.
373#[derive(Debug, Clone, PartialEq, Eq)]
374pub struct DeleteNode {
375    /// Repo-relative UTF-8 path (`repo_path`).
376    pub path: String,
377    /// Node identity (`bytes`, 32).
378    pub node_id: NodeId,
379    /// Previous node kind (`enum_u16`); must agree with the preimage.
380    pub old_node_kind: NodeKind,
381    /// Discriminated deletion preimage.
382    pub preimage: DeleteNodePreimage,
383}
384
385impl DeleteNode {
386    /// Reject `old_node_kind` / preimage discriminator mismatches and an all-zero
387    /// `node_id` (FDD-03 §9.3 forbids the reserved value in any node-bearing op).
388    pub fn validate(&self) -> Result<()> {
389        if self.node_id.is_zero() {
390            return Err(PrikkError::CanonicalEncoding(
391                "DeleteNode node_id must be nonzero".to_string(),
392            ));
393        }
394        let consistent = matches!(
395            (self.old_node_kind, &self.preimage),
396            (
397                NodeKind::TextFile | NodeKind::BinaryFile,
398                DeleteNodePreimage::File { .. }
399            ) | (NodeKind::Symlink, DeleteNodePreimage::Symlink { .. })
400        );
401        if !consistent {
402            return Err(PrikkError::CanonicalEncoding(
403                "DeleteNode old_node_kind does not match preimage discriminator".to_string(),
404            ));
405        }
406        Ok(())
407    }
408}
409
410impl CanonicalEncode for DeleteNode {
411    fn encode_canonical(&self, writer: &mut CanonicalWriter) -> Result<()> {
412        self.validate()?;
413        writer.field_repo_path(1, &self.path)?;
414        writer.field_bytes(2, self.node_id.as_bytes())?;
415        writer.field_enum_u16(3, self.old_node_kind.code())?;
416        match &self.preimage {
417            DeleteNodePreimage::File {
418                old_blob_id,
419                old_mode,
420            } => {
421                writer.field_object_id(4, old_blob_id)?;
422                writer.field_u32(6, *old_mode)?;
423            }
424            DeleteNodePreimage::Symlink { old_target } => {
425                writer.field_string(5, old_target)?;
426            }
427        }
428        Ok(())
429    }
430}
431
432/// Text edit payload using content-anchor identity.
433#[derive(Debug, Clone, PartialEq, Eq)]
434pub struct EditText {
435    /// Node identity (`bytes`, 32). EditText is node-addressed, not path-addressed.
436    pub node_id: NodeId,
437    /// Content-anchor span identity (`bytes`, 32; FDD-01 §5.1).
438    pub span_id: [u8; TEXT_SPAN_HASH_BYTES],
439    /// SHA-256 of `old_span_text`; the validator binds the two.
440    pub old_span_hash: [u8; TEXT_SPAN_HASH_BYTES],
441    /// Bounded left-context hash (`bytes`, 32).
442    pub left_anchor_hash: [u8; TEXT_SPAN_HASH_BYTES],
443    /// Bounded right-context hash (`bytes`, 32).
444    pub right_anchor_hash: [u8; TEXT_SPAN_HASH_BYTES],
445    /// New span bytes (`bytes`); UTF-8 text for v1, stored verbatim (never NFC).
446    pub replacement_text: Vec<u8>,
447    /// Optional presentation hint (line); not part of algebraic identity.
448    pub presentation_hint_line: Option<u32>,
449    /// Optional presentation hint (column); not part of algebraic identity.
450    pub presentation_hint_column: Option<u32>,
451    /// Old span bytes (`bytes`); UTF-8 for v1, verbatim; inverse material.
452    pub old_span_text: Vec<u8>,
453}
454
455impl EditText {
456    /// Validate the FDD-03 §9.3 EditText record contract: nonzero `node_id`,
457    /// `old_span_hash == SHA-256(old_span_text)`, and both span-text fields are
458    /// well-formed UTF-8 (non-UTF-8 content must use `ReplaceBinary`).
459    pub fn validate(&self) -> Result<()> {
460        if self.node_id.is_zero() {
461            return Err(PrikkError::CanonicalEncoding(
462                "EditText node_id must be nonzero".to_string(),
463            ));
464        }
465        if self.old_span_hash != text_span_hash(&self.old_span_text) {
466            return Err(PrikkError::CanonicalEncoding(
467                "EditText old_span_hash must equal SHA-256(old_span_text)".to_string(),
468            ));
469        }
470        if core::str::from_utf8(&self.old_span_text).is_err() {
471            return Err(PrikkError::CanonicalEncoding(
472                "EditText old_span_text must be well-formed UTF-8".to_string(),
473            ));
474        }
475        if core::str::from_utf8(&self.replacement_text).is_err() {
476            return Err(PrikkError::CanonicalEncoding(
477                "EditText replacement_text must be well-formed UTF-8".to_string(),
478            ));
479        }
480        Ok(())
481    }
482}
483
484impl CanonicalEncode for EditText {
485    fn encode_canonical(&self, writer: &mut CanonicalWriter) -> Result<()> {
486        self.validate()?;
487        writer.field_bytes(1, self.node_id.as_bytes())?;
488        writer.field_bytes(2, &self.span_id)?;
489        writer.field_bytes(3, &self.old_span_hash)?;
490        writer.field_bytes(4, &self.left_anchor_hash)?;
491        writer.field_bytes(5, &self.right_anchor_hash)?;
492        writer.field_bytes(6, &self.replacement_text)?;
493        if let Some(line) = self.presentation_hint_line {
494            writer.field_u32(7, line)?;
495        }
496        if let Some(column) = self.presentation_hint_column {
497            writer.field_u32(8, column)?;
498        }
499        writer.field_bytes(9, &self.old_span_text)?;
500        Ok(())
501    }
502}
503
504/// Rename path payload (FDD-03 §9.3, node-addressed).
505#[derive(Debug, Clone, PartialEq, Eq)]
506pub struct RenamePath {
507    /// Node identity (`bytes`, 32).
508    pub node_id: NodeId,
509    /// Old repo-relative path (`repo_path`).
510    pub old_path: String,
511    /// New repo-relative path (`repo_path`).
512    pub new_path: String,
513}
514
515impl RenamePath {
516    /// Reject an all-zero `node_id`; FDD-03 §9.3 forbids the reserved value in any
517    /// persisted node-bearing operation, and the encoder produces identity bytes.
518    pub fn validate(&self) -> Result<()> {
519        if self.node_id.is_zero() {
520            return Err(PrikkError::CanonicalEncoding(
521                "RenamePath node_id must be nonzero".to_string(),
522            ));
523        }
524        Ok(())
525    }
526}
527
528impl CanonicalEncode for RenamePath {
529    fn encode_canonical(&self, writer: &mut CanonicalWriter) -> Result<()> {
530        self.validate()?;
531        writer.field_bytes(1, self.node_id.as_bytes())?;
532        writer.field_repo_path(2, &self.old_path)?;
533        writer.field_repo_path(3, &self.new_path)?;
534        Ok(())
535    }
536}
537
538/// Permission change payload (FDD-03 §9.3, node-addressed).
539#[derive(Debug, Clone, PartialEq, Eq)]
540pub struct ChangePerm {
541    /// Node identity (`bytes`, 32).
542    pub node_id: NodeId,
543    /// Old mode bits (`u32`).
544    pub old_mode: u32,
545    /// New mode bits (`u32`).
546    pub new_mode: u32,
547}
548
549impl ChangePerm {
550    /// Reject an all-zero `node_id` (FDD-03 §9.3).
551    pub fn validate(&self) -> Result<()> {
552        if self.node_id.is_zero() {
553            return Err(PrikkError::CanonicalEncoding(
554                "ChangePerm node_id must be nonzero".to_string(),
555            ));
556        }
557        Ok(())
558    }
559}
560
561impl CanonicalEncode for ChangePerm {
562    fn encode_canonical(&self, writer: &mut CanonicalWriter) -> Result<()> {
563        self.validate()?;
564        writer.field_bytes(1, self.node_id.as_bytes())?;
565        writer.field_u32(2, self.old_mode)?;
566        writer.field_u32(3, self.new_mode)?;
567        Ok(())
568    }
569}
570
571/// Symlink creation payload (FDD-03 §9.3). Note tag order: `path` (1), then
572/// `node_id` (2), then `target` (3).
573#[derive(Debug, Clone, PartialEq, Eq)]
574pub struct CreateSymlink {
575    /// Repo-relative UTF-8 path (`repo_path`).
576    pub path: String,
577    /// Node identity (`bytes`, 32).
578    pub node_id: NodeId,
579    /// Symlink target (`utf8_string`). Static escape/four-boundary validation
580    /// (FDD-04 §5.4a / §13.1) is a later increment; this reconciles identity bytes.
581    pub target: String,
582}
583
584impl CreateSymlink {
585    /// Reject an all-zero `node_id` (FDD-03 §9.3).
586    pub fn validate(&self) -> Result<()> {
587        if self.node_id.is_zero() {
588            return Err(PrikkError::CanonicalEncoding(
589                "CreateSymlink node_id must be nonzero".to_string(),
590            ));
591        }
592        Ok(())
593    }
594}
595
596impl CanonicalEncode for CreateSymlink {
597    fn encode_canonical(&self, writer: &mut CanonicalWriter) -> Result<()> {
598        self.validate()?;
599        writer.field_repo_path(1, &self.path)?;
600        writer.field_bytes(2, self.node_id.as_bytes())?;
601        writer.field_string(3, &self.target)?;
602        Ok(())
603    }
604}
605
606/// Binary replacement payload.
607#[derive(Debug, Clone, PartialEq, Eq)]
608pub struct ReplaceBinary {
609    /// Node identity (`bytes`, 32).
610    pub node_id: NodeId,
611    /// Old blob ID (`object_id`).
612    pub old_blob_id: ObjectId,
613    /// New blob ID (`object_id`).
614    pub new_blob_id: ObjectId,
615}
616
617impl ReplaceBinary {
618    /// Reject an all-zero `node_id`; FDD-03 §9.3 forbids the reserved value in any
619    /// persisted node-bearing operation, and the encoder produces identity bytes.
620    pub fn validate(&self) -> Result<()> {
621        if self.node_id.is_zero() {
622            return Err(PrikkError::CanonicalEncoding(
623                "ReplaceBinary node_id must be nonzero".to_string(),
624            ));
625        }
626        Ok(())
627    }
628}
629
630impl CanonicalEncode for ReplaceBinary {
631    fn encode_canonical(&self, writer: &mut CanonicalWriter) -> Result<()> {
632        self.validate()?;
633        writer.field_bytes(1, self.node_id.as_bytes())?;
634        writer.field_object_id(2, &self.old_blob_id)?;
635        writer.field_object_id(3, &self.new_blob_id)?;
636        Ok(())
637    }
638}