alkgit 0.1.0

Git smart protocol for the alk family: pkt-line substrates, protocol V2 serving (upload-pack/receive-pack), producer/consumer halves on alkcall channels, and the backend seam traits — the shipped alkstore+alkblobs composition is the alkgit-substrate crate (ADR-010/025)
Documentation
//! The registry record family (backend.md §Registry types and schemas,
//! review 001 N-3).
//!
//! The serde shapes the op handlers and the registry adapters both
//! serialize — pinned once so the two workstreams cannot invent them
//! independently. These shapes enter OQ-03's freeze inventory at the
//! core's first publish; unknown fields are rejected, fail-closed (the
//! same extension posture as the op inputs' `additionalProperties:
//! false`; extensions replace, not accumulate, before publish). The
//! record is identity/visibility/grants only — no path fields of any
//! kind (ADR-019 §8): storage placement is the composition's
//! constructor concern, keyed on `repo_id`.

use std::collections::BTreeMap;

use serde::{Deserialize, Serialize};

/// Who can see a repo (backend.md §RepoRecord — `public` | `private`,
/// lowercase serde).
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Visibility {
    /// Visible to anonymous callers (ADR-011 §3 — anonymous is
    /// first-class on `Public` repos).
    Public,
    /// Resolvable only for grant-holders.
    Private,
}

/// One grant action (ADR-015 §1 — the three-action set, lowercase
/// serde). `manage` implies the lower actions per ADR-015 §2's
/// monotonicity; stored grant sets need not be closed under that
/// implication — the `authorize` policy function (wave 2) evaluates
/// it.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AccessAction {
    /// Fetch/clone access.
    Read,
    /// Push access (implies read).
    Write,
    /// Repo administration (implies write and read) — the op-gate
    /// action (ADR-015 §2–3).
    Manage,
}

/// The per-repo registry record (backend.md §RepoRecord; ADR-011 §2 as
/// amended by ADR-015).
///
/// `repo_id` is a non-empty string — the registry id, never a
/// filesystem path (ADR-008/ADR-019 §8). Grant keys are opaque
/// identity-id strings (ADR-015 §7 / OQ-16 — no structure, no
/// validation beyond non-empty, made by the identity provider's
/// guarantee, not this type). Grant action arrays are the lowercase
/// [`AccessAction`] strings.
///
/// `deny_unknown_fields` is load-bearing: the schema is draft-until-
/// publish, and extensions replace, not accumulate (backend.md's
/// additive-field rule).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RepoRecord {
    /// The registry id — a non-empty string, the schema's only key
    /// into storage (ADR-019 §8).
    pub repo_id: String,
    /// Who can see the repo.
    pub visibility: Visibility,
    /// Grant sets keyed on opaque identity-id strings. Serialized as
    /// a JSON object; `BTreeMap` for deterministic byte output across
    /// instances (the record replicates as flat state, ADR-015 §8).
    pub grants: BTreeMap<String, Vec<AccessAction>>,
}

/// The maximum serialized length of a repo id, in bytes (backend.md
/// §Repo-id grammar).
pub const REPO_ID_MAX_BYTES: usize = 100;

/// A precise repo-id grammar rejection (backend.md §Repo-id grammar,
/// review 002 R-5): segment-level detail for the caller that needs to
/// correct its id.
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
pub enum RepoIdError {
    /// Zero-length id.
    #[error("repo id is empty")]
    Empty,
    /// The id exceeds [`REPO_ID_MAX_BYTES`] bytes.
    #[error("repo id is {len} bytes; the limit is {max}")]
    TooLong {
        /// The offending id's byte length.
        len: usize,
        /// [`REPO_ID_MAX_BYTES`], restated for the display.
        max: usize,
    },
    /// More than two `/`-separated segments.
    #[error("repo id has {count} path segments; at most two (owner/name)")]
    TooManySegments {
        /// The offending id's segment count.
        count: usize,
    },
    /// A segment carries no characters.
    #[error("repo id segment {index} is empty")]
    EmptySegment {
        /// Zero-based index of the empty segment.
        index: usize,
    },
    /// A character outside `a-z`, `0-9`, `-`, `_`.
    #[error("repo id segment {segment} holds disallowed character {character:?} at byte {offset}")]
    InvalidCharacter {
        /// Zero-based index of the offending segment.
        segment: usize,
        /// Byte offset of the character within its segment.
        offset: usize,
        /// The disallowed character.
        character: char,
    },
}

