1use 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
10pub const TEXT_SPAN_HASH_BYTES: usize = 32;
12
13#[must_use]
15pub fn text_span_hash(bytes: &[u8]) -> [u8; TEXT_SPAN_HASH_BYTES] {
16 prikk_hash::sha256(bytes)
17}
18
19pub 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#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct PatchPayload {
42 pub operations: Vec<Operation>,
44 pub parent_patch_ids: Vec<ObjectId>,
46 pub intent: Option<Intent>,
48 pub preconditions: Vec<OperationConditionEntry>,
50 pub purpose: PatchPurpose,
52}
53
54impl PatchPayload {
55 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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
100#[repr(u16)]
101pub enum PatchPurpose {
102 Normal = 1,
104 RollbackDraft = 2,
106}
107
108impl PatchPurpose {
109 #[must_use]
111 pub const fn code(self) -> u16 {
112 self as u16
113 }
114
115 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 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#[derive(Debug, Clone, PartialEq, Eq)]
268pub struct Operation {
269 pub op_seq: u32,
271 pub op_id: Option<String>,
273 pub preconditions: Vec<OperationCondition>,
275 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#[derive(Debug, Clone, PartialEq, Eq)]
299pub enum OperationKind {
300 CreateFile(CreateFile),
302 DeleteNode(DeleteNode),
304 EditText(EditText),
306 RenamePath(RenamePath),
308 ChangePerm(ChangePerm),
310 CreateSymlink(CreateSymlink),
312 ReplaceBinary(ReplaceBinary),
314}
315
316#[derive(Debug, Clone, PartialEq, Eq)]
318pub struct CreateFile {
319 pub path: String,
321 pub node_id: NodeId,
323 pub blob_id: ObjectId,
325 pub mode: u32,
327}
328
329impl CreateFile {
330 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#[derive(Debug, Clone, PartialEq, Eq)]
355pub enum DeleteNodePreimage {
356 File {
358 old_blob_id: ObjectId,
360 old_mode: u32,
362 },
363 Symlink {
365 old_target: String,
367 },
368}
369
370#[derive(Debug, Clone, PartialEq, Eq)]
374pub struct DeleteNode {
375 pub path: String,
377 pub node_id: NodeId,
379 pub old_node_kind: NodeKind,
381 pub preimage: DeleteNodePreimage,
383}
384
385impl DeleteNode {
386 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#[derive(Debug, Clone, PartialEq, Eq)]
434pub struct EditText {
435 pub node_id: NodeId,
437 pub span_id: [u8; TEXT_SPAN_HASH_BYTES],
439 pub old_span_hash: [u8; TEXT_SPAN_HASH_BYTES],
441 pub left_anchor_hash: [u8; TEXT_SPAN_HASH_BYTES],
443 pub right_anchor_hash: [u8; TEXT_SPAN_HASH_BYTES],
445 pub replacement_text: Vec<u8>,
447 pub presentation_hint_line: Option<u32>,
449 pub presentation_hint_column: Option<u32>,
451 pub old_span_text: Vec<u8>,
453}
454
455impl EditText {
456 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#[derive(Debug, Clone, PartialEq, Eq)]
506pub struct RenamePath {
507 pub node_id: NodeId,
509 pub old_path: String,
511 pub new_path: String,
513}
514
515impl RenamePath {
516 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#[derive(Debug, Clone, PartialEq, Eq)]
540pub struct ChangePerm {
541 pub node_id: NodeId,
543 pub old_mode: u32,
545 pub new_mode: u32,
547}
548
549impl ChangePerm {
550 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#[derive(Debug, Clone, PartialEq, Eq)]
574pub struct CreateSymlink {
575 pub path: String,
577 pub node_id: NodeId,
579 pub target: String,
582}
583
584impl CreateSymlink {
585 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#[derive(Debug, Clone, PartialEq, Eq)]
608pub struct ReplaceBinary {
609 pub node_id: NodeId,
611 pub old_blob_id: ObjectId,
613 pub new_blob_id: ObjectId,
615}
616
617impl ReplaceBinary {
618 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}