shardline-server-core 1.0.0

Shared core types for the Shardline server ecosystem.
Documentation
use sha2::{Digest, Sha256};
use shardline_protocol::RepositoryScope;
use shardline_storage::ObjectKey;

use crate::validate_content_hash;

const MAX_UPLOAD_SESSION_ID_BYTES: usize = 64;

pub trait ProtocolValidation {
    fn invalid_digest() -> Self;
    fn invalid_content_hash() -> Self;
    fn invalid_repository_name() -> Self;
    fn not_found() -> Self;
    fn invalid_manifest_reference() -> Self;
    fn invalid_upload_session() -> Self;
}

pub fn parse_sha256_digest<E: ProtocolValidation>(value: &str) -> Result<String, E> {
    let Some(hash_hex) = value.strip_prefix("sha256:") else {
        return Err(E::invalid_digest());
    };
    validate_content_hash(hash_hex).map_err(|_error| E::invalid_digest())?;
    Ok(hash_hex.to_owned())
}

pub fn scope_namespace(repository_scope: Option<&RepositoryScope>) -> String {
    repository_scope.map_or_else(
        || "global".to_owned(),
        |scope| {
            let mut hasher = Sha256::new();
            hasher.update(scope.provider().as_str().as_bytes());
            hasher.update([0]);
            hasher.update(scope.owner().as_bytes());
            hasher.update([0]);
            hasher.update(scope.name().as_bytes());
            hasher.update([0]);
            if let Some(revision) = scope.revision() {
                hasher.update(revision.as_bytes());
            }
            hex::encode(hasher.finalize())
        },
    )
}

pub fn object_key<E: ProtocolValidation>(value: &str) -> Result<ObjectKey, E> {
    ObjectKey::parse(value).map_err(|_error| E::invalid_content_hash())
}

pub fn shared_sha256_object_key<E: ProtocolValidation>(digest_hex: &str) -> Result<ObjectKey, E> {
    validate_content_hash(digest_hex).map_err(|_error| E::invalid_content_hash())?;
    object_key(&format!("protocols/shared/sha256/{digest_hex}"))
}

pub fn validate_oci_repository_name<E: ProtocolValidation>(value: &str) -> Result<(), E> {
    if value.is_empty() || value.starts_with('/') || value.ends_with('/') || value.contains('\\') {
        return Err(E::invalid_repository_name());
    }

    for segment in value.split('/') {
        if segment.is_empty()
            || segment == "."
            || segment == ".."
            || !segment.bytes().all(|byte| {
                byte.is_ascii_lowercase()
                    || byte.is_ascii_digit()
                    || matches!(byte, b'.' | b'_' | b'-')
            })
        {
            return Err(E::invalid_repository_name());
        }
    }

    Ok(())
}

pub fn validate_oci_repository_scope<E: ProtocolValidation>(
    value: &str,
    repository_scope: Option<&RepositoryScope>,
) -> Result<(), E> {
    let Some(repository_scope) = repository_scope else {
        return Ok(());
    };

    let expected_root = format!(
        "{}/{}",
        repository_scope.owner().to_ascii_lowercase(),
        repository_scope.name().to_ascii_lowercase()
    );
    if value == expected_root
        || value
            .strip_prefix(&expected_root)
            .is_some_and(|suffix| suffix.starts_with('/'))
    {
        return Ok(());
    }

    Err(E::not_found())
}

pub fn validate_oci_tag<E: ProtocolValidation>(value: &str) -> Result<(), E> {
    let mut bytes = value.bytes();
    let Some(first) = bytes.next() else {
        return Err(E::invalid_manifest_reference());
    };
    if !(first.is_ascii_alphanumeric() || first == b'_') {
        return Err(E::invalid_manifest_reference());
    }
    if value.len() > 128
        || !bytes.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-'))
    {
        return Err(E::invalid_manifest_reference());
    }

    Ok(())
}