/// Validate a repo id against the pinned grammar (backend.md §Repo-id
/// grammar, review 002 R-5): one or two path segments (`owner/name`; a
/// single segment is the valid legacy/local shape), lowercase
/// alphanumerics and `-`/`_` per segment, segments non-empty, at most
/// [`REPO_ID_MAX_BYTES`] bytes total.
///
/// This function is deliberately **mapping-free**: the *mapping* of a
/// rejection is call-site business — `unauthorized` on serving paths
/// (collapsed with unknown-repo, ADR-008 — the id shape leaks nothing)
/// and `invalid` on op paths (create/update — the caller needs the
/// correction). The id is an opaque registry key: parsed against this
/// grammar for *rejection* only, never decomposed (the `owner` segment
/// confers nothing; the registry index is flat); never joined or
/// normalized into filesystem paths.
pub fn validate_repo_id(repo_id: &str) -> Result<(), RepoIdError> {
    if repo_id.is_empty() {
        return Err(RepoIdError::Empty);
    }
    let len = repo_id.len();
    if len > REPO_ID_MAX_BYTES {
        return Err(RepoIdError::TooLong {
            len,
            max: REPO_ID_MAX_BYTES,
        });
    }
    let segments: Vec<&str> = repo_id.split('/').collect();
    if segments.len() > 2 {
        return Err(RepoIdError::TooManySegments {
            count: segments.len(),
        });
    }
    for (index, segment) in segments.iter().enumerate() {
        if segment.is_empty() {
            return Err(RepoIdError::EmptySegment { index });
        }
        for (offset, character) in segment.char_indices() {
            if !matches!(character, 'a'..='z' | '0'..='9' | '-' | '_') {
                return Err(RepoIdError::InvalidCharacter {
                    segment: index,
                    offset,
                    character,
                });
            }
        }
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    /// The backend.md §RepoRecord example, byte-for-byte: parse the
    /// doc's JSON shape, re-serialize, compare to the compact form.
    #[test]
    fn repo_record_round_trips_the_backend_example_byte_shape() {
        let doc = json!({
            "repo_id": "alkdev/alkgit",
            "visibility": "public",
            "grants": {
                "u-7731": ["read", "write", "manage"]
            }
        });
        let record: RepoRecord = serde_json::from_value(doc).unwrap();
        assert_eq!(record.repo_id, "alkdev/alkgit");
        assert_eq!(record.visibility, Visibility::Public);
        assert_eq!(
            record.grants.get("u-7731").map(Vec::as_slice),
            Some(
                [
                    AccessAction::Read,
                    AccessAction::Write,
                    AccessAction::Manage
                ]
                .as_ref()
            )
        );
        let serialized = serde_json::to_string(&record).unwrap();
        assert_eq!(
            serialized,
            "{\"repo_id\":\"alkdev/alkgit\",\"visibility\":\"public\",\
             \"grants\":{\"u-7731\":[\"read\",\"write\",\"manage\"]}}"
        );
        let reparsed: RepoRecord = serde_json::from_str(&serialized).unwrap();
        assert_eq!(reparsed, record);
    }

    #[test]
    fn unknown_fields_are_rejected() {
        let with_storage_root = serde_json::json!({
            "repo_id": "alkdev/alkgit",
            "visibility": "public",
            "storage_root": "/srv/git/alkdev/alkgit",
            "grants": {}
        });
        let err = serde_json::from_value::<RepoRecord>(with_storage_root).unwrap_err();
        assert!(
            err.to_string().contains("unknown field"),
            "deny_unknown_fields must reject the ADR-019 §8-removed path \
             field: {err}"
        );
    }

    #[test]
    fn missing_fields_are_rejected() {
        let no_grants = serde_json::json!({
            "repo_id": "alkdev/alkgit",
            "visibility": "public"
        });
        assert!(serde_json::from_value::<RepoRecord>(no_grants).is_err());
        let no_visibility = serde_json::json!({
            "repo_id": "alkdev/alkgit",
            "grants": {}
        });
        assert!(serde_json::from_value::<RepoRecord>(no_visibility).is_err());
    }

    #[test]
    fn private_visibility_round_trips_lowercase() {
        let record: RepoRecord = serde_json::from_value(json!({
            "repo_id": "solo/project_x-1",
            "visibility": "private",
            "grants": {}
        }))
        .unwrap();
        assert_eq!(record.visibility, Visibility::Private);
        assert_eq!(
            serde_json::to_value(record.visibility).unwrap(),
            json!("private")
        );
    }

    #[test]
    fn visibility_serde_is_lowercase() {
        assert_eq!(serde_json::to_value(Visibility::Public).unwrap(), "public");
        assert_eq!(
            serde_json::to_value(Visibility::Private).unwrap(),
            "private"
        );
        assert!(
            serde_json::from_value::<Visibility>(json!("Public")).is_err(),
            "serde is lowercase — 'Public' must not parse"
        );
    }

    #[test]
    fn access_action_is_the_three_action_lowercase_set() {
        for (action, wire) in [
            (AccessAction::Read, "read"),
            (AccessAction::Write, "write"),
            (AccessAction::Manage, "manage"),
        ] {
            assert_eq!(serde_json::to_value(action).unwrap(), json!(wire));
            assert_eq!(
                serde_json::from_value::<AccessAction>(json!(wire)).unwrap(),
                action
            );
        }
        assert!(
            serde_json::from_value::<AccessAction>(json!("admin")).is_err(),
            "the set is exactly read/write/manage — no admin variant"
        );
        assert!(
            serde_json::from_value::<AccessAction>(json!("Read")).is_err(),
            "serde is lowercase"
        );
    }

    #[test]
    fn grant_keys_are_opaque_passthrough_strings() {
        let record: RepoRecord = serde_json::from_value(json!({
            "repo_id": "alkdev/alkgit",
            "visibility": "private",
            "grants": {
                "u-7731": ["read"],
                "00010203-0405-0607-0809-0a0b0c0d0e0f": ["manage"],
                "user:zeta@example.test": ["write"]
            }
        }))
        .unwrap();
        assert_eq!(record.grants.len(), 3);
        assert!(record
            .grants
            .contains_key("00010203-0405-0607-0809-0a0b0c0d0e0f"));
    }

    #[test]
    fn wire_supplied_repo_ids_pass_grammar_rejection_only() {
        let record: RepoRecord = serde_json::from_value(json!({
            "repo_id": "alkdev/alkgit",
            "visibility": "public",
            "grants": {}
        }))
        .unwrap();
        validate_repo_id(&record.repo_id).unwrap();
        let padded = format!(" {}", record.repo_id);
        assert!(
            validate_repo_id(&padded).is_err(),
            "a whitespace-padded id is grammar-invalid"
        );
    }

    #[test]
    fn repo_id_valid_shapes_pass() {
        for valid in ["solo", "owner/name", "alkdev/alkgit", "a-b_c/def-456"] {
            validate_repo_id(valid).expect(valid);
        }
    }

    #[test]
    fn repo_id_rejects_empty_id_and_empty_segments() {
        assert_eq!(validate_repo_id(""), Err(RepoIdError::Empty));
        assert_eq!(
            validate_repo_id("/name"),
            Err(RepoIdError::EmptySegment { index: 0 })
        );
        assert_eq!(
            validate_repo_id("owner/"),
            Err(RepoIdError::EmptySegment { index: 1 })
        );
    }

    #[test]
    fn repo_id_rejects_uppercase() {
        assert_eq!(
            validate_repo_id("Owner/name"),
            Err(RepoIdError::InvalidCharacter {
                segment: 0,
                offset: 0,
                character: 'O',
            })
        );
    }

    #[test]
    fn repo_id_rejects_disallowed_characters() {
        assert_eq!(
            validate_repo_id("owner/na.me"),
            Err(RepoIdError::InvalidCharacter {
                segment: 1,
                offset: 2,
                character: '.',
            })
        );
        assert!(
            validate_repo_id("owner/naïve").is_err(),
            "non-ASCII is outside the grammar"
        );
        assert!(
            validate_repo_id("owner/na me").is_err(),
            "spaces are outside the grammar"
        );
    }

    #[test]
    fn repo_id_rejects_more_than_two_segments() {
        assert_eq!(
            validate_repo_id("a/b/c"),
            Err(RepoIdError::TooManySegments { count: 3 })
        );
    }

    #[test]
    fn repo_id_rejects_ids_over_100_bytes() {
        let long_single = "a".repeat(101);
        assert_eq!(
            validate_repo_id(&long_single),
            Err(RepoIdError::TooLong { len: 101, max: 100 })
        );
        let two_segment = format!("{}/{}", "a".repeat(50), "b".repeat(51));
        assert_eq!(
            validate_repo_id(&two_segment),
            Err(RepoIdError::TooLong { len: 102, max: 100 })
        );
        assert_eq!(validate_repo_id(&"a".repeat(100)), Ok(()));
    }
}