Skip to main content

shardline_server/
protocol_support.rs

1use shardline_server_core::protocol_support as core_ps;
2
3use crate::ServerError;
4
5impl core_ps::ProtocolValidation for ServerError {
6    fn invalid_digest() -> Self {
7        Self::InvalidDigest
8    }
9    fn invalid_content_hash() -> Self {
10        Self::InvalidContentHash
11    }
12    fn invalid_repository_name() -> Self {
13        Self::InvalidRepositoryName
14    }
15    fn not_found() -> Self {
16        Self::NotFound
17    }
18    fn invalid_manifest_reference() -> Self {
19        Self::InvalidManifestReference
20    }
21    fn invalid_upload_session() -> Self {
22        Self::InvalidUploadSession
23    }
24}
25
26pub(crate) fn parse_sha256_digest(value: &str) -> Result<String, ServerError> {
27    core_ps::parse_sha256_digest(value)
28}
29
30pub(crate) fn scope_namespace(
31    repository_scope: Option<&shardline_protocol::RepositoryScope>,
32) -> String {
33    core_ps::scope_namespace(repository_scope)
34}
35
36/// Builds a shared-namespace object key for a SHA-256 digest.
37///
38/// # Errors
39///
40/// Returns [`ServerError::InvalidContentHash`] when the digest hex is malformed.
41pub fn shared_sha256_object_key(
42    digest_hex: &str,
43) -> Result<shardline_storage::ObjectKey, ServerError> {
44    core_ps::shared_sha256_object_key(digest_hex)
45}
46
47#[cfg(feature = "fuzzing")]
48pub(crate) fn validate_oci_repository_name(value: &str) -> Result<(), ServerError> {
49    core_ps::validate_oci_repository_name(value)
50}
51
52pub(crate) fn validate_oci_repository_scope(
53    value: &str,
54    repository_scope: Option<&shardline_protocol::RepositoryScope>,
55) -> Result<(), ServerError> {
56    core_ps::validate_oci_repository_scope(value, repository_scope)
57}
58
59pub(crate) fn validate_oci_tag(value: &str) -> Result<(), ServerError> {
60    core_ps::validate_oci_tag(value)
61}
62
63#[cfg(feature = "fuzzing")]
64pub(crate) fn validate_upload_session_id(value: &str) -> Result<(), ServerError> {
65    core_ps::validate_upload_session_id(value)
66}
67
68#[cfg(test)]
69mod tests {
70    use super::{
71        parse_sha256_digest, shared_sha256_object_key, validate_oci_repository_scope,
72        validate_oci_tag,
73    };
74    use crate::ServerError;
75    use shardline_protocol::{RepositoryProvider, RepositoryScope};
76
77    #[cfg(feature = "fuzzing")]
78    use super::{validate_oci_repository_name, validate_upload_session_id};
79
80    #[test]
81    fn sha256_digest_parser_requires_prefixed_lowercase_hex() {
82        let digest = parse_sha256_digest(
83            "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
84        );
85        assert!(digest.is_ok());
86        assert_eq!(
87            digest.unwrap_or_default(),
88            "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
89        );
90        assert!(matches!(
91            parse_sha256_digest("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"),
92            Err(ServerError::InvalidDigest)
93        ));
94        assert!(matches!(
95            parse_sha256_digest(
96                "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdeg"
97            ),
98            Err(ServerError::InvalidDigest)
99        ));
100    }
101
102    #[cfg(feature = "fuzzing")]
103    #[test]
104    fn oci_repository_validator_rejects_traversal_and_uppercase() {
105        assert!(validate_oci_repository_name("team/assets").is_ok());
106        assert!(matches!(
107            validate_oci_repository_name("../assets"),
108            Err(ServerError::InvalidRepositoryName)
109        ));
110        assert!(matches!(
111            validate_oci_repository_name("Team/assets"),
112            Err(ServerError::InvalidRepositoryName)
113        ));
114        assert!(matches!(
115            validate_oci_repository_name("team//assets"),
116            Err(ServerError::InvalidRepositoryName)
117        ));
118    }
119
120    #[test]
121    fn oci_tag_validator_enforces_allowed_characters() {
122        assert!(validate_oci_tag("v1").is_ok());
123        assert!(validate_oci_tag("_debug.2026-04-23").is_ok());
124        assert!(matches!(
125            validate_oci_tag("bad/tag"),
126            Err(ServerError::InvalidManifestReference)
127        ));
128        assert!(matches!(
129            validate_oci_tag("-bad"),
130            Err(ServerError::InvalidManifestReference)
131        ));
132    }
133
134    #[cfg(feature = "fuzzing")]
135    #[test]
136    fn upload_session_validator_accepts_hex_and_hyphen_only() {
137        assert!(validate_upload_session_id("0000000000000001").is_ok());
138        assert!(validate_upload_session_id("dead-beef").is_ok());
139        assert!(matches!(
140            validate_upload_session_id("session_1"),
141            Err(ServerError::InvalidUploadSession)
142        ));
143        assert!(matches!(
144            validate_upload_session_id(&"a".repeat(65)),
145            Err(ServerError::InvalidUploadSession)
146        ));
147    }
148
149    #[test]
150    fn oci_repository_scope_validator_accepts_bound_roots_and_nested_namespaces() {
151        let scope = RepositoryScope::new(RepositoryProvider::GitHub, "team", "assets", None);
152        assert!(scope.is_ok());
153        let Ok(scope) = scope else {
154            return;
155        };
156
157        assert!(validate_oci_repository_scope("team/assets", Some(&scope)).is_ok());
158        assert!(validate_oci_repository_scope("team/assets/cache", Some(&scope)).is_ok());
159        assert!(matches!(
160            validate_oci_repository_scope("team/other", Some(&scope)),
161            Err(ServerError::NotFound)
162        ));
163        assert!(matches!(
164            validate_oci_repository_scope("other/assets", Some(&scope)),
165            Err(ServerError::NotFound)
166        ));
167    }
168
169    #[test]
170    fn shared_sha256_key_uses_stable_shared_namespace() {
171        let key = shared_sha256_object_key(
172            "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
173        );
174        assert!(key.is_ok());
175        let Ok(key) = key else {
176            return;
177        };
178        assert_eq!(
179            key.as_str(),
180            "protocols/shared/sha256/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
181        );
182    }
183
184    #[test]
185    fn shared_sha256_key_rejects_invalid_digest() {
186        assert!(matches!(
187            shared_sha256_object_key("not-a-valid-hex-digest"),
188            Err(ServerError::InvalidContentHash)
189        ));
190    }
191
192    #[test]
193    fn parse_sha256_digest_rejects_empty() {
194        assert!(matches!(
195            parse_sha256_digest(""),
196            Err(ServerError::InvalidDigest)
197        ));
198    }
199
200    #[test]
201    fn parse_sha256_digest_rejects_non_sha256_prefix() {
202        assert!(matches!(
203            parse_sha256_digest("md5:abc123"),
204            Err(ServerError::InvalidDigest)
205        ));
206    }
207
208    #[test]
209    fn validate_oci_repository_scope_without_scope_accepts_any() {
210        assert!(validate_oci_repository_scope("team/assets", None).is_ok());
211    }
212
213    #[test]
214    fn validate_oci_repository_scope_rejects_out_of_bounds() {
215        use shardline_protocol::{RepositoryProvider, RepositoryScope};
216        // Create a scope with no revision
217        let Ok(scope) = RepositoryScope::new(RepositoryProvider::GitHub, "team", "assets", None)
218        else {
219            return;
220        };
221        // "other/assets" does not start with "team/assets" → NotFound
222        assert!(matches!(
223            validate_oci_repository_scope("other/assets", Some(&scope)),
224            Err(ServerError::NotFound)
225        ));
226    }
227
228    #[test]
229    fn scope_namespace_returns_global_for_none() {
230        let result = super::scope_namespace(None);
231        assert_eq!(result, "global");
232    }
233
234    #[test]
235    fn scope_namespace_returns_hex_hash_for_some() {
236        use shardline_protocol::{RepositoryProvider, RepositoryScope};
237        let Ok(scope) = RepositoryScope::new(RepositoryProvider::GitHub, "owner", "repo", None)
238        else {
239            return;
240        };
241        let result = super::scope_namespace(Some(&scope));
242        // Returns a SHA-256 hex string of the scoped provider+owner+name
243        assert_eq!(
244            result.len(),
245            64,
246            "scope namespace should be a 64-char hex hash"
247        );
248        assert!(
249            result.chars().all(|c| c.is_ascii_hexdigit()),
250            "scope namespace should be hex: {result}"
251        );
252    }
253}