prikk-object 0.26.0

Object identity, canonical encoding, and payload types for Prikk. Internal to the prikk CLI; its API may change without notice before 1.0.
Documentation
//! Object identifiers and object type codes.

use core::fmt;
use core::str::FromStr;

use prikk_error::{PrikkError, Result};
use prikk_hash::{sha256, to_hex};

/// Single domain used for object identity preimages.
pub const OBJECT_ID_DOMAIN: &[u8] = b"PRIKK-OBJECT-ID-v1";

/// Object type codes retired from the assignable range. `from_code` refuses every one of these
/// with a message naming the retirement, checked *before* the live-code match below, so
/// re-adding a code here can never silently start decoding again just because a future match arm
/// happens to claim it too -- the retirement always wins. There are 245 codes free in the u16
/// range `from_code`/`code` never use below `0x100`; the benefit of ever reusing a retired one is
/// zero and the cost of a collision (two different object shapes sharing one identity-preimage
/// type tag) is unbounded.
const RETIRED_CODES: &[(u16, &str)] = &[(0x0A, "project-genesis")];

// RFC 118 stage 6, applying the same discipline as stage 4's `verification_stages!` and DC-21's
// `conflict_witness_kinds!`: the variant list, `ALL`, `from_code`, and `name()` are generated
// together from one token list, in one macro expansion, rather than kept as independently
// hand-maintained lists a hand-added variant can silently omit from. Unlike those two prior
// macros, `ObjectType` carries an explicit `u16` discriminant per variant -- `from_code` needs
// the reverse (code -> variant) mapping, which cannot be derived from `self as u16` alone, so the
// discriminant token is written once per variant and copied by this expansion into both the enum
// discriminant and the `from_code` match arm. That is the one duplication this shape cannot
// avoid (the two things really are different directions of the same mapping); everything else --
// adding an eleventh type -- means adding one line here and nowhere else.
macro_rules! object_types {
    (
        $(
            $(#[$doc:meta])*
            $variant:ident = $code:literal => $name:literal,
        )+
    ) => {
        /// A Prikk object type code.
        #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
        #[repr(u16)]
        pub enum ObjectType {
            $(
                $(#[$doc])*
                $variant = $code,
            )+
        }

        impl ObjectType {
            /// Every live variant, in declaration order. Generated by this enum's own defining
            /// macro invocation, not a second, independently maintained list -- see
            /// `id/tests.rs`'s `a_new_variant_reaches_every_generated_consumer`-style controls for
            /// why that matters: a hand-written `ALL` cannot be forced to grow when a variant is
            /// added, only a match can, and `ALL` is not a match.
            pub const ALL: &'static [Self] = &[$(Self::$variant),+];

            /// Return the stable u16 code used in object identity bytes.
            #[must_use]
            pub const fn code(self) -> u16 {
                self as u16
            }

            /// Parse a stable u16 code.
            pub fn from_code(code: u16) -> Result<Self> {
                if let Some((_, name)) = RETIRED_CODES.iter().find(|&&(retired, _)| retired == code) {
                    return Err(PrikkError::MalformedData(format!(
                        "object type code {code} is retired (formerly {name}) and must never be reused"
                    )));
                }
                match code {
                    $($code => Ok(Self::$variant),)+
                    other => Err(PrikkError::MalformedData(format!(
                        "unknown object type code: {other}"
                    ))),
                }
            }

            /// Return a stable human-readable name.
            #[must_use]
            pub const fn name(self) -> &'static str {
                match self {
                    $(Self::$variant => $name,)+
                }
            }
        }
    };
}

object_types! {
    /// Patch object.
    Patch = 0x01 => "patch",
    /// Block object.
    Block = 0x02 => "block",
    /// RefState object.
    RefState = 0x03 => "ref-state",
    /// RefUpdate event. Object-envelope type stored inline in `refs/logs/`
    /// (journal then log), not a permanent object-store directory.
    RefUpdate = 0x04 => "ref-update",
    /// Tag object.
    Tag = 0x05 => "tag",
    /// Attestation object.
    Attestation = 0x06 => "attestation",
    /// Blob object.
    Blob = 0x07 => "blob",
    /// Rebuildable block-summary cache. Uses the canonical codec for
    /// reproducibility but is never a root of trust or part of block identity.
    BlockSummaryCache = 0x08 => "block-summary-cache",
    /// Signed doctor-repair note stored inline in `refs/recovery/`. Never a
    /// `RefUpdate` substitute (FDD-02 §10.4).
    RecoveryNote = 0x09 => "recovery-note",
    // `0x0A` was `ProjectGenesis` (FDD-03 §9.13) -- deleted (repository-identity settlement
    // handoff v1): a project-level genesis object implies repositories carry an identity to
    // anchor, which the shipped design never grants (RFC 115 §2.4-§2.7: repositories are
    // anonymous, identity lives only in signer keys and patch ids). No payload module ever
    // existed for it, `admitted_schemas` always returned `None`, and nothing could construct
    // one. See `RETIRED_CODES` above -- `0x0A` must never be reassigned.
    /// RFC 115 Stage 2 (design-v1.md D3): a signed claim that named patches were sealed into a
    /// named block, under the signer's key. Never trust-conferring and never existence-checked
    /// against the block/patches it names — see `RecognitionClaimPayload`'s own doc.
    RecognitionClaim = 0x0B => "recognition-claim",
}

impl fmt::Display for ObjectType {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.name())
    }
}

