use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Visibility {
Public,
Private,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AccessAction {
Read,
Write,
Manage,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RepoRecord {
pub repo_id: String,
pub visibility: Visibility,
pub grants: BTreeMap<String, Vec<AccessAction>>,
}
pub const REPO_ID_MAX_BYTES: usize = 100;
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
pub enum RepoIdError {
#[error("repo id is empty")]
Empty,
#[error("repo id is {len} bytes; the limit is {max}")]
TooLong {
len: usize,
max: usize,
},
#[error("repo id has {count} path segments; at most two (owner/name)")]
TooManySegments {
count: usize,
},
#[error("repo id segment {index} is empty")]
EmptySegment {
index: usize,
},
#[error("repo id segment {segment} holds disallowed character {character:?} at byte {offset}")]
InvalidCharacter {
segment: usize,
offset: usize,
character: char,
},
}
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;
#[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(()));
}
}