shardline_server/
protocol_support.rs1use 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
36pub 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 let Ok(scope) = RepositoryScope::new(RepositoryProvider::GitHub, "team", "assets", None)
218 else {
219 return;
220 };
221 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 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}