use prikk_error::{PrikkError, Result};
use crate::canonical::{is_contiguous_op_seq, is_strictly_sorted};
use crate::payload::common::{Intent, OperationCondition, OperationConditionEntry};
use crate::payload::node::{NodeId, NodeKind};
use crate::{CanonicalEncode, CanonicalWriter, ObjectId, WireType};
pub const TEXT_SPAN_HASH_BYTES: usize = 32;
#[must_use]
pub fn text_span_hash(bytes: &[u8]) -> [u8; TEXT_SPAN_HASH_BYTES] {
prikk_hash::sha256(bytes)
}
pub fn validate_text_anchor_id(value: &str) -> Result<()> {
if value.is_empty() {
return Err(PrikkError::CanonicalEncoding(
"text anchor id must not be empty".to_string(),
));
}
if !value.is_ascii() {
return Err(PrikkError::CanonicalEncoding(
"text anchor id must be ASCII in v1".to_string(),
));
}
if value.bytes().any(|byte| byte < 0x21 || byte == 0x7f) {
return Err(PrikkError::CanonicalEncoding(
"text anchor id must not contain whitespace or control characters".to_string(),
));
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PatchPayload {
pub operations: Vec<Operation>,
pub parent_patch_ids: Vec<ObjectId>,
pub intent: Option<Intent>,
pub preconditions: Vec<OperationConditionEntry>,
pub purpose: PatchPurpose,
}
impl PatchPayload {
pub fn validate(&self) -> Result<()> {
if self.operations.is_empty() {
return Err(PrikkError::CanonicalEncoding(
"patch operations must contain at least one operation".to_string(),
));
}
let op_seq: Vec<u32> = self.operations.iter().map(|op| op.op_seq).collect();
if !is_contiguous_op_seq(&op_seq) {
return Err(PrikkError::CanonicalEncoding(
"patch operations must have contiguous op_seq values starting at 1".to_string(),
));
}
if !is_strictly_sorted(&self.parent_patch_ids) {
return Err(PrikkError::CanonicalEncoding(
"parent_patch_ids must be sorted and unique".to_string(),
));
}
if !is_strictly_sorted(&self.preconditions) {
return Err(PrikkError::CanonicalEncoding(
"patch preconditions must be sorted and unique".to_string(),
));
}
Ok(())
}
}
impl CanonicalEncode for PatchPayload {
fn encode_canonical(&self, writer: &mut CanonicalWriter) -> Result<()> {
self.validate()?;
writer.repeated_record_list(1, &self.operations)?;
writer.repeated_object_id(2, &self.parent_patch_ids)?;
if let Some(intent) = self.intent {
writer.field_enum_u16(3, intent.code())?;
}
writer.repeated_record(4, &self.preconditions)?;
if self.purpose != PatchPurpose::Normal {
writer.field_enum_u16(5, self.purpose.code())?;
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[repr(u16)]
pub enum PatchPurpose {
Normal = 1,
RollbackDraft = 2,
}
impl PatchPurpose {
#[must_use]
pub const fn code(self) -> u16 {
self as u16
}
pub fn from_present_code(code: u16) -> Result<Self> {
match code {
1 => Err(PrikkError::CanonicalEncoding(
"PatchPurpose::Normal must be omitted, not encoded explicitly".to_string(),
)),
2 => Ok(Self::RollbackDraft),
other => Err(PrikkError::CanonicalEncoding(format!(
"unknown patch purpose code: {other}"
))),
}
}
pub fn decode_from_patch_payload(bytes: &[u8]) -> Result<Self> {
let mut cursor = PatchPayloadFieldCursor::new(bytes);
let mut purpose = Self::Normal;
let mut seen_purpose = false;
while let Some(field) = cursor.next_field()? {
match field.tag {
1..=4 => {}
5 => {
if seen_purpose {
return Err(PrikkError::CanonicalEncoding(
"duplicate PatchPurpose field".to_string(),
));
}
seen_purpose = true;
field.require_wire(WireType::EnumU16)?;
purpose = Self::from_present_code(field.read_u16()?)?;
}
other => {
return Err(PrikkError::CanonicalEncoding(format!(
"unknown PatchPayload field tag: {other}"
)));
}
}
}
Ok(purpose)
}
}
struct PatchPayloadFieldCursor<'a> {
bytes: &'a [u8],
pos: usize,
last_tag: Option<u16>,
}
impl<'a> PatchPayloadFieldCursor<'a> {
const fn new(bytes: &'a [u8]) -> Self {
Self {
bytes,
pos: 0,
last_tag: None,
}
}
fn next_field(&mut self) -> Result<Option<PatchPayloadField<'a>>> {
if self.pos == self.bytes.len() {
return Ok(None);
}
let tag = u16::from_be_bytes(self.read_array::<2>()?);
if tag == 0 {
return Err(PrikkError::CanonicalEncoding(
"field tag 0 is reserved".to_string(),
));
}
if let Some(last) = self.last_tag {
if tag < last {
return Err(PrikkError::CanonicalEncoding(format!(
"field tag order violation: {tag} after {last}"
)));
}
}
self.last_tag = Some(tag);
let wire_type = self.read_u8()?;
let len = usize::try_from(u64::from_be_bytes(self.read_array::<8>()?)).map_err(|_| {
PrikkError::CanonicalEncoding("canonical field length does not fit usize".to_string())
})?;
let value = self.read_exact(len)?;
Ok(Some(PatchPayloadField {
tag,
wire_type,
value,
}))
}
fn read_u8(&mut self) -> Result<u8> {
let bytes = self.read_exact(1)?;
let Some(byte) = bytes.first() else {
return Err(PrikkError::CanonicalEncoding(
"unexpected empty byte".to_string(),
));
};
Ok(*byte)
}
fn read_array<const N: usize>(&mut self) -> Result<[u8; N]> {
let bytes = self.read_exact(N)?;
let mut out = [0_u8; N];
out.copy_from_slice(bytes);
Ok(out)
}
fn read_exact(&mut self, len: usize) -> Result<&'a [u8]> {
let end = self
.pos
.checked_add(len)
.ok_or_else(|| PrikkError::CanonicalEncoding("canonical range overflow".to_string()))?;
let Some(slice) = self.bytes.get(self.pos..end) else {
return Err(PrikkError::CanonicalEncoding(
"unexpected end of canonical payload".to_string(),
));
};
self.pos = end;
Ok(slice)
}
}
struct PatchPayloadField<'a> {
tag: u16,
wire_type: u8,
value: &'a [u8],
}
impl PatchPayloadField<'_> {
fn require_wire(&self, expected: WireType) -> Result<()> {
if self.wire_type == expected as u8 {
return Ok(());
}
Err(PrikkError::CanonicalEncoding(format!(
"field {} has wrong wire type: expected {}, got {}",
self.tag, expected as u8, self.wire_type
)))
}
fn read_u16(&self) -> Result<u16> {
if self.value.len() != 2 {
return Err(PrikkError::CanonicalEncoding(format!(
"field {} expected 2 bytes, got {}",
self.tag,
self.value.len()
)));
}
let mut out = [0_u8; 2];
out.copy_from_slice(self.value);
Ok(u16::from_be_bytes(out))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Operation {
pub op_seq: u32,
pub op_id: Option<String>,
pub preconditions: Vec<OperationCondition>,
pub kind: OperationKind,
}
impl CanonicalEncode for Operation {
fn encode_canonical(&self, writer: &mut CanonicalWriter) -> Result<()> {
writer.field_u32(1, self.op_seq)?;
writer.field_string_opt(2, self.op_id.as_deref())?;
writer.repeated_record(3, &self.preconditions)?;
match &self.kind {
OperationKind::CreateFile(value) => writer.field_record(10, value)?,
OperationKind::DeleteNode(value) => writer.field_record(11, value)?,
OperationKind::EditText(value) => writer.field_record(12, value)?,
OperationKind::RenamePath(value) => writer.field_record(13, value)?,
OperationKind::ChangePerm(value) => writer.field_record(14, value)?,
OperationKind::CreateSymlink(value) => writer.field_record(15, value)?,
OperationKind::ReplaceBinary(value) => writer.field_record(16, value)?,
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OperationKind {
CreateFile(CreateFile),
DeleteNode(DeleteNode),
EditText(EditText),
RenamePath(RenamePath),
ChangePerm(ChangePerm),
CreateSymlink(CreateSymlink),
ReplaceBinary(ReplaceBinary),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateFile {
pub path: String,
pub node_id: NodeId,
pub blob_id: ObjectId,
pub mode: u32,
}
impl CreateFile {
pub fn validate(&self) -> Result<()> {
if self.node_id.is_zero() {
return Err(PrikkError::CanonicalEncoding(
"CreateFile node_id must be nonzero".to_string(),
));
}
Ok(())
}
}
impl CanonicalEncode for CreateFile {
fn encode_canonical(&self, writer: &mut CanonicalWriter) -> Result<()> {
self.validate()?;
writer.field_repo_path(1, &self.path)?;
writer.field_bytes(2, self.node_id.as_bytes())?;
writer.field_object_id(3, &self.blob_id)?;
writer.field_u32(4, self.mode)?;
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DeleteNodePreimage {
File {
old_blob_id: ObjectId,
old_mode: u32,
},
Symlink {
old_target: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeleteNode {
pub path: String,
pub node_id: NodeId,
pub old_node_kind: NodeKind,
pub preimage: DeleteNodePreimage,
}
impl DeleteNode {
pub fn validate(&self) -> Result<()> {
if self.node_id.is_zero() {
return Err(PrikkError::CanonicalEncoding(
"DeleteNode node_id must be nonzero".to_string(),
));
}
let consistent = matches!(
(self.old_node_kind, &self.preimage),
(
NodeKind::TextFile | NodeKind::BinaryFile,
DeleteNodePreimage::File { .. }
) | (NodeKind::Symlink, DeleteNodePreimage::Symlink { .. })
);
if !consistent {
return Err(PrikkError::CanonicalEncoding(
"DeleteNode old_node_kind does not match preimage discriminator".to_string(),
));
}
Ok(())
}
}
impl CanonicalEncode for DeleteNode {
fn encode_canonical(&self, writer: &mut CanonicalWriter) -> Result<()> {
self.validate()?;
writer.field_repo_path(1, &self.path)?;
writer.field_bytes(2, self.node_id.as_bytes())?;
writer.field_enum_u16(3, self.old_node_kind.code())?;
match &self.preimage {
DeleteNodePreimage::File {
old_blob_id,
old_mode,
} => {
writer.field_object_id(4, old_blob_id)?;
writer.field_u32(6, *old_mode)?;
}
DeleteNodePreimage::Symlink { old_target } => {
writer.field_string(5, old_target)?;
}
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EditText {
pub node_id: NodeId,
pub span_id: [u8; TEXT_SPAN_HASH_BYTES],
pub old_span_hash: [u8; TEXT_SPAN_HASH_BYTES],
pub left_anchor_hash: [u8; TEXT_SPAN_HASH_BYTES],
pub right_anchor_hash: [u8; TEXT_SPAN_HASH_BYTES],
pub replacement_text: Vec<u8>,
pub presentation_hint_line: Option<u32>,
pub presentation_hint_column: Option<u32>,
pub old_span_text: Vec<u8>,
}
impl EditText {
pub fn validate(&self) -> Result<()> {
if self.node_id.is_zero() {
return Err(PrikkError::CanonicalEncoding(
"EditText node_id must be nonzero".to_string(),
));
}
if self.old_span_hash != text_span_hash(&self.old_span_text) {
return Err(PrikkError::CanonicalEncoding(
"EditText old_span_hash must equal SHA-256(old_span_text)".to_string(),
));
}
if core::str::from_utf8(&self.old_span_text).is_err() {
return Err(PrikkError::CanonicalEncoding(
"EditText old_span_text must be well-formed UTF-8".to_string(),
));
}
if core::str::from_utf8(&self.replacement_text).is_err() {
return Err(PrikkError::CanonicalEncoding(
"EditText replacement_text must be well-formed UTF-8".to_string(),
));
}
Ok(())
}
}
impl CanonicalEncode for EditText {
fn encode_canonical(&self, writer: &mut CanonicalWriter) -> Result<()> {
self.validate()?;
writer.field_bytes(1, self.node_id.as_bytes())?;
writer.field_bytes(2, &self.span_id)?;
writer.field_bytes(3, &self.old_span_hash)?;
writer.field_bytes(4, &self.left_anchor_hash)?;
writer.field_bytes(5, &self.right_anchor_hash)?;
writer.field_bytes(6, &self.replacement_text)?;
if let Some(line) = self.presentation_hint_line {
writer.field_u32(7, line)?;
}
if let Some(column) = self.presentation_hint_column {
writer.field_u32(8, column)?;
}
writer.field_bytes(9, &self.old_span_text)?;
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RenamePath {
pub node_id: NodeId,
pub old_path: String,
pub new_path: String,
}
impl RenamePath {
pub fn validate(&self) -> Result<()> {
if self.node_id.is_zero() {
return Err(PrikkError::CanonicalEncoding(
"RenamePath node_id must be nonzero".to_string(),
));
}
Ok(())
}
}
impl CanonicalEncode for RenamePath {
fn encode_canonical(&self, writer: &mut CanonicalWriter) -> Result<()> {
self.validate()?;
writer.field_bytes(1, self.node_id.as_bytes())?;
writer.field_repo_path(2, &self.old_path)?;
writer.field_repo_path(3, &self.new_path)?;
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChangePerm {
pub node_id: NodeId,
pub old_mode: u32,
pub new_mode: u32,
}
impl ChangePerm {
pub fn validate(&self) -> Result<()> {
if self.node_id.is_zero() {
return Err(PrikkError::CanonicalEncoding(
"ChangePerm node_id must be nonzero".to_string(),
));
}
Ok(())
}
}
impl CanonicalEncode for ChangePerm {
fn encode_canonical(&self, writer: &mut CanonicalWriter) -> Result<()> {
self.validate()?;
writer.field_bytes(1, self.node_id.as_bytes())?;
writer.field_u32(2, self.old_mode)?;
writer.field_u32(3, self.new_mode)?;
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateSymlink {
pub path: String,
pub node_id: NodeId,
pub target: String,
}
impl CreateSymlink {
pub fn validate(&self) -> Result<()> {
if self.node_id.is_zero() {
return Err(PrikkError::CanonicalEncoding(
"CreateSymlink node_id must be nonzero".to_string(),
));
}
Ok(())
}
}
impl CanonicalEncode for CreateSymlink {
fn encode_canonical(&self, writer: &mut CanonicalWriter) -> Result<()> {
self.validate()?;
writer.field_repo_path(1, &self.path)?;
writer.field_bytes(2, self.node_id.as_bytes())?;
writer.field_string(3, &self.target)?;
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReplaceBinary {
pub node_id: NodeId,
pub old_blob_id: ObjectId,
pub new_blob_id: ObjectId,
}
impl ReplaceBinary {
pub fn validate(&self) -> Result<()> {
if self.node_id.is_zero() {
return Err(PrikkError::CanonicalEncoding(
"ReplaceBinary node_id must be nonzero".to_string(),
));
}
Ok(())
}
}
impl CanonicalEncode for ReplaceBinary {
fn encode_canonical(&self, writer: &mut CanonicalWriter) -> Result<()> {
self.validate()?;
writer.field_bytes(1, self.node_id.as_bytes())?;
writer.field_object_id(2, &self.old_blob_id)?;
writer.field_object_id(3, &self.new_blob_id)?;
Ok(())
}
}