Skip to main content

prikk_object/payload/patch/
operations.rs

1//! Per-operation-kind patch payload structs (FDD-03 §9.3): validation and canonical encoding.
2//! Split out of `payload/patch.rs` (DC-58) — no behaviour change, all items moved verbatim.
3//! Re-exported at `payload/patch.rs` so every existing public path (`payload::patch::CreateFile`
4//! etc., and the crate-root re-export at `payload.rs`) is unchanged.
5
6use prikk_error::{PrikkError, Result};
7
8use crate::path::validate_repo_path;
9use crate::payload::node::{NodeId, NodeKind};
10use crate::{CanonicalEncode, CanonicalWriter, ObjectId};
11
12use super::{TEXT_SPAN_HASH_BYTES, text_span_hash};
13
14/// Create file payload (FDD-03 §9.3).
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct CreateFile {
17    /// Repo-relative UTF-8 path (`repo_path`).
18    pub path: String,
19    /// Node identity (`bytes`, 32).
20    pub node_id: NodeId,
21    /// Initial blob ID (`object_id`).
22    pub blob_id: ObjectId,
23    /// Mode bits (`u32`).
24    pub mode: u32,
25}
26
27impl CreateFile {
28    /// Reject an all-zero `node_id`; FDD-03 §9.3 forbids the reserved value in any
29    /// persisted node-bearing operation, and the encoder produces identity bytes. Also reject a
30    /// `path` that violates the `RepoPath` grammar decode enforces (DC-54): encode must reject
31    /// exactly what decode rejects, using the same validator, so the two sides cannot drift.
32    pub fn validate(&self) -> Result<()> {
33        if self.node_id.is_zero() {
34            return Err(PrikkError::CanonicalEncoding(
35                "CreateFile node_id must be nonzero".to_string(),
36            ));
37        }
38        validate_repo_path(&self.path)?;
39        Ok(())
40    }
41}
42
43impl CanonicalEncode for CreateFile {
44    fn encode_canonical(&self, writer: &mut CanonicalWriter) -> Result<()> {
45        self.validate()?;
46        writer.field_repo_path(1, &self.path)?;
47        writer.field_bytes(2, self.node_id.as_bytes())?;
48        writer.field_object_id(3, &self.blob_id)?;
49        writer.field_u32(4, self.mode)?;
50        Ok(())
51    }
52}
53
54/// Discriminated deletion preimage (FDD-03 §9.3).
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum DeleteNodePreimage {
57    /// File or binary node: blob + mode preimage.
58    File {
59        /// Previous blob ID (`object_id`).
60        old_blob_id: ObjectId,
61        /// Previous mode bits (`u32`).
62        old_mode: u32,
63    },
64    /// Symlink node: target preimage.
65    Symlink {
66        /// Previous symlink target (`utf8`).
67        old_target: String,
68    },
69}
70
71/// Delete a node (FDD-03 §9.3; the wire tag is retained as `delete_file`). The
72/// preimage is discriminated by `old_node_kind`: text/binary file nodes carry
73/// `old_blob_id` + `old_mode`; symlink nodes carry `old_target`.
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct DeleteNode {
76    /// Repo-relative UTF-8 path (`repo_path`).
77    pub path: String,
78    /// Node identity (`bytes`, 32).
79    pub node_id: NodeId,
80    /// Previous node kind (`enum_u16`); must agree with the preimage.
81    pub old_node_kind: NodeKind,
82    /// Discriminated deletion preimage.
83    pub preimage: DeleteNodePreimage,
84}
85
86impl DeleteNode {
87    /// Reject `old_node_kind` / preimage discriminator mismatches, an all-zero
88    /// `node_id` (FDD-03 §9.3 forbids the reserved value in any node-bearing op), and a `path`
89    /// that violates the `RepoPath` grammar decode enforces (DC-54).
90    pub fn validate(&self) -> Result<()> {
91        if self.node_id.is_zero() {
92            return Err(PrikkError::CanonicalEncoding(
93                "DeleteNode node_id must be nonzero".to_string(),
94            ));
95        }
96        validate_repo_path(&self.path)?;
97        let consistent = matches!(
98            (self.old_node_kind, &self.preimage),
99            (
100                NodeKind::TextFile | NodeKind::BinaryFile,
101                DeleteNodePreimage::File { .. }
102            ) | (NodeKind::Symlink, DeleteNodePreimage::Symlink { .. })
103        );
104        if !consistent {
105            return Err(PrikkError::CanonicalEncoding(
106                "DeleteNode old_node_kind does not match preimage discriminator".to_string(),
107            ));
108        }
109        Ok(())
110    }
111}
112
113impl CanonicalEncode for DeleteNode {
114    fn encode_canonical(&self, writer: &mut CanonicalWriter) -> Result<()> {
115        self.validate()?;
116        writer.field_repo_path(1, &self.path)?;
117        writer.field_bytes(2, self.node_id.as_bytes())?;
118        writer.field_enum_u16(3, self.old_node_kind.code())?;
119        match &self.preimage {
120            DeleteNodePreimage::File {
121                old_blob_id,
122                old_mode,
123            } => {
124                writer.field_object_id(4, old_blob_id)?;
125                writer.field_u32(6, *old_mode)?;
126            }
127            DeleteNodePreimage::Symlink { old_target } => {
128                writer.field_string(5, old_target)?;
129            }
130        }
131        Ok(())
132    }
133}
134
135/// Text edit payload using content-anchor identity.
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct EditText {
138    /// Node identity (`bytes`, 32). EditText is node-addressed, not path-addressed.
139    pub node_id: NodeId,
140    /// Content-anchor span identity (`bytes`, 32; FDD-01 §5.1).
141    pub span_id: [u8; TEXT_SPAN_HASH_BYTES],
142    /// SHA-256 of `old_span_text`; the validator binds the two.
143    pub old_span_hash: [u8; TEXT_SPAN_HASH_BYTES],
144    /// Bounded left-context hash (`bytes`, 32).
145    pub left_anchor_hash: [u8; TEXT_SPAN_HASH_BYTES],
146    /// Bounded right-context hash (`bytes`, 32).
147    pub right_anchor_hash: [u8; TEXT_SPAN_HASH_BYTES],
148    /// New span bytes (`bytes`); UTF-8 text for v1, stored verbatim (never NFC).
149    pub replacement_text: Vec<u8>,
150    /// Optional presentation hint (line); not part of algebraic identity.
151    pub presentation_hint_line: Option<u32>,
152    /// Optional presentation hint (column); not part of algebraic identity.
153    pub presentation_hint_column: Option<u32>,
154    /// Old span bytes (`bytes`); UTF-8 for v1, verbatim; inverse material.
155    pub old_span_text: Vec<u8>,
156}
157
158impl EditText {
159    /// Validate the FDD-03 §9.3 EditText record contract: nonzero `node_id`,
160    /// `old_span_hash == SHA-256(old_span_text)`, and both span-text fields are
161    /// well-formed UTF-8 (non-UTF-8 content must use `ReplaceBinary`).
162    pub fn validate(&self) -> Result<()> {
163        if self.node_id.is_zero() {
164            return Err(PrikkError::CanonicalEncoding(
165                "EditText node_id must be nonzero".to_string(),
166            ));
167        }
168        if self.old_span_hash != text_span_hash(&self.old_span_text) {
169            return Err(PrikkError::CanonicalEncoding(
170                "EditText old_span_hash must equal SHA-256(old_span_text)".to_string(),
171            ));
172        }
173        if core::str::from_utf8(&self.old_span_text).is_err() {
174            return Err(PrikkError::CanonicalEncoding(
175                "EditText old_span_text must be well-formed UTF-8".to_string(),
176            ));
177        }
178        if core::str::from_utf8(&self.replacement_text).is_err() {
179            return Err(PrikkError::CanonicalEncoding(
180                "EditText replacement_text must be well-formed UTF-8".to_string(),
181            ));
182        }
183        Ok(())
184    }
185}
186
187impl CanonicalEncode for EditText {
188    fn encode_canonical(&self, writer: &mut CanonicalWriter) -> Result<()> {
189        self.validate()?;
190        writer.field_bytes(1, self.node_id.as_bytes())?;
191        writer.field_bytes(2, &self.span_id)?;
192        writer.field_bytes(3, &self.old_span_hash)?;
193        writer.field_bytes(4, &self.left_anchor_hash)?;
194        writer.field_bytes(5, &self.right_anchor_hash)?;
195        writer.field_bytes(6, &self.replacement_text)?;
196        if let Some(line) = self.presentation_hint_line {
197            writer.field_u32(7, line)?;
198        }
199        if let Some(column) = self.presentation_hint_column {
200            writer.field_u32(8, column)?;
201        }
202        writer.field_bytes(9, &self.old_span_text)?;
203        Ok(())
204    }
205}
206
207/// Rename path payload (FDD-03 §9.3, node-addressed).
208#[derive(Debug, Clone, PartialEq, Eq)]
209pub struct RenamePath {
210    /// Node identity (`bytes`, 32).
211    pub node_id: NodeId,
212    /// Old repo-relative path (`repo_path`).
213    pub old_path: String,
214    /// New repo-relative path (`repo_path`).
215    pub new_path: String,
216}
217
218impl RenamePath {
219    /// Reject an all-zero `node_id`; FDD-03 §9.3 forbids the reserved value in any
220    /// persisted node-bearing operation, and the encoder produces identity bytes. Also reject an
221    /// `old_path` or `new_path` that violates the `RepoPath` grammar decode enforces (DC-54) —
222    /// both fields are checked independently, since a path-safe `new_path` must not mask an
223    /// unsafe `old_path`.
224    pub fn validate(&self) -> Result<()> {
225        if self.node_id.is_zero() {
226            return Err(PrikkError::CanonicalEncoding(
227                "RenamePath node_id must be nonzero".to_string(),
228            ));
229        }
230        validate_repo_path(&self.old_path)?;
231        validate_repo_path(&self.new_path)?;
232        Ok(())
233    }
234}
235
236impl CanonicalEncode for RenamePath {
237    fn encode_canonical(&self, writer: &mut CanonicalWriter) -> Result<()> {
238        self.validate()?;
239        writer.field_bytes(1, self.node_id.as_bytes())?;
240        writer.field_repo_path(2, &self.old_path)?;
241        writer.field_repo_path(3, &self.new_path)?;
242        Ok(())
243    }
244}
245
246/// Permission change payload (FDD-03 §9.3, node-addressed).
247#[derive(Debug, Clone, PartialEq, Eq)]
248pub struct ChangePerm {
249    /// Node identity (`bytes`, 32).
250    pub node_id: NodeId,
251    /// Old mode bits (`u32`).
252    pub old_mode: u32,
253    /// New mode bits (`u32`).
254    pub new_mode: u32,
255}
256
257impl ChangePerm {
258    /// Reject an all-zero `node_id` (FDD-03 §9.3).
259    pub fn validate(&self) -> Result<()> {
260        if self.node_id.is_zero() {
261            return Err(PrikkError::CanonicalEncoding(
262                "ChangePerm node_id must be nonzero".to_string(),
263            ));
264        }
265        Ok(())
266    }
267}
268
269impl CanonicalEncode for ChangePerm {
270    fn encode_canonical(&self, writer: &mut CanonicalWriter) -> Result<()> {
271        self.validate()?;
272        writer.field_bytes(1, self.node_id.as_bytes())?;
273        writer.field_u32(2, self.old_mode)?;
274        writer.field_u32(3, self.new_mode)?;
275        Ok(())
276    }
277}
278
279/// Symlink creation payload (FDD-03 §9.3). Note tag order: `path` (1), then
280/// `node_id` (2), then `target` (3).
281#[derive(Debug, Clone, PartialEq, Eq)]
282pub struct CreateSymlink {
283    /// Repo-relative UTF-8 path (`repo_path`).
284    pub path: String,
285    /// Node identity (`bytes`, 32).
286    pub node_id: NodeId,
287    /// Symlink target (`utf8_string`). Static escape/four-boundary validation
288    /// (FDD-04 §5.4a / §13.1) is a later increment; this reconciles identity bytes.
289    pub target: String,
290}
291
292impl CreateSymlink {
293    /// Reject an all-zero `node_id` (FDD-03 §9.3) and a `path` that violates the `RepoPath`
294    /// grammar decode enforces (DC-54). `target` is deliberately left untouched: it is an opaque
295    /// symlink target by accepted DC-40 design, not a repository-relative path.
296    pub fn validate(&self) -> Result<()> {
297        if self.node_id.is_zero() {
298            return Err(PrikkError::CanonicalEncoding(
299                "CreateSymlink node_id must be nonzero".to_string(),
300            ));
301        }
302        validate_repo_path(&self.path)?;
303        Ok(())
304    }
305}
306
307impl CanonicalEncode for CreateSymlink {
308    fn encode_canonical(&self, writer: &mut CanonicalWriter) -> Result<()> {
309        self.validate()?;
310        writer.field_repo_path(1, &self.path)?;
311        writer.field_bytes(2, self.node_id.as_bytes())?;
312        writer.field_string(3, &self.target)?;
313        Ok(())
314    }
315}
316
317/// Binary replacement payload.
318#[derive(Debug, Clone, PartialEq, Eq)]
319pub struct ReplaceBinary {
320    /// Node identity (`bytes`, 32).
321    pub node_id: NodeId,
322    /// Old blob ID (`object_id`).
323    pub old_blob_id: ObjectId,
324    /// New blob ID (`object_id`).
325    pub new_blob_id: ObjectId,
326}
327
328impl ReplaceBinary {
329    /// Reject an all-zero `node_id`; FDD-03 §9.3 forbids the reserved value in any
330    /// persisted node-bearing operation, and the encoder produces identity bytes.
331    pub fn validate(&self) -> Result<()> {
332        if self.node_id.is_zero() {
333            return Err(PrikkError::CanonicalEncoding(
334                "ReplaceBinary node_id must be nonzero".to_string(),
335            ));
336        }
337        Ok(())
338    }
339}
340
341impl CanonicalEncode for ReplaceBinary {
342    fn encode_canonical(&self, writer: &mut CanonicalWriter) -> Result<()> {
343        self.validate()?;
344        writer.field_bytes(1, self.node_id.as_bytes())?;
345        writer.field_object_id(2, &self.old_blob_id)?;
346        writer.field_object_id(3, &self.new_blob_id)?;
347        Ok(())
348    }
349}