pub fn validate_upload_session_id<E: ProtocolValidation>(value: &str) -> Result<(), E> {
    if value.is_empty()
        || value.len() > MAX_UPLOAD_SESSION_ID_BYTES
        || !value
            .bytes()
            .all(|byte| byte.is_ascii_hexdigit() || byte == b'-')
    {
        return Err(E::invalid_upload_session());
    }

    Ok(())
}

impl ProtocolValidation for crate::ValidateContentHashError {
    fn invalid_digest() -> Self {
        Self
    }
    fn invalid_content_hash() -> Self {
        Self
    }
    fn invalid_repository_name() -> Self {
        Self
    }
    fn not_found() -> Self {
        Self
    }
    fn invalid_manifest_reference() -> Self {
        Self
    }
    fn invalid_upload_session() -> Self {
        Self
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::ValidateContentHashError;

    #[test]
    fn sha256_digest_parser_requires_prefixed_lowercase_hex() {
        let digest = parse_sha256_digest::<ValidateContentHashError>(
            "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
        );
        assert!(digest.is_ok());
        assert_eq!(
            digest.unwrap_or_default(),
            "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
        );
        assert!(
            parse_sha256_digest::<ValidateContentHashError>(
                "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
            )
            .is_err()
        );
        assert!(
            parse_sha256_digest::<ValidateContentHashError>(
                "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdeg"
            )
            .is_err()
        );
    }

    #[test]
    fn oci_repository_validator_rejects_traversal_and_uppercase() {
        assert!(validate_oci_repository_name::<ValidateContentHashError>("team/assets").is_ok());
        assert!(validate_oci_repository_name::<ValidateContentHashError>("../assets").is_err());
        assert!(validate_oci_repository_name::<ValidateContentHashError>("Team/assets").is_err());
        assert!(validate_oci_repository_name::<ValidateContentHashError>("team//assets").is_err());
    }

    #[test]
    fn oci_tag_validator_enforces_allowed_characters() {
        assert!(validate_oci_tag::<ValidateContentHashError>("v1").is_ok());
        assert!(validate_oci_tag::<ValidateContentHashError>("_debug.2026-04-23").is_ok());
        assert!(validate_oci_tag::<ValidateContentHashError>("bad/tag").is_err());
        assert!(validate_oci_tag::<ValidateContentHashError>("-bad").is_err());
    }

    #[test]
    fn upload_session_validator_accepts_hex_and_hyphen_only() {
        assert!(validate_upload_session_id::<ValidateContentHashError>("0000000000000001").is_ok());
        assert!(validate_upload_session_id::<ValidateContentHashError>("dead-beef").is_ok());
        assert!(validate_upload_session_id::<ValidateContentHashError>("session_1").is_err());
        assert!(
            validate_upload_session_id::<ValidateContentHashError>(
                &"a".repeat(MAX_UPLOAD_SESSION_ID_BYTES + 1)
            )
            .is_err()
        );
    }

    #[test]
    fn oci_repository_scope_validator_accepts_bound_roots_and_nested_namespaces() {
        use shardline_protocol::{RepositoryProvider, RepositoryScope};
        let scope = RepositoryScope::new(RepositoryProvider::GitHub, "team", "assets", None);
        assert!(scope.is_ok());
        let Ok(scope) = scope else {
            return;
        };

        assert!(
            validate_oci_repository_scope::<ValidateContentHashError>("team/assets", Some(&scope))
                .is_ok()
        );
        assert!(
            validate_oci_repository_scope::<ValidateContentHashError>(
                "team/assets/cache",
                Some(&scope)
            )
            .is_ok()
        );
        assert!(
            validate_oci_repository_scope::<ValidateContentHashError>("team/other", Some(&scope))
                .is_err()
        );
        assert!(
            validate_oci_repository_scope::<ValidateContentHashError>("other/assets", Some(&scope))
                .is_err()
        );
    }

    #[test]
    fn shared_sha256_key_uses_stable_shared_namespace() {
        let key = shared_sha256_object_key::<ValidateContentHashError>(
            "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
        );
        assert!(key.is_ok());
        let Ok(key) = key else {
            return;
        };
        assert_eq!(
            key.as_str(),
            "protocols/shared/sha256/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
        );
    }
}