1use std::io::{Read, Write};
20use std::path::Path;
21use std::sync::LazyLock;
22
23use crate::cache::{CacheDir, CacheEntry, KernelMetadata};
24
25static RUNTIME: LazyLock<tokio::runtime::Runtime> = LazyLock::new(|| {
52 tokio::runtime::Builder::new_current_thread()
53 .enable_all()
54 .build()
55 .expect("failed to create tokio runtime for remote cache")
56});
57
58pub fn is_enabled() -> bool {
64 std::env::var(crate::KTSTR_GHA_CACHE_ENV)
65 .ok()
66 .is_some_and(|v| v == "1")
67 && std::env::var("ACTIONS_CACHE_URL")
68 .ok()
69 .is_some_and(|v| !v.is_empty())
70}
71
72const REMOTE_CACHE_NAMESPACE: &str = "ktstr-v2";
95
96fn create_operator() -> Result<opendal::Operator, String> {
105 let builder = opendal::services::Ghac::default()
106 .root("/")
107 .version(REMOTE_CACHE_NAMESPACE);
108
109 opendal::Operator::new(builder)
110 .map_err(|e| format!("create ghac operator: {e}"))
111 .map(|b| b.finish())
112}
113
114const ZSTD_MAGIC: [u8; 4] = [0x28, 0xB5, 0x2F, 0xFD];
116
117pub const MAX_DECOMPRESSED_REMOTE_CACHE_BYTES: u64 = 1024 * 1024 * 1024;
128
129fn pack_entry(entry_dir: &Path, metadata: &KernelMetadata) -> Result<Vec<u8>, String> {
140 let mut archive = tar::Builder::new(Vec::new());
141
142 let mut meta_sanitized = metadata.clone();
146 if let crate::cache::KernelSource::Local {
147 source_tree_path, ..
148 } = &mut meta_sanitized.source
149 {
150 *source_tree_path = None;
151 }
152
153 let meta_json = serde_json::to_string_pretty(&meta_sanitized)
155 .map_err(|e| format!("serialize metadata: {e}"))?;
156 let meta_bytes = meta_json.as_bytes();
157 crate::tar_util::pack_tar_entry(
158 &mut archive,
159 "metadata.json",
160 0o644,
161 meta_bytes.len() as u64,
162 meta_bytes,
163 )
164 .map_err(|e| format!("tar append metadata: {e}"))?;
165
166 let image_path = entry_dir.join(&metadata.image_name);
168 let mut image_file = std::fs::File::open(&image_path)
169 .map_err(|e| format!("open image {}: {e}", image_path.display()))?;
170 let image_size = image_file
171 .metadata()
172 .map_err(|e| format!("image metadata: {e}"))?
173 .len();
174 crate::tar_util::pack_tar_entry(
175 &mut archive,
176 &metadata.image_name,
177 0o644,
178 image_size,
179 &mut image_file,
180 )
181 .map_err(|e| format!("tar append image: {e}"))?;
182
183 let vmlinux_path = entry_dir.join("vmlinux");
185 if let Ok(mut vmlinux_file) = std::fs::File::open(&vmlinux_path) {
186 let vmlinux_size = vmlinux_file
187 .metadata()
188 .map_err(|e| format!("vmlinux metadata: {e}"))?
189 .len();
190 crate::tar_util::pack_tar_entry(
191 &mut archive,
192 "vmlinux",
193 0o644,
194 vmlinux_size,
195 &mut vmlinux_file,
196 )
197 .map_err(|e| format!("tar append vmlinux: {e}"))?;
198 }
199
200 let tar_bytes = archive
201 .into_inner()
202 .map_err(|e| format!("finalize tar: {e}"))?;
203
204 zstd::encode_all(tar_bytes.as_slice(), 3).map_err(|e| format!("zstd compress: {e}"))
206}
207
208fn decompress_payload(data: &[u8]) -> Result<Vec<u8>, String> {
220 if data.len() < 4 || data[..4] != ZSTD_MAGIC {
221 return Err("remote cache entry missing zstd magic".to_string());
222 }
223 decompress_capped(data, MAX_DECOMPRESSED_REMOTE_CACHE_BYTES)
224 .map_err(|e| format!("zstd decompress: {e}"))
225}
226
227fn decompress_capped(bytes: &[u8], max_decompressed: u64) -> Result<Vec<u8>, String> {
236 let decoder =
237 zstd::stream::read::Decoder::new(bytes).map_err(|e| format!("zstd decoder init: {e}"))?;
238 let mut out = Vec::new();
239 decoder
240 .take(max_decompressed.saturating_add(1))
241 .read_to_end(&mut out)
242 .map_err(|e| format!("zstd decompress read: {e}"))?;
243 if out.len() as u64 > max_decompressed {
244 return Err(format!(
245 "zstd-decompressed payload exceeds the {max_decompressed}-byte cap (decompression-bomb guard)",
246 ));
247 }
248 Ok(out)
249}
250
251fn unpack_and_store(cache: &CacheDir, cache_key: &str, data: &[u8]) -> Result<CacheEntry, String> {
262 let tar_bytes = decompress_payload(data)?;
263 let mut archive = tar::Archive::new(tar_bytes.as_slice());
264 let entries = archive
265 .entries()
266 .map_err(|e| format!("read tar entries: {e}"))?;
267
268 let mut metadata: Option<KernelMetadata> = None;
269 let mut image_data: Option<(String, Vec<u8>)> = None;
270 let mut vmlinux_data: Option<Vec<u8>> = None;
271
272 for entry_result in entries {
273 let mut entry = entry_result.map_err(|e| format!("tar entry: {e}"))?;
274 let path = entry
275 .path()
276 .map_err(|e| format!("tar entry path: {e}"))?
277 .to_string_lossy()
278 .into_owned();
279
280 if path == "metadata.json" {
281 let mut content = String::new();
282 entry
283 .read_to_string(&mut content)
284 .map_err(|e| format!("read metadata from tar: {e}"))?;
285 metadata = Some(
286 serde_json::from_str(&content)
287 .map_err(|e| format!("parse metadata from tar: {e}"))?,
288 );
289 } else if path == "vmlinux" {
290 let mut data = Vec::new();
291 entry
292 .read_to_end(&mut data)
293 .map_err(|e| format!("read vmlinux from tar: {e}"))?;
294 vmlinux_data = Some(data);
295 } else {
296 let mut data = Vec::new();
297 entry
298 .read_to_end(&mut data)
299 .map_err(|e| format!("read image from tar: {e}"))?;
300 image_data = Some((path, data));
301 }
302 }
303
304 let meta = metadata.ok_or_else(|| "tar archive missing metadata.json".to_string())?;
305 let (_, img_bytes) =
306 image_data.ok_or_else(|| "tar archive missing kernel image".to_string())?;
307
308 let tmp_dir = tempfile::TempDir::new().map_err(|e| format!("create temp dir: {e}"))?;
310 let tmp_image = tmp_dir.path().join(&meta.image_name);
311 let mut f = std::fs::File::create(&tmp_image).map_err(|e| format!("create temp image: {e}"))?;
312 f.write_all(&img_bytes)
313 .map_err(|e| format!("write temp image: {e}"))?;
314 drop(f);
315
316 let tmp_vmlinux_path;
317 let vmlinux_ref = if let Some(ref vml_bytes) = vmlinux_data {
318 tmp_vmlinux_path = tmp_dir.path().join("vmlinux");
319 let mut vf = std::fs::File::create(&tmp_vmlinux_path)
320 .map_err(|e| format!("create temp vmlinux: {e}"))?;
321 vf.write_all(vml_bytes)
322 .map_err(|e| format!("write temp vmlinux: {e}"))?;
323 drop(vf);
324 Some(tmp_vmlinux_path.as_path())
325 } else {
326 None
327 };
328
329 let mut artifacts = crate::cache::CacheArtifacts::new(&tmp_image);
330 if let Some(v) = vmlinux_ref {
331 artifacts = artifacts.with_vmlinux(v);
332 }
333 cache
334 .store(cache_key, &artifacts, &meta)
335 .map_err(|e| format!("local cache store: {e}"))
336}
337
338pub fn remote_lookup(cache: &CacheDir, cache_key: &str, cli_label: &str) -> Option<CacheEntry> {
348 let op = match create_operator() {
349 Ok(op) => op,
350 Err(e) => {
351 eprintln!("{cli_label}: remote cache warning: {e}");
352 return None;
353 }
354 };
355
356 let data = match RUNTIME.block_on(op.read(cache_key)) {
357 Ok(buf) => buf.to_vec(),
358 Err(e) => {
359 if e.kind() == opendal::ErrorKind::NotFound {
360 return None;
361 }
362 eprintln!("{cli_label}: remote cache read warning: {e}");
363 return None;
364 }
365 };
366
367 match unpack_and_store(cache, cache_key, &data) {
368 Ok(entry) => {
369 eprintln!("{cli_label}: fetched from remote cache: {cache_key}");
370 Some(entry)
371 }
372 Err(e) => {
373 eprintln!("{cli_label}: remote cache unpack warning ({cache_key}): {e}");
374 None
375 }
376 }
377}
378
379pub fn remote_store(entry: &CacheEntry, cli_label: &str) {
387 let meta = &entry.metadata;
389
390 let op = match create_operator() {
391 Ok(op) => op,
392 Err(e) => {
393 eprintln!("{cli_label}: remote cache warning: {e}");
394 return;
395 }
396 };
397
398 let data = match pack_entry(&entry.path, meta) {
399 Ok(d) => d,
400 Err(e) => {
401 eprintln!("{cli_label}: remote cache pack warning: {e}");
402 return;
403 }
404 };
405
406 match RUNTIME.block_on(op.write(&entry.key, data)) {
407 Ok(_) => {
408 eprintln!("{cli_label}: stored to remote cache: {}", entry.key);
409 }
410 Err(e) => {
411 eprintln!("{cli_label}: remote cache write warning: {e}");
412 }
413 }
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419 use crate::cache::{CacheArtifacts, CacheDir, KernelMetadata, KernelSource};
420
421 fn test_metadata() -> KernelMetadata {
422 KernelMetadata::new(
423 KernelSource::Tarball,
424 "x86_64",
425 "bzImage",
426 "2026-04-12T10:00:00Z",
427 )
428 .with_version("6.14.2")
429 }
430
431 fn create_fake_image(dir: &std::path::Path) -> std::path::PathBuf {
432 let image = dir.join("bzImage");
433 std::fs::write(&image, b"fake kernel image data for testing").unwrap();
434 image
435 }
436
437 #[test]
440 fn remote_cache_disabled_by_default() {
441 let _g1 = EnvVarGuard::remove(crate::KTSTR_GHA_CACHE_ENV);
442 let _g2 = EnvVarGuard::remove("ACTIONS_CACHE_URL");
443 assert!(!is_enabled());
444 }
445
446 #[test]
447 fn remote_cache_disabled_without_cache_url() {
448 let _g1 = EnvVarGuard::set(crate::KTSTR_GHA_CACHE_ENV, "1");
449 let _g2 = EnvVarGuard::remove("ACTIONS_CACHE_URL");
450 assert!(!is_enabled());
451 }
452
453 #[test]
454 fn remote_cache_disabled_without_gha_flag() {
455 let _g1 = EnvVarGuard::remove(crate::KTSTR_GHA_CACHE_ENV);
456 let _g2 = EnvVarGuard::set("ACTIONS_CACHE_URL", "https://example.com");
457 assert!(!is_enabled());
458 }
459
460 #[test]
461 fn remote_cache_disabled_with_empty_url() {
462 let _g1 = EnvVarGuard::set(crate::KTSTR_GHA_CACHE_ENV, "1");
463 let _g2 = EnvVarGuard::set("ACTIONS_CACHE_URL", "");
464 assert!(!is_enabled());
465 }
466
467 #[test]
468 fn remote_cache_disabled_with_wrong_flag() {
469 let _g1 = EnvVarGuard::set(crate::KTSTR_GHA_CACHE_ENV, "0");
470 let _g2 = EnvVarGuard::set("ACTIONS_CACHE_URL", "https://example.com");
471 assert!(!is_enabled());
472 }
473
474 #[test]
475 fn remote_cache_enabled_when_both_set() {
476 let _g1 = EnvVarGuard::set(crate::KTSTR_GHA_CACHE_ENV, "1");
477 let _g2 = EnvVarGuard::set("ACTIONS_CACHE_URL", "https://example.com");
478 assert!(is_enabled());
479 }
480
481 #[test]
484 fn remote_cache_pack_unpack_roundtrip() {
485 let tmp = tempfile::TempDir::new().unwrap();
486 let cache = CacheDir::with_root(tmp.path().join("cache"));
487
488 let src = tempfile::TempDir::new().unwrap();
489 let image = create_fake_image(src.path());
490 let meta = test_metadata();
491 let entry = cache
492 .store("test-key", &CacheArtifacts::new(&image), &meta)
493 .unwrap();
494
495 let packed = pack_entry(&entry.path, &entry.metadata).unwrap();
496 assert!(!packed.is_empty());
497
498 let tmp2 = tempfile::TempDir::new().unwrap();
499 let cache2 = CacheDir::with_root(tmp2.path().join("cache"));
500 let restored = unpack_and_store(&cache2, "test-key", &packed).unwrap();
501
502 assert_eq!(restored.key, "test-key");
503 let restored_meta = &restored.metadata;
504 assert_eq!(restored_meta.version.as_deref(), Some("6.14.2"));
505 assert_eq!(restored_meta.arch, "x86_64");
506 assert_eq!(restored_meta.image_name, "bzImage");
507 assert_eq!(restored_meta.source, KernelSource::Tarball);
508
509 let restored_image = restored.path.join("bzImage");
510 let original_content = std::fs::read(&image).unwrap();
511 let restored_content = std::fs::read(&restored_image).unwrap();
512 assert_eq!(original_content, restored_content);
513 }
514
515 #[test]
516 fn remote_cache_pack_entry_excludes_config_sidecar() {
517 let tmp = tempfile::TempDir::new().unwrap();
523 let cache = CacheDir::with_root(tmp.path().join("cache"));
524 let src = tempfile::TempDir::new().unwrap();
525 let image = create_fake_image(src.path());
526 let meta = test_metadata();
527 let entry = cache
528 .store("legacy-config", &CacheArtifacts::new(&image), &meta)
529 .unwrap();
530 std::fs::write(entry.path.join(".config"), b"CONFIG_HZ=1000\n").unwrap();
532
533 let packed = pack_entry(&entry.path, &entry.metadata).unwrap();
534 let tar_bytes = decompress_payload(&packed).unwrap();
535 let mut archive = tar::Archive::new(tar_bytes.as_slice());
536 let paths: Vec<String> = archive
537 .entries()
538 .unwrap()
539 .map(|e| e.unwrap().path().unwrap().to_string_lossy().into_owned())
540 .collect();
541 assert!(
542 !paths.iter().any(|p| p == ".config"),
543 "pack_entry should not include .config, got {paths:?}"
544 );
545 }
546
547 #[test]
548 fn remote_cache_pack_produces_valid_tar() {
549 let tmp = tempfile::TempDir::new().unwrap();
550 let cache = CacheDir::with_root(tmp.path().join("cache"));
551
552 let src = tempfile::TempDir::new().unwrap();
553 let image = create_fake_image(src.path());
554 let meta = test_metadata();
555 let entry = cache
556 .store("valid-tar", &CacheArtifacts::new(&image), &meta)
557 .unwrap();
558
559 let packed = pack_entry(&entry.path, &entry.metadata).unwrap();
560
561 let tar_bytes = decompress_payload(&packed).unwrap();
564 let mut archive = tar::Archive::new(tar_bytes.as_slice());
565 let entries: Vec<_> = archive.entries().unwrap().collect();
566 assert_eq!(entries.len(), 2);
567 }
568
569 #[test]
570 fn remote_cache_pack_is_zstd_compressed() {
571 let tmp = tempfile::TempDir::new().unwrap();
572 let cache = CacheDir::with_root(tmp.path().join("cache"));
573
574 let src = tempfile::TempDir::new().unwrap();
575 let image = create_fake_image(src.path());
576 let meta = test_metadata();
577 let entry = cache
578 .store("zstd-key", &CacheArtifacts::new(&image), &meta)
579 .unwrap();
580
581 let packed = pack_entry(&entry.path, &entry.metadata).unwrap();
582 assert!(
583 packed.len() >= 4 && packed[..4] == ZSTD_MAGIC,
584 "packed data should start with zstd magic"
585 );
586 }
587
588 #[test]
597 fn remote_cache_unpack_rejects_raw_tar() {
598 let tmp = tempfile::TempDir::new().unwrap();
599 let cache = CacheDir::with_root(tmp.path().join("cache"));
600
601 let mut archive = tar::Builder::new(Vec::new());
602 let meta = test_metadata();
603 let meta_json = serde_json::to_string_pretty(&meta).unwrap();
604 let meta_bytes = meta_json.as_bytes();
605 crate::tar_util::pack_tar_entry(
606 &mut archive,
607 "metadata.json",
608 0o644,
609 meta_bytes.len() as u64,
610 meta_bytes,
611 )
612 .unwrap();
613 let raw_tar = archive.into_inner().unwrap();
614
615 assert!(raw_tar.len() < 4 || raw_tar[..4] != ZSTD_MAGIC);
617
618 let err = unpack_and_store(&cache, "raw-tar-key", &raw_tar).unwrap_err();
619 assert!(
620 err.contains("zstd magic"),
621 "non-zstd payload must be rejected with a `zstd magic` \
622 diagnostic from the precondition check, got: {err}",
623 );
624 }
625
626 #[test]
635 fn remote_cache_decompress_payload_rejects_short_inputs() {
636 for len in 0..=3 {
637 let bytes = vec![0u8; len];
638 let err = super::decompress_payload(&bytes).unwrap_err();
639 assert!(
640 err.contains("zstd magic"),
641 "{len}-byte payload must be rejected by the magic-number \
642 precondition, got: {err}",
643 );
644 }
645 }
646
647 #[test]
648 fn remote_cache_unpack_rejects_missing_metadata() {
649 let tmp = tempfile::TempDir::new().unwrap();
650 let cache = CacheDir::with_root(tmp.path().join("cache"));
651
652 let mut archive = tar::Builder::new(Vec::new());
653 let data = b"kernel image";
654 crate::tar_util::pack_tar_entry(
655 &mut archive,
656 "bzImage",
657 0o644,
658 data.len() as u64,
659 data.as_slice(),
660 )
661 .unwrap();
662 let raw_tar = archive.into_inner().unwrap();
663 let packed = zstd::encode_all(raw_tar.as_slice(), 3).unwrap();
664
665 let result = unpack_and_store(&cache, "no-meta", &packed);
666 assert!(result.is_err());
667 assert!(
668 result.unwrap_err().contains("missing metadata"),
669 "expected metadata error"
670 );
671 }
672
673 #[test]
674 fn remote_cache_unpack_rejects_missing_image() {
675 let tmp = tempfile::TempDir::new().unwrap();
676 let cache = CacheDir::with_root(tmp.path().join("cache"));
677
678 let mut archive = tar::Builder::new(Vec::new());
679 let meta = test_metadata();
680 let meta_json = serde_json::to_string_pretty(&meta).unwrap();
681 let meta_bytes = meta_json.as_bytes();
682 crate::tar_util::pack_tar_entry(
683 &mut archive,
684 "metadata.json",
685 0o644,
686 meta_bytes.len() as u64,
687 meta_bytes,
688 )
689 .unwrap();
690 let raw_tar = archive.into_inner().unwrap();
691 let packed = zstd::encode_all(raw_tar.as_slice(), 3).unwrap();
692
693 let result = unpack_and_store(&cache, "no-image", &packed);
694 assert!(result.is_err());
695 assert!(
696 result.unwrap_err().contains("missing kernel image"),
697 "expected image error"
698 );
699 }
700
701 #[test]
704 fn remote_cache_remote_lookup_returns_none_when_disabled() {
705 let _g1 = EnvVarGuard::remove(crate::KTSTR_GHA_CACHE_ENV);
706 let _g2 = EnvVarGuard::remove("ACTIONS_CACHE_URL");
707 assert!(!is_enabled());
708 }
709
710 #[test]
713 fn remote_cache_remote_store_when_disabled() {
714 let _g1 = EnvVarGuard::remove(crate::KTSTR_GHA_CACHE_ENV);
715 let _g2 = EnvVarGuard::remove("ACTIONS_CACHE_URL");
716
717 let tmp = tempfile::TempDir::new().unwrap();
718 let cache = CacheDir::with_root(tmp.path().join("cache"));
719 let src = tempfile::TempDir::new().unwrap();
720 let image = create_fake_image(src.path());
721 let meta = test_metadata();
722 let entry = cache
723 .store("test-entry", &CacheArtifacts::new(&image), &meta)
724 .unwrap();
725
726 let packed = pack_entry(&entry.path, &entry.metadata);
727 assert!(packed.is_ok());
728 }
729
730 #[test]
733 fn remote_cache_source_tree_path_sanitized_on_roundtrip() {
734 let tmp = tempfile::TempDir::new().unwrap();
735 let cache = CacheDir::with_root(tmp.path().join("cache"));
736
737 let src = tempfile::TempDir::new().unwrap();
738 let image = create_fake_image(src.path());
739 let meta = KernelMetadata::new(
740 KernelSource::Local {
741 source_tree_path: Some(std::path::PathBuf::from("/tmp/linux-src")),
742 git_hash: Some("deadbee".to_string()),
743 },
744 "x86_64",
745 "bzImage",
746 "2026-04-12T10:00:00Z",
747 );
748 assert!(matches!(
749 meta.source,
750 KernelSource::Local {
751 source_tree_path: Some(_),
752 git_hash: Some(_),
753 }
754 ));
755
756 let entry = cache
757 .store("stp-key", &CacheArtifacts::new(&image), &meta)
758 .unwrap();
759
760 let packed = pack_entry(&entry.path, &entry.metadata).unwrap();
761
762 let tmp2 = tempfile::TempDir::new().unwrap();
763 let cache2 = CacheDir::with_root(tmp2.path().join("cache"));
764 let restored = unpack_and_store(&cache2, "stp-key", &packed).unwrap();
765
766 let restored_meta = &restored.metadata;
767 assert!(
768 matches!(
769 &restored_meta.source,
770 KernelSource::Local {
771 source_tree_path: None,
772 git_hash: Some(h),
773 } if h == "deadbee"
774 ),
775 "source_tree_path must be stripped during pack, git_hash must survive"
776 );
777 }
778
779 #[test]
780 fn remote_cache_pack_with_git_metadata() {
781 let tmp = tempfile::TempDir::new().unwrap();
782 let cache = CacheDir::with_root(tmp.path().join("cache"));
783
784 let src = tempfile::TempDir::new().unwrap();
785 let image = create_fake_image(src.path());
786 let meta = KernelMetadata::new(
787 KernelSource::git("a1b2c3d", "v6.15-rc3"),
788 "x86_64",
789 "bzImage",
790 "2026-04-12T12:00:00Z",
791 );
792
793 let entry = cache
794 .store("git-key", &CacheArtifacts::new(&image), &meta)
795 .unwrap();
796 let packed = pack_entry(&entry.path, &entry.metadata).unwrap();
797
798 let tmp2 = tempfile::TempDir::new().unwrap();
799 let cache2 = CacheDir::with_root(tmp2.path().join("cache"));
800 let restored = unpack_and_store(&cache2, "git-key", &packed).unwrap();
801
802 let rmeta = &restored.metadata;
803 assert!(matches!(
804 rmeta.source,
805 KernelSource::Git {
806 git_hash: Some(ref h),
807 git_ref: Some(ref r),
808 }
809 if h == "a1b2c3d" && r == "v6.15-rc3"
810 ));
811 }
812
813 #[test]
823 fn remote_cache_decompress_capped_rejects_decompression_bomb() {
824 let payload = vec![0u8; 8192];
825 let compressed = zstd::encode_all(payload.as_slice(), 3).unwrap();
826 let cap: u64 = 1024;
827 let err = super::decompress_capped(&compressed, cap).unwrap_err();
828 assert!(
829 err.contains("decompression-bomb guard"),
830 "expected decompression-bomb guard error, got: {err}",
831 );
832 }
833
834 #[test]
841 fn remote_cache_decompress_capped_accepts_payload_at_cap_boundary() {
842 let payload = b"hello world".to_vec();
843 let compressed = zstd::encode_all(payload.as_slice(), 3).unwrap();
844 let out = super::decompress_capped(&compressed, payload.len() as u64).unwrap();
845 assert_eq!(
846 out, payload,
847 "payload exactly at the cap must round-trip — \
848 cap is inclusive (`>` not `>=`)",
849 );
850 }
851
852 #[test]
862 fn remote_cache_namespace_has_version_suffix() {
863 let ns = super::REMOTE_CACHE_NAMESPACE;
864 assert!(!ns.is_empty(), "namespace must not be empty");
865 assert!(
866 ns.starts_with("ktstr-v"),
867 "namespace must keep `ktstr-v` prefix; got: {ns}",
868 );
869 let suffix = ns.strip_prefix("ktstr-v").unwrap();
870 assert!(
871 suffix.parse::<u32>().is_ok(),
872 "version suffix must be numeric; got: {suffix:?}",
873 );
874 }
875
876 use crate::test_support::test_helpers::EnvVarGuard;
877}