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