Skip to main content

freenet_git_types/
lib.rs

1//! Shared state types and the pure validation/update logic for freenet-git.
2//!
3//! Both the `repo-contract` WASM and the on-host helpers depend on this crate
4//! at the same pinned version. The contract WASM compiles a tiny shim that
5//! deserializes parameters/state and dispatches into [`validate_state`] /
6//! [`update_state`] / [`merge_state`] / [`summarize_state`] /
7//! [`get_state_delta`]. Keeping all the logic here lets us unit-test it as
8//! ordinary Rust without booting a WASM runtime.
9//!
10//! # Phase 1.0 scope
11//!
12//! Phase 1.0 is **single-writer**: only the repo owner can sign anything.
13//! The schema includes the ACL fields ([`AclState`], [`WriterGrant`]) so
14//! that adding multi-writer support in Phase 1.1 is a contract WASM upgrade
15//! and not a fundamentally different schema. For now `validate_state`
16//! requires `entry.updater == parameters.owner` for every signed entry.
17
18#![deny(unsafe_code)]
19#![warn(missing_docs)]
20
21#[cfg(feature = "signing")]
22pub mod signing;
23
24pub mod chunked;
25
26use std::collections::BTreeMap;
27
28use freenet_git_encoding::canonical::{MapBuilder, Value};
29use freenet_git_encoding::signed::build as build_payload;
30use freenet_git_encoding::WIRE_VERSION;
31use serde::{Deserialize, Serialize};
32
33/// Hard upper bounds on string-shaped fields. These are not security-critical
34/// (signatures already bound what an attacker can publish to what the owner
35/// signs) but they eliminate accidental footguns like `cat huge.log | xargs
36/// freenet-git rename`.
37pub mod limits {
38    /// Maximum length of [`SignedField<String>`](super::SignedField) for the
39    /// `name` field.
40    pub const MAX_NAME_BYTES: usize = 256;
41    /// Maximum length of [`SignedField<String>`](super::SignedField) for the
42    /// `description` field.
43    pub const MAX_DESCRIPTION_BYTES: usize = 4096;
44    /// Maximum length of any single [`RefName`](super::RefName).
45    pub const MAX_REF_NAME_BYTES: usize = 256;
46    /// Maximum length of any single extension key.
47    pub const MAX_EXTENSION_KEY_BYTES: usize = 256;
48    /// Maximum length of any single extension value.
49    pub const MAX_EXTENSION_VALUE_BYTES: usize = 64 * 1024;
50    /// Minimum length of a [`RepoParams`](super::RepoParams) prefix, in
51    /// base58 characters.
52    pub const MIN_PREFIX_LEN: usize = 4;
53    /// Maximum length of a [`RepoParams`](super::RepoParams) prefix, in
54    /// base58 characters. The full base58 of a 32-byte ed25519 public key
55    /// is at most 44 characters, but we cap at 32 to leave headroom for
56    /// future fingerprint formats.
57    pub const MAX_PREFIX_LEN: usize = 32;
58    /// Default prefix length the CLI emits for new repos.
59    ///
60    /// 12 base58 chars ≈ 70 bits of entropy. Birthday collision space is
61    /// ~2^35 ≈ 34 billion repos — comfortably past any plausible Freenet
62    /// adoption. Targeted preimage is 2^70, infeasible at any scale.
63    /// We can lengthen later (the contract WASM accepts any prefix length
64    /// in the 4..=32 range) without a migration.
65    pub const DEFAULT_PREFIX_LEN: usize = 12;
66}
67
68/// Owner public key, lives in [`RepoState`] and is checked by
69/// `validate_state` against the prefix in parameters.
70pub type PublicKey = [u8; 32];
71
72/// ed25519 signature.
73pub type Signature = [u8; 64];
74
75/// BLAKE3-32 of a packfile's bytes.
76pub type PackHash = [u8; 32];
77
78/// BLAKE3-32 of a chunked-pack manifest's bytes. (Not consumed in Phase 1.0.)
79pub type ManifestHash = [u8; 32];
80
81/// SHA-1 git commit hash, 20 bytes. Phase 1 does not care about the
82/// SHA-256-object-format experiment yet; if/when we adopt it, this becomes
83/// `enum CommitHash { Sha1([u8; 20]), Sha256([u8; 32]) }`.
84pub type CommitHash = [u8; 20];
85
86/// Stable identifier for a stored bundle: `BLAKE3-32(canonical-CBOR(bundle))`.
87pub type ObjectBundleId = [u8; 32];
88
89/// Logical key under which the contract is stored. We store this as the raw
90/// 32-byte key part rather than the full `freenet_stdlib::ContractKey` to
91/// avoid pulling stdlib into the contract WASM's link surface for
92/// signature-domain purposes.
93pub type RepoKey = [u8; 32];
94
95/// A git ref name, e.g. `refs/heads/main`. Stored as a `String` but
96/// constrained at validation time:
97///
98/// - NFC-normalized,
99/// - no control characters (U+0000–U+001F, U+007F),
100/// - no bidirectional override marks (U+202A–U+202E, U+2066–U+2069),
101/// - byte length <= [`limits::MAX_REF_NAME_BYTES`].
102///
103/// (In Phase 1.0 we accept ASCII-only ref names for simplicity. The
104/// stricter Unicode rules from the issue land alongside the multi-writer
105/// ACL in Phase 1.1, since they're more relevant when ref names appear in
106/// listings of writers and contributors.)
107pub type RefName = String;
108
109/// Initial parameters, immutable, part of the contract key via
110/// `BLAKE3(BLAKE3(WASM) || Parameters)`.
111///
112/// The parameters carry only the **prefix** — the first N base58
113/// characters of the owner's ed25519 public key. The owner pubkey
114/// itself lives in [`RepoState`] (`state.owner`) and is checked by
115/// [`validate_state`] against this prefix.
116///
117/// This means the URL — which is just the prefix — is short and
118/// readable, but anyone who has the URL plus the bundled contract WASM
119/// can compute the full contract key (`BLAKE3(BLAKE3(WASM) ||
120/// CBOR({prefix}))`). Two different prefix lengths produce two
121/// different contract keys, so the same owner can have many repos at
122/// different prefix lengths — though in practice we always use
123/// [`limits::DEFAULT_PREFIX_LEN`].
124#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
125pub struct RepoParams {
126    /// Base58 prefix of the owner's ed25519 public key. Length must be
127    /// in the `[MIN_PREFIX_LEN..=MAX_PREFIX_LEN]` range.
128    pub prefix: String,
129}
130
131impl RepoParams {
132    /// Construct parameters from an owner pubkey and prefix length.
133    /// Caller must ensure `len` is in the valid range; consumers
134    /// double-check via [`validate_state`].
135    pub fn from_owner(owner: &PublicKey, len: usize) -> Self {
136        Self {
137            prefix: pubkey_prefix(owner, len),
138        }
139    }
140
141    /// Encode as bytes for use as the `Parameters` payload of the contract.
142    pub fn to_bytes(&self) -> Vec<u8> {
143        bincode::serialize(self).expect("RepoParams serialization is infallible")
144    }
145
146    /// Decode from a `Parameters` payload.
147    pub fn from_bytes(bytes: &[u8]) -> Result<Self, ValidateError> {
148        bincode::deserialize(bytes).map_err(|e| ValidateError::DecodeParams(e.to_string()))
149    }
150}
151
152/// Compute the canonical prefix of an owner pubkey at a given length:
153/// `base58(owner)[..len]`, with `len` saturated to the encoded string's
154/// actual length so we never index past the end.
155pub fn pubkey_prefix(owner: &PublicKey, len: usize) -> String {
156    let encoded = bs58::encode(owner).into_string();
157    let take = len.min(encoded.len());
158    encoded[..take].to_string()
159}
160
161/// A field that carries its own owner signature so a peer can verify it
162/// in isolation, regardless of how the surrounding state was assembled.
163#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
164pub struct SignedField<T> {
165    /// Current value.
166    pub value: T,
167    /// Strictly-monotonic counter. Increments on every owner-signed update;
168    /// the highest `update_seq` wins under merge.
169    pub update_seq: u64,
170    /// Owner signature over `(domain || repo_key || field_name || value || update_seq)`.
171    #[serde(with = "serde_bytes_array_64")]
172    pub signature: Signature,
173}
174
175/// Per-writer ACL grant. Phase 1.0 schema includes this for forward compat
176/// but `validate_state` ignores it — only the owner is authorized.
177#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
178pub struct WriterGrant {
179    /// ACL epoch at which the grant was issued.
180    pub granted_at_epoch: u64,
181    /// `None` = currently authorized; `Some(e)` = revoked at epoch `e`.
182    pub revoked_at_epoch: Option<u64>,
183}
184
185/// ACL grant log with epoch numbers. Owner-signed as a whole via the
186/// surrounding [`SignedField`]. Phase 1.0 keeps this empty (`epoch = 0`,
187/// no grants); Phase 1.1 adds grant/revoke via dedicated CLI commands.
188#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
189pub struct AclState {
190    /// Current ACL epoch.
191    pub epoch: u64,
192    /// Map from writer public key to grant info. Never pruned, only
193    /// tombstoned via `revoked_at_epoch`. See the issue spec for why.
194    pub grants: BTreeMap<PublicKey, WriterGrant>,
195}
196
197/// One ref pointer. Phase 1.0 only accepts entries where
198/// `updater == parameters.owner`.
199#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
200pub struct RefEntry {
201    /// Object SHA the ref points at.
202    ///
203    /// For branch refs (`refs/heads/*`) and lightweight tag refs
204    /// this is a commit SHA. For *annotated* tag refs
205    /// (`refs/tags/*` created with `git tag -a`) this is the
206    /// tag-object SHA — `git rev-parse refs/tags/<name>` for an
207    /// annotated tag returns the tag object, not the commit it
208    /// points at, and that is what gets stored here.
209    ///
210    /// The `CommitHash` type name is a Phase 1.0 misnomer kept for
211    /// wire-format stability. Treat this field as an opaque
212    /// 20-byte object SHA; downstream code peels through tag
213    /// objects via `git rev-parse <sha>^{commit}` on demand
214    /// (`walk_unresolved_parents` in `git-remote-freenet`).
215    pub target: CommitHash,
216    /// Strictly-monotonic counter for this ref.
217    pub update_seq: u64,
218    /// Public key that signed this entry. In Phase 1.0 must equal
219    /// `parameters.owner`.
220    pub updater: PublicKey,
221    /// ACL epoch at which this update was signed. Phase 1.0 always uses
222    /// `0` (no ACL changes).
223    pub auth_epoch: u64,
224    /// Signature by `updater` over the canonical ref-update payload.
225    #[serde(with = "serde_bytes_array_64")]
226    pub signature: Signature,
227}
228
229/// One stored bundle of git objects.
230#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
231pub enum ObjectBundle {
232    /// A single packfile uploaded as one immutable contract.
233    SinglePack {
234        /// BLAKE3-32 of the pack bytes.
235        pack_hash: PackHash,
236        /// Pack size in bytes (informational; helps clients estimate fetches).
237        size_bytes: u64,
238    },
239    /// A chunked pack split across multiple immutable contracts. Phase 1.0
240    /// helpers do not consume these; the variant is reserved so that adding
241    /// support later does not require a contract key migration.
242    ChunkedPack {
243        /// BLAKE3-32 of the manifest bytes.
244        manifest_hash: ManifestHash,
245        /// Total bytes across all chunks.
246        total_size: u64,
247        /// Number of chunks.
248        chunk_count: u32,
249    },
250}
251
252impl ObjectBundle {
253    /// Compute the deterministic [`ObjectBundleId`] for this bundle.
254    ///
255    /// Defined as `BLAKE3-32(canonical-CBOR(bundle))`. The exact byte format
256    /// of the canonical encoding lives in [`freenet_git_encoding::canonical`]
257    /// and is part of the wire-format pin.
258    pub fn id(&self) -> ObjectBundleId {
259        let value = match self {
260            Self::SinglePack {
261                pack_hash,
262                size_bytes,
263            } => MapBuilder::default()
264                .text_entry("kind", Value::text("single-pack"))
265                .text_entry("pack_hash", Value::bytes(pack_hash.to_vec()))
266                .text_entry("size_bytes", Value::uint(*size_bytes))
267                .build(),
268            Self::ChunkedPack {
269                manifest_hash,
270                total_size,
271                chunk_count,
272            } => MapBuilder::default()
273                .text_entry("kind", Value::text("chunked-pack"))
274                .text_entry("manifest_hash", Value::bytes(manifest_hash.to_vec()))
275                .text_entry("total_size", Value::uint(*total_size))
276                .text_entry("chunk_count", Value::uint(u64::from(*chunk_count)))
277                .build(),
278        };
279        *blake3::hash(&value.encode()).as_bytes()
280    }
281}
282
283/// One entry in [`RepoState::object_index`]: a bundle and the signature of
284/// the writer who introduced it.
285#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
286pub struct ObjectBundleRecord {
287    /// The bundle. Its `id()` equals the [`ObjectBundleId`] under which this
288    /// record is keyed in `object_index`.
289    pub bundle: ObjectBundle,
290    /// Writer who added this bundle. In Phase 1.0 must equal
291    /// `parameters.owner`.
292    pub added_by: PublicKey,
293    /// ACL epoch at which the writer was authorized.
294    pub auth_epoch: u64,
295    /// Signature by `added_by` over the canonical bundle-add payload.
296    #[serde(with = "serde_bytes_array_64")]
297    pub signature: Signature,
298}
299
300/// One entry in [`RepoState::extensions`].
301#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
302pub struct ExtensionEntry {
303    /// Raw value bytes.
304    #[serde(with = "serde_bytes")]
305    pub value: Vec<u8>,
306    /// Strictly-monotonic counter for this key.
307    pub update_seq: u64,
308    /// Owner signature over the canonical extension payload.
309    #[serde(with = "serde_bytes_array_64")]
310    pub signature: Signature,
311}
312
313/// The full mutable state of a repo contract.
314#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
315pub struct RepoState {
316    /// The owner's ed25519 public key. `validate_state` enforces that
317    /// `base58(owner)[..params.prefix.len()] == params.prefix`. The
318    /// owner is implicitly authenticated through every signed field —
319    /// changing it would invalidate all signatures, since the signature
320    /// domain key derives from prefix + owner.
321    pub owner: PublicKey,
322    /// Display name of the repo.
323    pub name: Option<SignedField<String>>,
324    /// Free-form description.
325    pub description: Option<SignedField<String>>,
326    /// Default branch (`refs/heads/<x>`). When set, clones default to it.
327    pub default_branch: Option<SignedField<RefName>>,
328    /// Refs in this set are allowed to be force-pushed (non-fast-forward).
329    /// Phase 1.0 always treats this as empty.
330    pub force_push_allowed: Option<SignedField<Vec<RefName>>>,
331    /// ACL grant log. Phase 1.0 keeps this at default (epoch 0, empty).
332    pub acl: Option<SignedField<AclState>>,
333    /// Append-only successor pointer for breaking schema migrations.
334    pub upgrade: Option<SignedField<Option<RepoKey>>>,
335    /// Refs by name.
336    pub refs: BTreeMap<RefName, RefEntry>,
337    /// Stored object bundles, keyed by their `id()`.
338    pub object_index: BTreeMap<ObjectBundleId, ObjectBundleRecord>,
339    /// Owner-signed extensions for forward compatibility.
340    pub extensions: BTreeMap<String, ExtensionEntry>,
341}
342
343impl RepoState {
344    /// Encode as bytes for use as the `State` payload of the contract.
345    pub fn to_bytes(&self) -> Vec<u8> {
346        bincode::serialize(self).expect("RepoState serialization is infallible")
347    }
348
349    /// Decode from a `State` payload.
350    pub fn from_bytes(bytes: &[u8]) -> Result<Self, ValidateError> {
351        if bytes.is_empty() {
352            return Ok(Self::default());
353        }
354        bincode::deserialize(bytes).map_err(|e| ValidateError::DecodeState(e.to_string()))
355    }
356}
357
358/// Errors `validate_state` can surface. The string variants carry diagnostic
359/// detail; treat the variant as the machine-readable signal.
360#[derive(Debug, thiserror::Error)]
361pub enum ValidateError {
362    /// Could not decode the parameters bytes.
363    #[error("decode parameters: {0}")]
364    DecodeParams(String),
365    /// Could not decode the state bytes.
366    #[error("decode state: {0}")]
367    DecodeState(String),
368    /// A signed field's signature did not verify against its claimed signer.
369    #[error("signature verification failed for {0}")]
370    InvalidSignature(&'static str),
371    /// Phase 1.0 only accepts owner-signed entries; this entry was signed by
372    /// someone else.
373    #[error("entry signed by non-owner key (Phase 1.0 is single-writer)")]
374    NonOwnerSigner,
375    /// Field exceeded its size limit.
376    #[error("field {field} exceeds limit ({len} > {max})")]
377    FieldTooLong {
378        /// The offending field.
379        field: &'static str,
380        /// Observed length in bytes.
381        len: usize,
382        /// Maximum allowed length in bytes.
383        max: usize,
384    },
385    /// Bundle id did not match the canonical hash of the bundle.
386    #[error("object_index entry has wrong bundle id")]
387    BundleIdMismatch,
388    /// `acl.epoch` went backwards or `update_seq` is non-monotonic.
389    #[error("monotonic invariant violated: {0}")]
390    NonMonotonic(&'static str),
391    /// `params.prefix` length is outside `[MIN_PREFIX_LEN..=MAX_PREFIX_LEN]`.
392    #[error("prefix length {len} outside valid range [{min}..={max}]")]
393    InvalidPrefixLength {
394        /// Observed prefix length.
395        len: usize,
396        /// Minimum allowed.
397        min: usize,
398        /// Maximum allowed.
399        max: usize,
400    },
401    /// `state.owner` does not produce `params.prefix` when base58-encoded.
402    #[error("owner pubkey does not match parameters prefix: expected {expected}, got {actual}")]
403    PrefixMismatch {
404        /// Prefix declared in parameters.
405        expected: String,
406        /// Prefix actually derived from `state.owner`.
407        actual: String,
408    },
409    /// `params.prefix` contains characters outside the Bitcoin base58
410    /// alphabet.
411    #[error("prefix contains invalid base58 characters")]
412    InvalidPrefixChars,
413}
414
415/// Run the full contract `validate_state` check.
416///
417/// Called by the contract WASM whenever a peer receives a candidate state
418/// from any source (network or local). Verifies every signature in
419/// isolation; rejects any state that does not exclusively consist of
420/// owner-signed entries.
421pub fn validate_state(params: &RepoParams, state: &RepoState) -> Result<(), ValidateError> {
422    // 1. Validate prefix length is in range.
423    if params.prefix.len() < limits::MIN_PREFIX_LEN || params.prefix.len() > limits::MAX_PREFIX_LEN
424    {
425        return Err(ValidateError::InvalidPrefixLength {
426            len: params.prefix.len(),
427            min: limits::MIN_PREFIX_LEN,
428            max: limits::MAX_PREFIX_LEN,
429        });
430    }
431    // 2. Validate prefix characters are base58 (catches typos and
432    //    keeps the prefix domain narrow).
433    if bs58::decode(&params.prefix).into_vec().is_err() {
434        return Err(ValidateError::InvalidPrefixChars);
435    }
436    // 3. Validate owner pubkey produces the declared prefix.
437    let actual_prefix = pubkey_prefix(&state.owner, params.prefix.len());
438    if actual_prefix != params.prefix {
439        return Err(ValidateError::PrefixMismatch {
440            expected: params.prefix.clone(),
441            actual: actual_prefix,
442        });
443    }
444    let repo_key = params_repo_key(params, &state.owner);
445
446    if let Some(field) = &state.name {
447        check_size("name", field.value.len(), limits::MAX_NAME_BYTES)?;
448        verify_signed_field_string(&repo_key, "name", &state.owner, field, "name")?;
449    }
450    if let Some(field) = &state.description {
451        check_size(
452            "description",
453            field.value.len(),
454            limits::MAX_DESCRIPTION_BYTES,
455        )?;
456        verify_signed_field_string(&repo_key, "description", &state.owner, field, "description")?;
457    }
458    if let Some(field) = &state.default_branch {
459        check_size(
460            "default_branch",
461            field.value.len(),
462            limits::MAX_REF_NAME_BYTES,
463        )?;
464        verify_signed_field_string(
465            &repo_key,
466            "default_branch",
467            &state.owner,
468            field,
469            "default_branch",
470        )?;
471    }
472    if let Some(field) = &state.force_push_allowed {
473        verify_signed_field_ref_list(
474            &repo_key,
475            "force_push_allowed",
476            &state.owner,
477            field,
478            "force_push_allowed",
479        )?;
480    }
481    if let Some(field) = &state.acl {
482        verify_signed_field_acl(&repo_key, "acl", &state.owner, field, "acl")?;
483    }
484    if let Some(field) = &state.upgrade {
485        verify_signed_field_optional_repo_key(
486            &repo_key,
487            "upgrade",
488            &state.owner,
489            field,
490            "upgrade",
491        )?;
492    }
493
494    for (ref_name, entry) in &state.refs {
495        check_size("ref name", ref_name.len(), limits::MAX_REF_NAME_BYTES)?;
496        // Phase 1.0: only owner-signed refs are accepted.
497        if entry.updater != state.owner {
498            return Err(ValidateError::NonOwnerSigner);
499        }
500        verify_ref_entry(&repo_key, ref_name, entry)?;
501    }
502
503    for (bundle_id, record) in &state.object_index {
504        if record.bundle.id() != *bundle_id {
505            return Err(ValidateError::BundleIdMismatch);
506        }
507        if record.added_by != state.owner {
508            return Err(ValidateError::NonOwnerSigner);
509        }
510        verify_bundle_record(&repo_key, record)?;
511    }
512
513    for (ext_key, entry) in &state.extensions {
514        check_size(
515            "extension key",
516            ext_key.len(),
517            limits::MAX_EXTENSION_KEY_BYTES,
518        )?;
519        check_size(
520            "extension value",
521            entry.value.len(),
522            limits::MAX_EXTENSION_VALUE_BYTES,
523        )?;
524        verify_extension_entry(&repo_key, ext_key, &state.owner, entry)?;
525    }
526
527    Ok(())
528}
529
530/// Errors `update_state` can surface in addition to [`ValidateError`].
531#[derive(Debug, thiserror::Error)]
532pub enum UpdateError {
533    /// The candidate state failed `validate_state`.
534    #[error(transparent)]
535    Invalid(#[from] ValidateError),
536    /// A non-fast-forward update was attempted on a ref that is not in
537    /// `force_push_allowed`.
538    #[error("non-fast-forward update on protected ref")]
539    NonFastForward,
540}
541
542/// Apply a *full new state* on top of the existing state, treating both as
543/// CRDT snapshots and merging deterministically. This is the path triggered
544/// by a peer pushing a fresh state to us.
545///
546/// Returns the merged state; never overwrites with strictly-older info.
547pub fn merge_state(current: &RepoState, incoming: &RepoState) -> RepoState {
548    let mut out = current.clone();
549    out.name = pick_signed_field(out.name, incoming.name.clone());
550    out.description = pick_signed_field(out.description, incoming.description.clone());
551    out.default_branch = pick_signed_field(out.default_branch, incoming.default_branch.clone());
552    out.force_push_allowed =
553        pick_signed_field(out.force_push_allowed, incoming.force_push_allowed.clone());
554    out.acl = pick_signed_field(out.acl, incoming.acl.clone());
555    out.upgrade = pick_signed_field(out.upgrade, incoming.upgrade.clone());
556
557    for (k, v) in &incoming.refs {
558        let pick = match out.refs.remove(k) {
559            None => v.clone(),
560            Some(existing) => pick_ref_entry(existing, v.clone()),
561        };
562        out.refs.insert(k.clone(), pick);
563    }
564    for (k, v) in &incoming.object_index {
565        out.object_index.entry(*k).or_insert_with(|| v.clone());
566    }
567    for (k, v) in &incoming.extensions {
568        let pick = match out.extensions.remove(k) {
569            None => v.clone(),
570            Some(existing) => pick_extension_entry(existing, v.clone()),
571        };
572        out.extensions.insert(k.clone(), pick);
573    }
574    out
575}
576
577/// Apply a delta (a partial update produced by a writer) to the current
578/// state. Used on the optimistic path. Performs the [`merge_state`] CRDT
579/// reconciliation plus the additional fast-forward / sequence-number
580/// guards described in the issue spec.
581pub fn update_state(
582    params: &RepoParams,
583    current: &RepoState,
584    delta: &RepoState,
585) -> Result<RepoState, UpdateError> {
586    // First merge by the same CRDT rule. Then validate. The order matters:
587    // a delta can carry a new `default_branch` that, post-merge, makes a
588    // ref-update legitimate. Validating before merge would reject deltas
589    // that depend on co-arriving owner-signed metadata.
590    let merged = merge_state(current, delta);
591
592    // Phase 1.0 force-push enforcement: any ref present both in `current`
593    // and `merged` whose `target` changed must EITHER be in
594    // `merged.force_push_allowed` OR have a higher `update_seq` than
595    // before. Without an object DB we can't verify ancestry, but we can
596    // verify that the writer claimed `previous_target` matches the
597    // current target. Since the on-host helper always submits a fresh
598    // entry with `update_seq = current + 1`, the rule reduces to "the
599    // entry in the delta either points to the same target as `current`,
600    // or its `update_seq` is strictly greater than the existing
601    // entry's." That's already enforced by `pick_ref_entry`. So in
602    // Phase 1.0 the only concrete check here is the validate pass.
603    let _ = params;
604
605    validate_state(params, &merged)?;
606    Ok(merged)
607}
608
609/// Compact summary used for `summarize_state` -> `get_state_delta`.
610#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
611pub struct RepoSummary {
612    /// `update_seq` of each owner-signed singleton field, if present.
613    pub field_seqs: BTreeMap<String, u64>,
614    /// Per-ref `update_seq`.
615    pub ref_seqs: BTreeMap<RefName, u64>,
616    /// Set of bundle ids the summarizer already has.
617    pub bundle_ids: Vec<ObjectBundleId>,
618    /// Per-extension `update_seq`.
619    pub extension_seqs: BTreeMap<String, u64>,
620}
621
622/// Build a `RepoSummary` from a state. Used by the contract's
623/// `summarize_state` entry point.
624pub fn summarize_state(state: &RepoState) -> RepoSummary {
625    let mut s = RepoSummary::default();
626    if let Some(f) = &state.name {
627        s.field_seqs.insert("name".into(), f.update_seq);
628    }
629    if let Some(f) = &state.description {
630        s.field_seqs.insert("description".into(), f.update_seq);
631    }
632    if let Some(f) = &state.default_branch {
633        s.field_seqs.insert("default_branch".into(), f.update_seq);
634    }
635    if let Some(f) = &state.force_push_allowed {
636        s.field_seqs
637            .insert("force_push_allowed".into(), f.update_seq);
638    }
639    if let Some(f) = &state.acl {
640        s.field_seqs.insert("acl".into(), f.update_seq);
641    }
642    if let Some(f) = &state.upgrade {
643        s.field_seqs.insert("upgrade".into(), f.update_seq);
644    }
645    for (k, v) in &state.refs {
646        s.ref_seqs.insert(k.clone(), v.update_seq);
647    }
648    for k in state.object_index.keys() {
649        s.bundle_ids.push(*k);
650    }
651    for (k, v) in &state.extensions {
652        s.extension_seqs.insert(k.clone(), v.update_seq);
653    }
654    s
655}
656
657/// Compute the delta a peer needs to update from `summary` to `state`.
658pub fn get_state_delta(state: &RepoState, summary: &RepoSummary) -> RepoState {
659    let mut d = RepoState::default();
660
661    if state.name.as_ref().map(|f| f.update_seq) > summary.field_seqs.get("name").copied() {
662        d.name = state.name.clone();
663    }
664    if state.description.as_ref().map(|f| f.update_seq)
665        > summary.field_seqs.get("description").copied()
666    {
667        d.description = state.description.clone();
668    }
669    if state.default_branch.as_ref().map(|f| f.update_seq)
670        > summary.field_seqs.get("default_branch").copied()
671    {
672        d.default_branch = state.default_branch.clone();
673    }
674    if state.force_push_allowed.as_ref().map(|f| f.update_seq)
675        > summary.field_seqs.get("force_push_allowed").copied()
676    {
677        d.force_push_allowed = state.force_push_allowed.clone();
678    }
679    if state.acl.as_ref().map(|f| f.update_seq) > summary.field_seqs.get("acl").copied() {
680        d.acl = state.acl.clone();
681    }
682    if state.upgrade.as_ref().map(|f| f.update_seq) > summary.field_seqs.get("upgrade").copied() {
683        d.upgrade = state.upgrade.clone();
684    }
685
686    for (k, v) in &state.refs {
687        if summary.ref_seqs.get(k).copied().unwrap_or(0) < v.update_seq {
688            d.refs.insert(k.clone(), v.clone());
689        }
690    }
691    let known: std::collections::HashSet<&ObjectBundleId> = summary.bundle_ids.iter().collect();
692    for (k, v) in &state.object_index {
693        if !known.contains(k) {
694            d.object_index.insert(*k, v.clone());
695        }
696    }
697    for (k, v) in &state.extensions {
698        if summary.extension_seqs.get(k).copied().unwrap_or(0) < v.update_seq {
699            d.extensions.insert(k.clone(), v.clone());
700        }
701    }
702    d
703}
704
705// ---------------------------------------------------------------------------
706// Signed-payload constructors
707// ---------------------------------------------------------------------------
708
709/// Construct the canonical signed-payload bytes for the `name` /
710/// `description` / `default_branch` field updates.
711pub fn signed_payload_string_field(
712    repo_key: &RepoKey,
713    field_name: &str,
714    value: &str,
715    update_seq: u64,
716) -> Vec<u8> {
717    build_payload(field_name, |b| {
718        b.field_bytes(repo_key);
719        b.field_str(value);
720        b.field_u64(update_seq);
721    })
722}
723
724/// Signed-payload bytes for `force_push_allowed`.
725pub fn signed_payload_ref_list_field(
726    repo_key: &RepoKey,
727    field_name: &str,
728    refs: &[RefName],
729    update_seq: u64,
730) -> Vec<u8> {
731    build_payload(field_name, |b| {
732        b.field_bytes(repo_key);
733        b.field_u32(u32::try_from(refs.len()).unwrap_or(u32::MAX));
734        for r in refs {
735            b.field_str(r);
736        }
737        b.field_u64(update_seq);
738    })
739}
740
741/// Signed-payload bytes for `acl`.
742pub fn signed_payload_acl_field(
743    repo_key: &RepoKey,
744    field_name: &str,
745    acl: &AclState,
746    update_seq: u64,
747) -> Vec<u8> {
748    build_payload(field_name, |b| {
749        b.field_bytes(repo_key);
750        b.field_u64(acl.epoch);
751        b.field_u32(u32::try_from(acl.grants.len()).unwrap_or(u32::MAX));
752        for (writer, grant) in &acl.grants {
753            b.field_bytes(writer);
754            b.field_u64(grant.granted_at_epoch);
755            b.field_option_bytes(
756                grant
757                    .revoked_at_epoch
758                    .map(|e| e.to_le_bytes())
759                    .as_ref()
760                    .map(|x| x.as_slice()),
761            );
762        }
763        b.field_u64(update_seq);
764    })
765}
766
767/// Signed-payload bytes for `upgrade`.
768pub fn signed_payload_optional_repo_key_field(
769    repo_key: &RepoKey,
770    field_name: &str,
771    successor: Option<&RepoKey>,
772    update_seq: u64,
773) -> Vec<u8> {
774    build_payload(field_name, |b| {
775        b.field_bytes(repo_key);
776        b.field_option_bytes(successor.map(|k| k.as_slice()));
777        b.field_u64(update_seq);
778    })
779}
780
781/// Signed-payload bytes for a [`RefEntry`].
782pub fn signed_payload_ref_entry(
783    repo_key: &RepoKey,
784    ref_name: &str,
785    target: &CommitHash,
786    update_seq: u64,
787    auth_epoch: u64,
788) -> Vec<u8> {
789    build_payload("ref-update", |b| {
790        b.field_bytes(repo_key);
791        b.field_str(ref_name);
792        b.field_bytes(target);
793        b.field_u64(update_seq);
794        b.field_u64(auth_epoch);
795    })
796}
797
798/// Signed-payload bytes for an [`ObjectBundleRecord`].
799pub fn signed_payload_bundle_record(
800    repo_key: &RepoKey,
801    bundle: &ObjectBundle,
802    auth_epoch: u64,
803) -> Vec<u8> {
804    let bundle_id = bundle.id();
805    build_payload("object-bundle", |b| {
806        b.field_bytes(repo_key);
807        b.field_bytes(&bundle_id);
808        b.field_u64(auth_epoch);
809    })
810}
811
812/// Signed-payload bytes for an [`ExtensionEntry`].
813pub fn signed_payload_extension(
814    repo_key: &RepoKey,
815    ext_key: &str,
816    value: &[u8],
817    update_seq: u64,
818) -> Vec<u8> {
819    build_payload("extension", |b| {
820        b.field_bytes(repo_key);
821        b.field_str(ext_key);
822        b.field_bytes(value);
823        b.field_u64(update_seq);
824    })
825}
826
827// ---------------------------------------------------------------------------
828// Internal helpers
829// ---------------------------------------------------------------------------
830
831/// Compute the abstract repo key used in every signed payload as a
832/// domain-separating prefix: `BLAKE3-32(WIRE_VERSION || prefix || owner)`.
833///
834/// This is only used as the *signature domain* repo key — Freenet computes
835/// the actual contract key separately as `BLAKE3(BLAKE3(WASM) ||
836/// Parameters)`. Keeping the signature domain stable across WASM rebuilds
837/// means a contract WASM bump does not invalidate every signature in the
838/// repo's history; only the contract key changes (as it must — that is
839/// what migration via the upgrade pointer is for).
840fn params_repo_key(params: &RepoParams, owner: &PublicKey) -> RepoKey {
841    let mut h = blake3::Hasher::new();
842    h.update(WIRE_VERSION.as_bytes());
843    h.update(params.prefix.as_bytes());
844    h.update(owner);
845    *h.finalize().as_bytes()
846}
847
848/// Public version of [`params_repo_key`] for callers that need the same
849/// derivation when constructing signed payloads.
850pub fn signature_domain_key(params: &RepoParams, owner: &PublicKey) -> RepoKey {
851    params_repo_key(params, owner)
852}
853
854fn check_size(field: &'static str, len: usize, max: usize) -> Result<(), ValidateError> {
855    if len > max {
856        Err(ValidateError::FieldTooLong { field, len, max })
857    } else {
858        Ok(())
859    }
860}
861
862fn verify_signature(
863    payload: &[u8],
864    signer: &PublicKey,
865    signature: &Signature,
866    label: &'static str,
867) -> Result<(), ValidateError> {
868    let pk = ed25519_compact::PublicKey::from_slice(signer)
869        .map_err(|_| ValidateError::InvalidSignature(label))?;
870    let sig = ed25519_compact::Signature::from_slice(signature)
871        .map_err(|_| ValidateError::InvalidSignature(label))?;
872    pk.verify(payload, &sig)
873        .map_err(|_| ValidateError::InvalidSignature(label))
874}
875
876fn verify_signed_field_string(
877    repo_key: &RepoKey,
878    field_name: &str,
879    owner: &PublicKey,
880    field: &SignedField<String>,
881    label: &'static str,
882) -> Result<(), ValidateError> {
883    let payload = signed_payload_string_field(repo_key, field_name, &field.value, field.update_seq);
884    verify_signature(&payload, owner, &field.signature, label)
885}
886
887fn verify_signed_field_ref_list(
888    repo_key: &RepoKey,
889    field_name: &str,
890    owner: &PublicKey,
891    field: &SignedField<Vec<RefName>>,
892    label: &'static str,
893) -> Result<(), ValidateError> {
894    let payload =
895        signed_payload_ref_list_field(repo_key, field_name, &field.value, field.update_seq);
896    verify_signature(&payload, owner, &field.signature, label)
897}
898
899fn verify_signed_field_acl(
900    repo_key: &RepoKey,
901    field_name: &str,
902    owner: &PublicKey,
903    field: &SignedField<AclState>,
904    label: &'static str,
905) -> Result<(), ValidateError> {
906    let payload = signed_payload_acl_field(repo_key, field_name, &field.value, field.update_seq);
907    verify_signature(&payload, owner, &field.signature, label)
908}
909
910fn verify_signed_field_optional_repo_key(
911    repo_key: &RepoKey,
912    field_name: &str,
913    owner: &PublicKey,
914    field: &SignedField<Option<RepoKey>>,
915    label: &'static str,
916) -> Result<(), ValidateError> {
917    let payload = signed_payload_optional_repo_key_field(
918        repo_key,
919        field_name,
920        field.value.as_ref(),
921        field.update_seq,
922    );
923    verify_signature(&payload, owner, &field.signature, label)
924}
925
926fn verify_ref_entry(
927    repo_key: &RepoKey,
928    ref_name: &str,
929    entry: &RefEntry,
930) -> Result<(), ValidateError> {
931    let payload = signed_payload_ref_entry(
932        repo_key,
933        ref_name,
934        &entry.target,
935        entry.update_seq,
936        entry.auth_epoch,
937    );
938    verify_signature(&payload, &entry.updater, &entry.signature, "ref entry")
939}
940
941fn verify_bundle_record(
942    repo_key: &RepoKey,
943    record: &ObjectBundleRecord,
944) -> Result<(), ValidateError> {
945    let payload = signed_payload_bundle_record(repo_key, &record.bundle, record.auth_epoch);
946    verify_signature(
947        &payload,
948        &record.added_by,
949        &record.signature,
950        "bundle record",
951    )
952}
953
954fn verify_extension_entry(
955    repo_key: &RepoKey,
956    ext_key: &str,
957    owner: &PublicKey,
958    entry: &ExtensionEntry,
959) -> Result<(), ValidateError> {
960    let payload = signed_payload_extension(repo_key, ext_key, &entry.value, entry.update_seq);
961    verify_signature(&payload, owner, &entry.signature, "extension entry")
962}
963
964fn pick_signed_field<T: Clone>(
965    a: Option<SignedField<T>>,
966    b: Option<SignedField<T>>,
967) -> Option<SignedField<T>> {
968    match (a, b) {
969        (None, x) | (x, None) => x,
970        (Some(x), Some(y)) => Some(
971            if pick_higher_seq(x.update_seq, &x.signature, y.update_seq, &y.signature) {
972                x
973            } else {
974                y
975            },
976        ),
977    }
978}
979
980fn pick_ref_entry(a: RefEntry, b: RefEntry) -> RefEntry {
981    if pick_higher_seq(a.update_seq, &a.signature, b.update_seq, &b.signature) {
982        a
983    } else {
984        b
985    }
986}
987
988fn pick_extension_entry(a: ExtensionEntry, b: ExtensionEntry) -> ExtensionEntry {
989    if pick_higher_seq(a.update_seq, &a.signature, b.update_seq, &b.signature) {
990        a
991    } else {
992        b
993    }
994}
995
996/// Returns true if `a` is the "winner" under (update_seq desc, signature
997/// asc) ordering. The signature tiebreak makes the merge deterministic
998/// across implementations even when two writers race to the same seq.
999fn pick_higher_seq(a_seq: u64, a_sig: &Signature, b_seq: u64, b_sig: &Signature) -> bool {
1000    match a_seq.cmp(&b_seq) {
1001        std::cmp::Ordering::Greater => true,
1002        std::cmp::Ordering::Less => false,
1003        std::cmp::Ordering::Equal => a_sig <= b_sig,
1004    }
1005}
1006
1007// `serde` ergonomics for `[u8; 64]`. `serde_bytes` only handles `Vec<u8>`
1008// and `&[u8]`; we want the array shape preserved so a corrupt signature is
1009// caught at decode time, not at verify time.
1010mod serde_bytes_array_64 {
1011    use serde::de::Error as _;
1012    use serde::{Deserialize, Deserializer, Serialize, Serializer};
1013
1014    pub fn serialize<S: Serializer>(value: &[u8; 64], ser: S) -> Result<S::Ok, S::Error> {
1015        serde_bytes::Bytes::new(value).serialize(ser)
1016    }
1017
1018    pub fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result<[u8; 64], D::Error> {
1019        let bytes: serde_bytes::ByteBuf = serde_bytes::ByteBuf::deserialize(de)?;
1020        bytes
1021            .as_ref()
1022            .try_into()
1023            .map_err(|_| D::Error::custom("expected 64-byte signature"))
1024    }
1025}
1026
1027#[cfg(test)]
1028#[allow(clippy::field_reassign_with_default)]
1029mod tests {
1030    use super::*;
1031
1032    /// `params_repo_key` does not depend on the contract WASM hash, so two
1033    /// different WASMs with the same parameters and owner produce the
1034    /// same signature domain key. Without this property, every contract
1035    /// WASM upgrade would invalidate every historical signature.
1036    #[test]
1037    fn signature_domain_key_is_wasm_independent() {
1038        let owner = [1u8; 32];
1039        let params = RepoParams::from_owner(&owner, limits::DEFAULT_PREFIX_LEN);
1040        let k = signature_domain_key(&params, &owner);
1041        assert_eq!(k.len(), 32);
1042        // Stability: same inputs => same output across runs.
1043        assert_eq!(signature_domain_key(&params, &owner), k);
1044    }
1045
1046    #[test]
1047    fn default_state_with_matching_prefix_validates() {
1048        // RepoState::default() has owner = [0; 32]. Build matching params.
1049        let owner = [0u8; 32];
1050        let params = RepoParams::from_owner(&owner, limits::DEFAULT_PREFIX_LEN);
1051        assert!(validate_state(&params, &RepoState::default()).is_ok());
1052    }
1053
1054    #[test]
1055    fn prefix_mismatch_rejected() {
1056        let owner = [3u8; 32];
1057        let other = [4u8; 32];
1058        let params = RepoParams::from_owner(&owner, limits::DEFAULT_PREFIX_LEN);
1059        let mut state = RepoState::default();
1060        state.owner = other;
1061        match validate_state(&params, &state) {
1062            Err(ValidateError::PrefixMismatch { .. }) => {}
1063            other => panic!("expected PrefixMismatch, got {:?}", other),
1064        }
1065    }
1066
1067    #[test]
1068    fn invalid_prefix_length_rejected() {
1069        let too_short = RepoParams {
1070            prefix: "abc".into(),
1071        };
1072        match validate_state(&too_short, &RepoState::default()) {
1073            Err(ValidateError::InvalidPrefixLength { len: 3, .. }) => {}
1074            other => panic!("expected InvalidPrefixLength, got {:?}", other),
1075        }
1076        let too_long = RepoParams {
1077            prefix: "a".repeat(33),
1078        };
1079        match validate_state(&too_long, &RepoState::default()) {
1080            Err(ValidateError::InvalidPrefixLength { len: 33, .. }) => {}
1081            other => panic!("expected InvalidPrefixLength, got {:?}", other),
1082        }
1083    }
1084
1085    #[test]
1086    fn bundle_id_matches_canonical_hash() {
1087        let bundle = ObjectBundle::SinglePack {
1088            pack_hash: [0xAA; 32],
1089            size_bytes: 4096,
1090        };
1091        let id = bundle.id();
1092        // Same logical bundle always produces same id.
1093        assert_eq!(id, bundle.id());
1094        // Different sizes produce different ids.
1095        let bundle2 = ObjectBundle::SinglePack {
1096            pack_hash: [0xAA; 32],
1097            size_bytes: 4097,
1098        };
1099        assert_ne!(id, bundle2.id());
1100    }
1101
1102    #[test]
1103    fn merge_picks_higher_seq() {
1104        let mut a: SignedField<String> = SignedField {
1105            value: "a".into(),
1106            update_seq: 1,
1107            signature: [0u8; 64],
1108        };
1109        let b: SignedField<String> = SignedField {
1110            value: "b".into(),
1111            update_seq: 2,
1112            signature: [0u8; 64],
1113        };
1114        let pick = pick_signed_field(Some(a.clone()), Some(b.clone())).unwrap();
1115        assert_eq!(pick.update_seq, 2);
1116        // Tie: lower signature wins. Force a tie by raising a's seq.
1117        a.update_seq = 2;
1118        a.signature[0] = 0; // < b's signature[0] = 0 -- equal here, so a wins (≤)
1119        let pick = pick_signed_field(Some(a.clone()), Some(b.clone())).unwrap();
1120        assert_eq!(pick.value, "a");
1121        // Make a strictly bigger.
1122        a.signature[0] = 1;
1123        let pick = pick_signed_field(Some(a.clone()), Some(b.clone())).unwrap();
1124        assert_eq!(pick.value, "b");
1125    }
1126
1127    #[test]
1128    fn name_size_limit_is_enforced() {
1129        let owner = [5u8; 32];
1130        let params = RepoParams::from_owner(&owner, limits::DEFAULT_PREFIX_LEN);
1131        let mut state = RepoState::default();
1132        state.owner = owner;
1133        state.name = Some(SignedField {
1134            value: "x".repeat(limits::MAX_NAME_BYTES + 1),
1135            update_seq: 1,
1136            // Signature can be junk; we expect failure on the size check
1137            // BEFORE the signature is verified.
1138            signature: [0u8; 64],
1139        });
1140        match validate_state(&params, &state) {
1141            Err(ValidateError::FieldTooLong { field, .. }) => assert_eq!(field, "name"),
1142            other => panic!("expected FieldTooLong, got {:?}", other),
1143        }
1144    }
1145}