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