/// A 32-byte object identifier.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ObjectId([u8; 32]);

impl ObjectId {
    /// Construct an object ID from raw bytes.
    #[must_use]
    pub const fn from_bytes(bytes: [u8; 32]) -> Self {
        Self(bytes)
    }

    /// Return raw ID bytes.
    #[must_use]
    pub const fn as_bytes(&self) -> &[u8; 32] {
        &self.0
    }

    /// Compute an object ID from object type, schema version, and unsigned canonical payload.
    #[must_use]
    pub fn from_canonical_payload(
        object_type: ObjectType,
        schema_version: u32,
        canonical_payload: &[u8],
    ) -> Self {
        let mut preimage =
            Vec::with_capacity(OBJECT_ID_DOMAIN.len() + 2 + 4 + 8 + canonical_payload.len());
        preimage.extend_from_slice(OBJECT_ID_DOMAIN);
        preimage.extend_from_slice(&object_type.code().to_be_bytes());
        preimage.extend_from_slice(&schema_version.to_be_bytes());
        preimage.extend_from_slice(&(canonical_payload.len() as u64).to_be_bytes());
        preimage.extend_from_slice(canonical_payload);
        Self(sha256(&preimage))
    }

    /// Return lowercase hex.
    #[must_use]
    pub fn to_hex(&self) -> String {
        to_hex(&self.0)
    }
}

impl fmt::Debug for ObjectId {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "ObjectId({})", self.to_hex())
    }
}

impl fmt::Display for ObjectId {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.to_hex())
    }
}

impl FromStr for ObjectId {
    type Err = PrikkError;

    fn from_str(s: &str) -> Result<Self> {
        if s.len() != 64 {
            return Err(PrikkError::InvalidObjectId(format!(
                "expected 64 lowercase hex chars, got {}",
                s.len()
            )));
        }
        let mut out = [0_u8; 32];
        for (slot, pair) in out.iter_mut().zip(s.as_bytes().chunks_exact(2)) {
            let mut bytes = pair.iter().copied();
            let high = bytes.next().ok_or_else(|| {
                PrikkError::InvalidObjectId("hex pair is unexpectedly short".to_string())
            })?;
            let low = bytes.next().ok_or_else(|| {
                PrikkError::InvalidObjectId("hex pair is unexpectedly short".to_string())
            })?;
            *slot = (hex_value(high)? << 4) | hex_value(low)?;
        }
        Ok(Self(out))
    }
}

fn hex_value(byte: u8) -> Result<u8> {
    match byte {
        b'0'..=b'9' => Ok(byte - b'0'),
        b'a'..=b'f' => Ok(byte - b'a' + 10),
        _ => Err(PrikkError::InvalidObjectId(
            "object IDs must use lowercase hex only".to_string(),
        )),
    }
}

#[cfg(test)]
mod tests;