1use std::{fmt, num::NonZeroU64};
2
3use redis::AsyncCommands;
4use shardline_protocol::SecretBytes;
5
6use crate::{
7 AsyncReconstructionCache, ReconstructionCacheError, ReconstructionCacheFuture,
8 ReconstructionCacheKey,
9};
10
11const RECONSTRUCTION_CACHE_PREFIX: &str = "shardline:reconstruction:v1";
12
13#[derive(Clone, Default, PartialEq, Eq)]
18pub struct RedisTlsConfig {
19 root_cert: Option<SecretBytes>,
20 client_cert: Option<SecretBytes>,
21 client_key: Option<SecretBytes>,
22}
23
24impl RedisTlsConfig {
25 #[must_use]
27 pub const fn new(root_cert: Option<SecretBytes>) -> Self {
28 Self {
29 root_cert,
30 client_cert: None,
31 client_key: None,
32 }
33 }
34
35 #[must_use]
37 pub fn with_client_identity(
38 mut self,
39 client_cert: SecretBytes,
40 client_key: SecretBytes,
41 ) -> Self {
42 self.client_cert = Some(client_cert);
43 self.client_key = Some(client_key);
44 self
45 }
46
47 const fn is_empty(&self) -> bool {
48 self.root_cert.is_none() && self.client_cert.is_none() && self.client_key.is_none()
49 }
50
51 fn into_redis_tls_certificates(
52 self,
53 ) -> Result<redis::TlsCertificates, ReconstructionCacheError> {
54 let client_tls = match (self.client_cert, self.client_key) {
55 (None, None) => None,
56 (Some(client_cert), Some(client_key)) => Some(redis::ClientTlsConfig {
57 client_cert: client_cert.expose_secret().to_vec(),
58 client_key: client_key.expose_secret().to_vec(),
59 }),
60 (Some(_), None) | (None, Some(_)) => {
61 return Err(ReconstructionCacheError::IncompleteRedisTlsClientIdentity);
62 }
63 };
64
65 Ok(redis::TlsCertificates {
66 client_tls,
67 root_cert: self.root_cert.map(|c| c.expose_secret().to_vec()),
68 })
69 }
70}
71
72impl fmt::Debug for RedisTlsConfig {
73 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
74 formatter
75 .debug_struct("RedisTlsConfig")
76 .field("root_cert", &self.root_cert)
77 .field("client_cert", &self.client_cert)
78 .field("client_key", &self.client_key)
79 .finish()
80 }
81}
82
83pub struct RedisReconstructionCache {
85 client: redis::Client,
86 ttl_seconds: NonZeroU64,
87}
88
89impl Clone for RedisReconstructionCache {
90 fn clone(&self) -> Self {
91 Self {
92 client: self.client.clone(),
93 ttl_seconds: self.ttl_seconds,
94 }
95 }
96}
97
98impl fmt::Debug for RedisReconstructionCache {
99 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
100 formatter
101 .debug_struct("RedisReconstructionCache")
102 .field("client", &"***")
103 .field("ttl_seconds", &self.ttl_seconds)
104 .finish()
105 }
106}
107
108impl RedisReconstructionCache {
109 pub fn new(redis_url: &str, ttl_seconds: NonZeroU64) -> Result<Self, ReconstructionCacheError> {
115 Self::new_with_tls(redis_url, ttl_seconds, RedisTlsConfig::default())
116 }
117
118 pub fn new_with_tls(
128 redis_url: &str,
129 ttl_seconds: NonZeroU64,
130 tls_config: RedisTlsConfig,
131 ) -> Result<Self, ReconstructionCacheError> {
132 if redis_url.trim().is_empty() {
133 return Err(ReconstructionCacheError::EmptyRedisUrl);
134 }
135
136 if redis_url.trim_start().starts_with("rediss://") || !tls_config.is_empty() {
137 install_rustls_crypto_provider();
138 }
139
140 let client = if tls_config.is_empty() {
141 redis::Client::open(redis_url)?
142 } else {
143 redis::Client::build_with_tls(redis_url, tls_config.into_redis_tls_certificates()?)?
144 };
145
146 Ok(Self {
147 client,
148 ttl_seconds,
149 })
150 }
151
152 async fn get_connection(
157 &self,
158 ) -> Result<redis::aio::MultiplexedConnection, ReconstructionCacheError> {
159 Ok(self.client.get_multiplexed_async_connection().await?)
160 }
161
162 pub(crate) fn redis_key(key: &ReconstructionCacheKey) -> String {
163 let scope = key.repository_scope().map_or_else(
164 || "global".to_owned(),
165 |scope| {
166 let revision = scope
167 .revision()
168 .map_or_else(|| "head".to_owned(), encode_component);
169 format!(
170 "{}:{}:{}:{}",
171 scope.provider(),
172 encode_component(scope.owner()),
173 encode_component(scope.repo()),
174 revision
175 )
176 },
177 );
178 let content = key
179 .content_hash()
180 .map_or_else(|| "latest".to_owned(), encode_component);
181
182 format!(
183 "{RECONSTRUCTION_CACHE_PREFIX}:{scope}:{content}:{}",
184 encode_component(key.file_id())
185 )
186 }
187}
188
189fn install_rustls_crypto_provider() {
190 let _already_installed = rustls::crypto::ring::default_provider()
191 .install_default()
192 .is_err();
193}
194
195impl AsyncReconstructionCache for RedisReconstructionCache {
196 fn ready(&self) -> ReconstructionCacheFuture<'_, ()> {
197 Box::pin(async move {
198 let mut connection = self.get_connection().await?;
199 let _pong: String = redis::cmd("PING").query_async(&mut connection).await?;
200 Ok(())
201 })
202 }
203
204 fn get<'operation>(
205 &'operation self,
206 key: &'operation ReconstructionCacheKey,
207 ) -> ReconstructionCacheFuture<'operation, Option<Vec<u8>>> {
208 Box::pin(async move {
209 let mut connection = self.get_connection().await?;
210 let redis_key = Self::redis_key(key);
211 let value: Option<Vec<u8>> = connection.get(redis_key).await?;
212 Ok(value)
213 })
214 }
215
216 fn put<'operation>(
217 &'operation self,
218 key: &'operation ReconstructionCacheKey,
219 payload: &'operation [u8],
220 ) -> ReconstructionCacheFuture<'operation, ()> {
221 Box::pin(async move {
222 let mut connection = self.get_connection().await?;
223 let redis_key = Self::redis_key(key);
224 let ttl_seconds = self.ttl_seconds.get();
225 let _: () = connection
226 .set_ex(redis_key, payload.to_vec(), ttl_seconds)
227 .await?;
228 Ok(())
229 })
230 }
231
232 fn delete<'operation>(
233 &'operation self,
234 key: &'operation ReconstructionCacheKey,
235 ) -> ReconstructionCacheFuture<'operation, bool> {
236 Box::pin(async move {
237 let mut connection = self.get_connection().await?;
238 let redis_key = Self::redis_key(key);
239 let deleted: usize = connection.del(redis_key).await?;
240 Ok(deleted > 0)
241 })
242 }
243}
244
245fn encode_component(value: &str) -> String {
246 hex::encode(value.as_bytes())
247}
248
249#[cfg(test)]
250mod tests {
251 use std::{env::var as env_var, error::Error as StdError, num::NonZeroU64};
252
253 use redis::AsyncCommands;
254
255 use super::{RECONSTRUCTION_CACHE_PREFIX, RedisReconstructionCache, RedisTlsConfig};
256 use crate::{AsyncReconstructionCache, ReconstructionCacheKey};
257 use shardline_protocol::{RepositoryProvider, RepositoryScope, SecretBytes};
258
259 #[test]
260 fn redis_cache_debug_redacts_connection_url() {
261 let ttl_seconds = NonZeroU64::new(60).unwrap_or(NonZeroU64::MIN);
262 let cache = RedisReconstructionCache::new(
263 "redis://:cache-secret@cache.example.test:6379/0",
264 ttl_seconds,
265 );
266 assert!(cache.is_ok());
267 let Ok(cache) = cache else {
268 return;
269 };
270
271 let rendered = format!("{cache:?}");
272
273 assert!(!rendered.contains("cache-secret"));
274 assert!(rendered.contains("***"));
275 }
276
277 #[test]
278 fn redis_cache_rejects_empty_url() {
279 let ttl_seconds = NonZeroU64::new(3600).unwrap_or(NonZeroU64::MIN);
280 let result = RedisReconstructionCache::new("", ttl_seconds);
281 assert!(matches!(
282 result,
283 Err(crate::ReconstructionCacheError::EmptyRedisUrl)
284 ));
285 }
286
287 #[test]
288 fn redis_tls_config_debug_redacts_certificate_material() {
289 let tls = RedisTlsConfig::new(Some(SecretBytes::from_slice(b"root-secret")))
290 .with_client_identity(
291 SecretBytes::from_slice(b"client-secret"),
292 SecretBytes::from_slice(b"key-secret"),
293 );
294
295 let rendered = format!("{tls:?}");
296
297 assert!(!rendered.contains("root-secret"));
298 assert!(!rendered.contains("client-secret"));
299 assert!(!rendered.contains("key-secret"));
300 assert!(rendered.contains("***"));
301 }
302
303 #[test]
304 fn redis_tls_client_identity_must_include_certificate_and_key() {
305 let tls = RedisTlsConfig {
306 root_cert: None,
307 client_cert: Some(SecretBytes::from_slice(b"certificate")),
308 client_key: None,
309 };
310 let result =
311 RedisReconstructionCache::new_with_tls("rediss://localhost:6379", NonZeroU64::MIN, tls);
312
313 assert!(matches!(
314 result,
315 Err(crate::ReconstructionCacheError::IncompleteRedisTlsClientIdentity)
316 ));
317 }
318
319 #[tokio::test]
320 async fn redis_cache_roundtrips_payload_when_live_url_is_available() {
321 let Some(redis_url) = env_var("STEXS_REDIS_CACHE_TEST_URL").ok() else {
322 return;
323 };
324
325 let ttl_seconds = NonZeroU64::new(60).unwrap_or(NonZeroU64::MIN);
326 let cache = RedisReconstructionCache::new(&redis_url, ttl_seconds);
327 assert!(cache.is_ok());
328 let Ok(cache) = cache else {
329 return;
330 };
331 let initial_flush = flush_matching_keys(&redis_url).await;
332 assert!(initial_flush.is_ok());
333 let key = ReconstructionCacheKey::latest("asset.bin", None);
334
335 let put = cache.put(&key, b"payload").await;
336 assert!(put.is_ok());
337 let value = cache.get(&key).await;
338 assert!(value.is_ok());
339 assert_eq!(value.ok().flatten(), Some(b"payload".to_vec()));
340
341 let final_flush = flush_matching_keys(&redis_url).await;
342 assert!(final_flush.is_ok());
343 }
344
345 async fn flush_matching_keys(redis_url: &str) -> Result<(), Box<dyn StdError>> {
346 let client = redis::Client::open(redis_url)?;
347 let mut connection = client.get_multiplexed_async_connection().await?;
348 let pattern = format!("{RECONSTRUCTION_CACHE_PREFIX}:*");
349 let keys: Vec<String> = redis::cmd("KEYS")
350 .arg(&pattern)
351 .query_async(&mut connection)
352 .await?;
353 if !keys.is_empty() {
354 let _: usize = connection.del(keys).await?;
355 }
356 Ok(())
357 }
358
359 #[allow(clippy::unwrap_used)]
360 #[tokio::test]
361 async fn redis_cache_ready_succeeds_when_live_url_is_available() {
362 let Some(redis_url) = env_var("STEXS_REDIS_CACHE_TEST_URL").ok() else {
363 return;
364 };
365
366 let ttl_seconds = NonZeroU64::new(60).unwrap_or(NonZeroU64::MIN);
367 let cache = RedisReconstructionCache::new(&redis_url, ttl_seconds).unwrap();
368 let result = cache.ready().await;
369 assert!(result.is_ok());
370 }
371
372 #[test]
375 fn redis_key_format_with_scope() {
376 let scope = RepositoryScope::new(
377 RepositoryProvider::GitHub,
378 "my-org",
379 "my-repo",
380 Some("main"),
381 );
382 assert!(scope.is_ok());
383 let Ok(scope) = scope else {
384 return;
385 };
386 let key = ReconstructionCacheKey::latest("file.bin", Some(&scope));
387 let redis_key = RedisReconstructionCache::redis_key(&key);
388 let owner_hex = hex::encode("my-org");
392 let repo_hex = hex::encode("my-repo");
393 let revision_hex = hex::encode("main");
394 let file_hex = hex::encode("file.bin");
395 assert!(redis_key.starts_with("shardline:reconstruction:v1:"));
396 assert!(redis_key.contains(&format!(":github:{owner_hex}:{repo_hex}:{revision_hex}:")));
397 assert!(redis_key.ends_with(&format!(":latest:{file_hex}")));
398 }
399
400 #[test]
401 fn redis_key_format_without_scope() {
402 let key = ReconstructionCacheKey::latest("file.bin", None);
403 let redis_key = RedisReconstructionCache::redis_key(&key);
404 assert!(redis_key.starts_with("shardline:reconstruction:v1:"));
405 assert!(redis_key.contains(":global:"));
406 }
407
408 #[test]
409 fn redis_key_format_with_content_hash() {
410 let key = ReconstructionCacheKey::version("file.bin", "abc123", None);
411 let redis_key = RedisReconstructionCache::redis_key(&key);
412 let hash_hex = hex::encode("abc123");
413 let file_hex = hex::encode("file.bin");
414 assert!(redis_key.starts_with("shardline:reconstruction:v1:"));
415 assert!(redis_key.ends_with(&format!(":{hash_hex}:{file_hex}")));
416 }
417
418 #[test]
419 fn redis_key_format_with_scope_no_revision() {
420 let scope = RepositoryScope::new(RepositoryProvider::GitLab, "group", "project", None);
421 assert!(scope.is_ok());
422 let Ok(scope) = scope else {
423 return;
424 };
425 let key = ReconstructionCacheKey::latest("doc.pdf", Some(&scope));
426 let redis_key = RedisReconstructionCache::redis_key(&key);
427 let owner_hex = hex::encode("group");
428 let repo_hex = hex::encode("project");
429 assert!(redis_key.starts_with("shardline:reconstruction:v1:"));
430 assert!(redis_key.contains(&format!(":gitlab:{owner_hex}:{repo_hex}:")));
431 assert!(redis_key.contains(":head:"));
432 }
433
434 #[test]
437 fn redis_key_format_gitea_provider() {
438 let scope =
439 RepositoryScope::new(RepositoryProvider::Gitea, "gitea-org", "gitea-repo", None);
440 assert!(scope.is_ok());
441 let Ok(scope) = scope else {
442 return;
443 };
444 let key = ReconstructionCacheKey::version("f1.bin", "hash1", Some(&scope));
445 let redis_key = RedisReconstructionCache::redis_key(&key);
446 let owner_hex = hex::encode("gitea-org");
447 let repo_hex = hex::encode("gitea-repo");
448 let hash_hex = hex::encode("hash1");
449 let file_hex = hex::encode("f1.bin");
450 assert!(redis_key.starts_with("shardline:reconstruction:v1:"));
451 assert!(redis_key.contains(&format!(":gitea:{owner_hex}:{repo_hex}:head:")));
452 assert!(redis_key.ends_with(&format!(":{hash_hex}:{file_hex}")));
453 }
454
455 #[test]
456 fn redis_key_format_codeberg_provider() {
457 let scope = RepositoryScope::new(RepositoryProvider::Codeberg, "user", "repo", Some("v2"));
458 assert!(scope.is_ok());
459 let Ok(scope) = scope else {
460 return;
461 };
462 let key = ReconstructionCacheKey::latest("data.bin", Some(&scope));
463 let redis_key = RedisReconstructionCache::redis_key(&key);
464 let owner_hex = hex::encode("user");
465 let repo_hex = hex::encode("repo");
466 let rev_hex = hex::encode("v2");
467 assert!(redis_key.starts_with("shardline:reconstruction:v1:"));
468 assert!(redis_key.contains(&format!(":codeberg:{owner_hex}:{repo_hex}:{rev_hex}:")));
469 }
470
471 #[test]
472 fn redis_key_format_generic_provider() {
473 let scope = RepositoryScope::new(RepositoryProvider::Generic, "ns", "repo-name", None);
474 assert!(scope.is_ok());
475 let Ok(scope) = scope else {
476 return;
477 };
478 let key = ReconstructionCacheKey::latest("generic.bin", Some(&scope));
479 let redis_key = RedisReconstructionCache::redis_key(&key);
480 let owner_hex = hex::encode("ns");
481 let repo_hex = hex::encode("repo-name");
482 assert!(redis_key.starts_with("shardline:reconstruction:v1:"));
483 assert!(redis_key.contains(&format!(":generic:{owner_hex}:{repo_hex}:head:")));
484 }
485
486 #[test]
489 fn redis_key_format_special_characters_in_names() {
490 let scope = RepositoryScope::new(
491 RepositoryProvider::GitHub,
492 "my-org/team",
493 "asset.repo_v2",
494 Some("feature/branch"),
495 );
496 assert!(scope.is_ok());
497 let Ok(scope) = scope else {
498 return;
499 };
500 let key = ReconstructionCacheKey::latest("my file (1).bin", Some(&scope));
501 let redis_key = RedisReconstructionCache::redis_key(&key);
502 let owner_hex = hex::encode("my-org/team");
503 let repo_hex = hex::encode("asset.repo_v2");
504 let rev_hex = hex::encode("feature/branch");
505 let file_hex = hex::encode("my file (1).bin");
506 assert!(redis_key.contains(&format!(":github:{owner_hex}:{repo_hex}:{rev_hex}:")));
507 assert!(redis_key.ends_with(&format!(":latest:{file_hex}")));
508 }
509
510 #[test]
511 fn redis_key_format_empty_file_id() {
512 let key = ReconstructionCacheKey::latest("", None);
513 let redis_key = RedisReconstructionCache::redis_key(&key);
514 assert!(redis_key.ends_with(":latest:"));
515 }
516
517 #[test]
518 fn redis_key_format_empty_content_hash() {
519 let key = ReconstructionCacheKey::version("f", "", None);
520 let redis_key = RedisReconstructionCache::redis_key(&key);
521 let hash_hex = hex::encode("");
523 assert!(redis_key.contains(&format!(":{hash_hex}:")));
524 }
525
526 #[test]
529 fn redis_cache_rejects_missing_scheme() {
530 let ttl_seconds = NonZeroU64::new(3600).unwrap_or(NonZeroU64::MIN);
531 let result = RedisReconstructionCache::new("localhost:6379", ttl_seconds);
532 assert!(result.is_err());
533 }
534
535 #[test]
536 fn redis_cache_rejects_invalid_port() {
537 let ttl_seconds = NonZeroU64::new(3600).unwrap_or(NonZeroU64::MIN);
538 let result = RedisReconstructionCache::new("redis://localhost:abc", ttl_seconds);
539 assert!(result.is_err());
540 }
541
542 #[test]
543 fn redis_cache_rejects_whitespace_only_url() {
544 let ttl_seconds = NonZeroU64::new(3600).unwrap_or(NonZeroU64::MIN);
545 let result = RedisReconstructionCache::new(" ", ttl_seconds);
546 assert!(matches!(
547 result,
548 Err(crate::ReconstructionCacheError::EmptyRedisUrl)
549 ));
550 }
551
552 #[test]
553 fn redis_cache_rejects_url_with_only_newline() {
554 let ttl_seconds = NonZeroU64::new(3600).unwrap_or(NonZeroU64::MIN);
555 let result = RedisReconstructionCache::new("\n", ttl_seconds);
556 assert!(matches!(
557 result,
558 Err(crate::ReconstructionCacheError::EmptyRedisUrl)
559 ));
560 }
561
562 #[test]
563 fn redis_cache_rejects_url_with_tabs() {
564 let ttl_seconds = NonZeroU64::new(3600).unwrap_or(NonZeroU64::MIN);
565 let result = RedisReconstructionCache::new("\t\t", ttl_seconds);
566 assert!(matches!(
567 result,
568 Err(crate::ReconstructionCacheError::EmptyRedisUrl)
569 ));
570 }
571
572 #[test]
573 fn redis_cache_accepts_valid_redis_url() {
574 let ttl_seconds = NonZeroU64::new(3600).unwrap_or(NonZeroU64::MIN);
575 let result = RedisReconstructionCache::new("redis://127.0.0.1:6379", ttl_seconds);
576 assert!(result.is_ok());
577 }
578
579 #[test]
580 fn redis_cache_accepts_unix_socket_url() {
581 let ttl_seconds = NonZeroU64::new(3600).unwrap_or(NonZeroU64::MIN);
582 let result = RedisReconstructionCache::new("redis+unix:///tmp/redis.sock", ttl_seconds);
583 assert!(result.is_ok());
584 }
585
586 #[test]
589 fn encode_component_hex_encodes_input() {
590 assert_eq!(super::encode_component("hello"), hex::encode("hello"));
591 }
592
593 #[test]
594 fn encode_component_handles_special_characters() {
595 assert_eq!(
596 super::encode_component("file name!"),
597 hex::encode("file name!")
598 );
599 assert_eq!(super::encode_component("a/b:c"), hex::encode("a/b:c"));
600 }
601
602 #[test]
603 fn encode_component_handles_unicode() {
604 assert_eq!(super::encode_component("héllo"), hex::encode("héllo"));
605 }
606
607 #[test]
608 fn encode_component_empty_string() {
609 assert_eq!(super::encode_component(""), hex::encode(""));
610 }
611
612 #[test]
613 fn encode_component_handles_control_characters_in_string() {
614 assert_eq!(super::encode_component("a\tb"), hex::encode("a\tb"));
616 assert_eq!(
617 super::encode_component("line1\nline2"),
618 hex::encode("line1\nline2")
619 );
620 }
621
622 #[allow(clippy::unwrap_used)]
625 #[test]
626 fn redis_cache_clone_produces_independent_cache() {
627 let ttl = NonZeroU64::new(60).unwrap();
628 let cache = RedisReconstructionCache::new("redis://localhost", ttl).unwrap();
629 let cloned = cache.clone();
630 let debug_original = format!("{cache:?}");
632 let debug_cloned = format!("{cloned:?}");
633 assert_eq!(debug_original, debug_cloned);
634 assert_eq!(cache.ttl_seconds, cloned.ttl_seconds);
636 }
637
638 #[allow(clippy::unwrap_used)]
641 #[test]
642 fn redis_cache_new_with_redis_scheme() {
643 let ttl = NonZeroU64::new(60).unwrap();
644 assert!(RedisReconstructionCache::new("redis://localhost", ttl).is_ok());
645 }
646
647 #[allow(clippy::unwrap_used)]
648 #[test]
649 fn redis_cache_new_with_unix_scheme() {
650 let ttl = NonZeroU64::new(60).unwrap();
651 assert!(RedisReconstructionCache::new("redis+unix:///run/redis.sock", ttl).is_ok());
652 }
653
654 #[test]
657 fn redis_key_format_special_chars_in_names() {
658 let scope = RepositoryScope::new(
659 RepositoryProvider::GitHub,
660 "org.name",
661 "repo-name",
662 Some("rev_sion"),
663 );
664 assert!(scope.is_ok());
665 if let Ok(scope) = scope {
666 let key = ReconstructionCacheKey::latest("file.bin", Some(&scope));
667 let redis_key = RedisReconstructionCache::redis_key(&key);
668 assert!(redis_key.starts_with("shardline:reconstruction:v1:"));
669 assert!(redis_key.contains(hex::encode("org.name").as_str()));
670 assert!(redis_key.contains(hex::encode("repo-name").as_str()));
671 assert!(redis_key.contains(hex::encode("rev_sion").as_str()));
672 }
673 }
674
675 #[test]
676 fn redis_key_format_long_file_id() {
677 let long_id = "a".repeat(1000);
678 let key = ReconstructionCacheKey::latest(&long_id, None);
679 let redis_key = RedisReconstructionCache::redis_key(&key);
680 assert!(redis_key.starts_with("shardline:reconstruction:v1:global:latest:"));
681 assert!(redis_key.ends_with(&hex::encode(&long_id)));
682 assert_eq!(
683 redis_key.len(),
684 "shardline:reconstruction:v1:global:latest:".len() + hex::encode(&long_id).len()
685 );
686 }
687
688 #[test]
689 fn redis_key_format_long_content_hash() {
690 let long_hash = "b".repeat(200);
691 let key = ReconstructionCacheKey::version("f", &long_hash, None);
692 let redis_key = RedisReconstructionCache::redis_key(&key);
693 assert!(redis_key.starts_with("shardline:reconstruction:v1:global:"));
694 assert!(redis_key.contains(&hex::encode(&long_hash)));
695 }
696
697 #[test]
698 fn redis_key_format_with_all_providers_and_revision() {
699 let providers = [
700 (RepositoryProvider::GitHub, "github"),
701 (RepositoryProvider::GitLab, "gitlab"),
702 (RepositoryProvider::Gitea, "gitea"),
703 (RepositoryProvider::Codeberg, "codeberg"),
704 (RepositoryProvider::Generic, "generic"),
705 ];
706 for (provider, expected) in &providers {
707 let scope = RepositoryScope::new(*provider, "owner", "repo", Some("main"));
708 assert!(scope.is_ok());
709 if let Ok(scope) = scope {
710 let key = ReconstructionCacheKey::latest("f.bin", Some(&scope));
711 let redis_key = RedisReconstructionCache::redis_key(&key);
712 assert!(
713 redis_key.contains(expected),
714 "expected provider token {expected} in {redis_key}"
715 );
716 }
717 }
718 }
719
720 #[test]
721 fn redis_key_format_version_no_scope_all_providers() {
722 let providers = [
723 RepositoryProvider::GitHub,
724 RepositoryProvider::GitLab,
725 RepositoryProvider::Gitea,
726 RepositoryProvider::Codeberg,
727 RepositoryProvider::Generic,
728 ];
729 for provider in &providers {
730 let scope = RepositoryScope::new(*provider, "ns", "proj", None);
731 assert!(scope.is_ok());
732 if let Ok(scope) = scope {
733 let key = ReconstructionCacheKey::version("file.bin", "hash1", Some(&scope));
734 let redis_key = RedisReconstructionCache::redis_key(&key);
735 assert!(redis_key.starts_with("shardline:reconstruction:v1:"));
736 assert!(
738 redis_key.contains(":head:"),
739 "expected :head: in key for {provider:?}"
740 );
741 }
742 }
743 }
744}