1use std::collections::HashMap;
8use std::io::Read;
9use std::path::Path;
10
11use serde::{Deserialize, Serialize};
12
13use crate::errors::OciError;
14use crate::output::Printer;
15
16pub const MEDIA_TYPE_MODULE_CONFIG: &str = "application/vnd.cfgd.module.config.v1+json";
21pub const MEDIA_TYPE_MODULE_LAYER: &str = "application/vnd.cfgd.module.layer.v1.tar+gzip";
22
23const MEDIA_TYPE_OCI_MANIFEST: &str = "application/vnd.oci.image.manifest.v1+json";
25
26#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct OciReference {
33 pub registry: String,
35 pub repository: String,
37 pub reference: ReferenceKind,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum ReferenceKind {
43 Tag(String),
44 Digest(String),
45}
46
47impl std::fmt::Display for OciReference {
48 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49 match &self.reference {
50 ReferenceKind::Tag(tag) => {
51 write!(f, "{}/{}:{}", self.registry, self.repository, tag)
52 }
53 ReferenceKind::Digest(digest) => {
54 write!(f, "{}/{}@{}", self.registry, self.repository, digest)
55 }
56 }
57 }
58}
59
60impl OciReference {
61 pub fn parse(reference: &str) -> Result<Self, OciError> {
70 if reference.is_empty()
71 || reference
72 .chars()
73 .any(|c| c.is_whitespace() || c.is_control())
74 {
75 return Err(OciError::InvalidReference {
76 reference: reference.to_string(),
77 });
78 }
79
80 let (name_part, ref_kind) = if let Some((name, digest)) = reference.split_once('@') {
82 (name, ReferenceKind::Digest(digest.to_string()))
83 } else if let Some((name, tag)) = reference.rsplit_once(':') {
84 if tag.chars().all(|c| c.is_ascii_digit()) && !name.contains('/') {
92 return Err(OciError::InvalidReference {
94 reference: reference.to_string(),
95 });
96 }
97 if tag.contains('/') {
100 (reference, ReferenceKind::Tag("latest".to_string()))
103 } else {
104 (name, ReferenceKind::Tag(tag.to_string()))
105 }
106 } else {
107 (reference, ReferenceKind::Tag("latest".to_string()))
108 };
109
110 let parts: Vec<&str> = name_part.splitn(2, '/').collect();
114 let (registry, repository) = if parts.len() == 1 {
115 ("docker.io".to_string(), format!("library/{}", parts[0]))
117 } else {
118 let first = parts[0];
119 let rest = parts[1];
120 if first.contains('.') || first.contains(':') || first == "localhost" {
121 (first.to_string(), rest.to_string())
122 } else {
123 ("docker.io".to_string(), name_part.to_string())
125 }
126 };
127
128 if repository.is_empty() {
129 return Err(OciError::InvalidReference {
130 reference: reference.to_string(),
131 });
132 }
133
134 Ok(OciReference {
135 registry,
136 repository,
137 reference: ref_kind,
138 })
139 }
140
141 pub fn reference_str(&self) -> &str {
143 match &self.reference {
144 ReferenceKind::Tag(t) => t,
145 ReferenceKind::Digest(d) => d,
146 }
147 }
148
149 fn api_base(&self) -> String {
151 let scheme = if self.registry == "localhost"
152 || self.registry.starts_with("localhost:")
153 || self.registry.starts_with("127.0.0.1")
154 || is_insecure_registry(&self.registry)
155 {
156 "http"
157 } else {
158 "https"
159 };
160 format!("{scheme}://{}/v2", self.registry)
161 }
162}
163
164fn is_insecure_registry(registry: &str) -> bool {
166 std::env::var("OCI_INSECURE_REGISTRIES")
167 .unwrap_or_default()
168 .split(',')
169 .any(|r| r.trim() == registry)
170}
171
172#[derive(Debug, Clone)]
178pub struct RegistryAuth {
179 pub username: String,
180 pub password: String,
181}
182
183#[derive(Debug, Default, Deserialize)]
185#[serde(rename_all = "camelCase")]
186struct DockerConfig {
187 #[serde(default)]
188 auths: HashMap<String, DockerAuthEntry>,
189 #[serde(default)]
190 cred_helpers: HashMap<String, String>,
191}
192
193#[derive(Debug, Default, Deserialize)]
194struct DockerAuthEntry {
195 auth: Option<String>,
196}
197
198impl RegistryAuth {
199 pub fn resolve(registry: &str) -> Option<Self> {
206 if let (Ok(user), Ok(pass)) = (
208 std::env::var("REGISTRY_USERNAME"),
209 std::env::var("REGISTRY_PASSWORD"),
210 ) && !user.is_empty()
211 && !pass.is_empty()
212 {
213 return Some(RegistryAuth {
214 username: user,
215 password: pass,
216 });
217 }
218
219 let config_path = docker_config_path();
221 if let Ok(contents) = std::fs::read_to_string(&config_path)
222 && let Ok(config) = serde_json::from_str::<DockerConfig>(&contents)
223 {
224 if let Some(auth) = resolve_from_docker_auths(&config.auths, registry) {
226 return Some(auth);
227 }
228
229 if let Some(helper) = config.cred_helpers.get(registry)
231 && let Some(auth) = resolve_from_credential_helper(helper, registry)
232 {
233 return Some(auth);
234 }
235 }
236
237 None
238 }
239
240 fn basic_auth_header(&self) -> String {
242 format!(
243 "Basic {}",
244 base64_encode(format!("{}:{}", self.username, self.password).as_bytes())
245 )
246 }
247}
248
249fn docker_config_path() -> std::path::PathBuf {
250 if let Ok(dir) = std::env::var("DOCKER_CONFIG") {
251 return std::path::PathBuf::from(dir).join("config.json");
252 }
253 crate::expand_tilde(std::path::Path::new("~/.docker/config.json"))
254}
255
256fn resolve_from_docker_auths(
259 auths: &HashMap<String, DockerAuthEntry>,
260 registry: &str,
261) -> Option<RegistryAuth> {
262 let candidates = [
264 registry.to_string(),
265 format!("https://{}", registry),
266 format!("https://{}/v2/", registry),
267 format!("https://{}/v1/", registry),
268 ];
269
270 for candidate in &candidates {
271 if let Some(entry) = auths.get(candidate)
272 && let Some(ref auth_b64) = entry.auth
273 && let Some(cred) = decode_docker_auth(auth_b64)
274 {
275 return Some(cred);
276 }
277 }
278
279 if registry == "docker.io" || registry == "index.docker.io" {
281 let alt_candidates = [
282 "https://index.docker.io/v1/".to_string(),
283 "index.docker.io".to_string(),
284 ];
285 for candidate in &alt_candidates {
286 if let Some(entry) = auths.get(candidate)
287 && let Some(ref auth_b64) = entry.auth
288 && let Some(cred) = decode_docker_auth(auth_b64)
289 {
290 return Some(cred);
291 }
292 }
293 }
294
295 None
296}
297
298fn decode_docker_auth(auth_b64: &str) -> Option<RegistryAuth> {
300 let decoded = base64_decode(auth_b64)?;
301 let decoded_str = String::from_utf8(decoded).ok()?;
302 let (user, pass) = decoded_str.split_once(':')?;
303 if user.is_empty() {
304 return None;
305 }
306 Some(RegistryAuth {
307 username: user.to_string(),
308 password: pass.to_string(),
309 })
310}
311
312fn resolve_from_credential_helper(helper_name: &str, registry: &str) -> Option<RegistryAuth> {
314 let helper_bin = format!("docker-credential-{}", helper_name);
315 let output = std::process::Command::new(&helper_bin)
316 .arg("get")
317 .stdin(std::process::Stdio::piped())
318 .stdout(std::process::Stdio::piped())
319 .stderr(std::process::Stdio::null())
320 .spawn()
321 .ok()
322 .and_then(|mut child| {
323 use std::io::Write;
324 if let Some(ref mut stdin) = child.stdin {
325 stdin.write_all(registry.as_bytes()).ok();
326 }
327 drop(child.stdin.take()); let start = std::time::Instant::now();
329 let timeout = std::time::Duration::from_secs(10);
330 loop {
331 match child.try_wait() {
332 Ok(Some(_)) => return child.wait_with_output().ok(),
333 Ok(None) if start.elapsed() >= timeout => {
334 let _ = child.kill();
335 let _ = child.wait();
336 tracing::warn!(helper = %helper_bin, "credential helper timed out after {}s", timeout.as_secs());
337 return None;
338 }
339 Ok(None) => std::thread::sleep(std::time::Duration::from_millis(50)),
340 Err(_) => return None,
341 }
342 }
343 })?;
344
345 if !output.status.success() {
346 return None;
347 }
348
349 #[derive(Deserialize)]
350 struct CredHelperOutput {
351 #[serde(alias = "Username")]
352 username: String,
353 #[serde(alias = "Secret")]
354 secret: String,
355 }
356
357 let parsed: CredHelperOutput = serde_json::from_slice(&output.stdout).ok()?;
358 if parsed.username.is_empty() {
359 return None;
360 }
361 Some(RegistryAuth {
362 username: parsed.username,
363 password: parsed.secret,
364 })
365}
366
367#[derive(Debug, Serialize, Deserialize)]
372#[serde(rename_all = "camelCase")]
373struct OciManifest {
374 schema_version: u32,
375 media_type: String,
376 config: OciDescriptor,
377 layers: Vec<OciDescriptor>,
378 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
379 annotations: HashMap<String, String>,
380}
381
382#[derive(Debug, Serialize, Deserialize)]
383#[serde(rename_all = "camelCase")]
384struct OciDescriptor {
385 media_type: String,
386 digest: String,
387 size: u64,
388 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
389 annotations: HashMap<String, String>,
390}
391
392fn get_bearer_token(
399 agent: &ureq::Agent,
400 www_authenticate: &str,
401 auth: Option<&RegistryAuth>,
402) -> Result<String, OciError> {
403 let realm = extract_auth_param(www_authenticate, "realm");
405 let service = extract_auth_param(www_authenticate, "service");
406 let scope = extract_auth_param(www_authenticate, "scope");
407
408 let realm = realm.ok_or_else(|| OciError::AuthFailed {
409 registry: String::new(),
410 message: format!("missing realm in Www-Authenticate header: {www_authenticate}"),
411 })?;
412
413 let mut url = realm.to_string();
414 let mut params = Vec::new();
415 if let Some(svc) = service {
416 params.push(format!("service={}", svc));
417 }
418 if let Some(sc) = scope {
419 params.push(format!("scope={}", sc));
420 }
421 if !params.is_empty() {
422 url = format!("{}?{}", url, params.join("&"));
423 }
424
425 let mut req = agent.get(&url);
426 if let Some(cred) = auth {
427 req = req.set("Authorization", &cred.basic_auth_header());
428 }
429
430 let resp = req.call().map_err(|e| OciError::AuthFailed {
431 registry: String::new(),
432 message: format!("token request failed: {e}"),
433 })?;
434
435 #[derive(Deserialize)]
436 struct TokenResponse {
437 token: Option<String>,
438 access_token: Option<String>,
439 }
440
441 let body_str = resp.into_string().map_err(|e| OciError::AuthFailed {
442 registry: String::new(),
443 message: format!("cannot read token response body: {e}"),
444 })?;
445 let body: TokenResponse =
446 serde_json::from_str(&body_str).map_err(|e| OciError::AuthFailed {
447 registry: String::new(),
448 message: format!("invalid token response JSON: {e}"),
449 })?;
450
451 body.token
452 .or(body.access_token)
453 .ok_or_else(|| OciError::AuthFailed {
454 registry: String::new(),
455 message: "no token in response".to_string(),
456 })
457}
458
459fn extract_auth_param<'a>(header: &'a str, param: &str) -> Option<&'a str> {
460 let search = format!("{param}=\"");
461 let start = header.find(&search)?;
462 let value_start = start + search.len();
463 let end = header[value_start..].find('"')?;
464 Some(&header[value_start..value_start + end])
465}
466
467fn authenticated_request(
469 agent: &ureq::Agent,
470 method: &str,
471 url: &str,
472 auth: Option<&RegistryAuth>,
473 accept: Option<&str>,
474 content_type: Option<&str>,
475 body: Option<&[u8]>,
476) -> Result<ureq::Response, OciError> {
477 let mut req = match method {
479 "GET" => agent.get(url),
480 "PUT" => agent.put(url),
481 "POST" => agent.post(url),
482 "HEAD" => agent.head(url),
483 "PATCH" => agent.request("PATCH", url),
484 _ => agent.get(url),
485 };
486
487 if let Some(ct) = content_type {
488 req = req.set("Content-Type", ct);
489 }
490 if let Some(acc) = accept {
491 req = req.set("Accept", acc);
492 }
493 if let Some(cred) = auth {
494 req = req.set("Authorization", &cred.basic_auth_header());
495 }
496
497 let result = if let Some(b) = body {
498 req.send_bytes(b)
499 } else {
500 req.call()
501 };
502
503 match result {
504 Ok(resp) => Ok(resp),
505 Err(ureq::Error::Status(401, resp)) => {
506 let www_auth = resp
508 .header("Www-Authenticate")
509 .or_else(|| resp.header("www-authenticate"))
510 .unwrap_or("")
511 .to_string();
512
513 if www_auth.is_empty() || !www_auth.contains("Bearer") {
514 return Err(OciError::AuthFailed {
515 registry: url.to_string(),
516 message: "401 with no Bearer challenge".to_string(),
517 });
518 }
519
520 let token = get_bearer_token(agent, &www_auth, auth)?;
521
522 let mut req2 = match method {
524 "GET" => agent.get(url),
525 "PUT" => agent.put(url),
526 "POST" => agent.post(url),
527 "HEAD" => agent.head(url),
528 "PATCH" => agent.request("PATCH", url),
529 _ => agent.get(url),
530 };
531
532 if let Some(ct) = content_type {
533 req2 = req2.set("Content-Type", ct);
534 }
535 if let Some(acc) = accept {
536 req2 = req2.set("Accept", acc);
537 }
538 req2 = req2.set("Authorization", &format!("Bearer {}", token));
539
540 let result2 = if let Some(b) = body {
541 req2.send_bytes(b)
542 } else {
543 req2.call()
544 };
545
546 result2.map_err(|e| OciError::RequestFailed {
547 message: format!("{e}"),
548 })
549 }
550 Err(ureq::Error::Status(code, _)) => Err(OciError::RequestFailed {
551 message: format!("HTTP {code} from {url}"),
552 }),
553 Err(e) => Err(OciError::RequestFailed {
554 message: format!("{e}"),
555 }),
556 }
557}
558
559fn sha256_digest(data: &[u8]) -> String {
564 format!("sha256:{}", crate::sha256_hex(data))
565}
566
567fn upload_blob(
570 agent: &ureq::Agent,
571 oci_ref: &OciReference,
572 auth: Option<&RegistryAuth>,
573 data: &[u8],
574 content_type: &str,
575) -> Result<String, OciError> {
576 let digest = sha256_digest(data);
577
578 let head_url = format!(
580 "{}/{}/blobs/{}",
581 oci_ref.api_base(),
582 oci_ref.repository,
583 digest
584 );
585 let head_result = authenticated_request(agent, "HEAD", &head_url, auth, None, None, None);
586 if head_result.is_ok() {
587 tracing::debug!(digest = %digest, "blob already exists, skipping upload");
588 return Ok(digest);
589 }
590
591 let upload_url = format!(
593 "{}/{}/blobs/uploads/",
594 oci_ref.api_base(),
595 oci_ref.repository,
596 );
597
598 let resp = authenticated_request(agent, "POST", &upload_url, auth, None, None, Some(&[]))
599 .map_err(|e| OciError::BlobUploadFailed {
600 digest: digest.clone(),
601 message: format!("upload initiation failed: {e}"),
602 })?;
603
604 let location = resp
605 .header("Location")
606 .ok_or_else(|| OciError::BlobUploadFailed {
607 digest: digest.clone(),
608 message: "no Location header in upload response".to_string(),
609 })?
610 .to_string();
611
612 let sep = if location.contains('?') { "&" } else { "?" };
614 let put_url = format!("{location}{sep}digest={digest}");
615
616 let put_url = if put_url.starts_with("http://") || put_url.starts_with("https://") {
618 put_url
619 } else {
620 format!(
621 "{}{}",
622 oci_ref
623 .api_base()
624 .trim_end_matches("/v2")
625 .trim_end_matches("/v2/"),
626 put_url
627 )
628 };
629
630 authenticated_request(
631 agent,
632 "PUT",
633 &put_url,
634 auth,
635 None,
636 Some(content_type),
637 Some(data),
638 )
639 .map_err(|e| OciError::BlobUploadFailed {
640 digest: digest.clone(),
641 message: format!("blob PUT failed: {e}"),
642 })?;
643
644 tracing::debug!(digest = %digest, size = data.len(), "blob uploaded");
645 Ok(digest)
646}
647
648pub fn create_tar_gz(dir: &Path) -> Result<Vec<u8>, OciError> {
654 let buf = Vec::new();
655 let encoder = flate2::write::GzEncoder::new(buf, flate2::Compression::default());
656 let mut archive = tar::Builder::new(encoder);
657
658 add_dir_to_tar(&mut archive, dir, dir)?;
660
661 let encoder = archive.into_inner().map_err(|e| OciError::ArchiveError {
662 message: format!("tar finalization failed: {e}"),
663 })?;
664 let compressed = encoder.finish().map_err(|e| OciError::ArchiveError {
665 message: format!("gzip finalization failed: {e}"),
666 })?;
667 Ok(compressed)
668}
669
670fn add_dir_to_tar<W: std::io::Write>(
671 archive: &mut tar::Builder<W>,
672 dir: &Path,
673 root: &Path,
674) -> Result<(), OciError> {
675 let entries = std::fs::read_dir(dir).map_err(|e| OciError::ArchiveError {
676 message: format!("cannot read directory {}: {e}", dir.display()),
677 })?;
678
679 for entry in entries {
680 let entry = entry.map_err(|e| OciError::ArchiveError {
681 message: format!("directory entry error: {e}"),
682 })?;
683 let file_type = entry.file_type().map_err(|e| OciError::ArchiveError {
684 message: format!("file type error: {e}"),
685 })?;
686 if file_type.is_symlink() {
688 continue;
689 }
690 let path = entry.path();
691 let relative = path
692 .strip_prefix(root)
693 .map_err(|e| OciError::ArchiveError {
694 message: format!("path prefix error: {e}"),
695 })?;
696
697 if file_type.is_dir() {
698 archive
699 .append_dir(relative, &path)
700 .map_err(|e| OciError::ArchiveError {
701 message: format!("tar append dir failed: {e}"),
702 })?;
703 add_dir_to_tar(archive, &path, root)?;
704 } else {
705 archive
706 .append_path_with_name(&path, relative)
707 .map_err(|e| OciError::ArchiveError {
708 message: format!("tar append file failed: {e}"),
709 })?;
710 }
711 }
712 Ok(())
713}
714
715pub fn extract_tar_gz(data: &[u8], output_dir: &Path) -> Result<(), OciError> {
717 let decoder = flate2::read::GzDecoder::new(data);
718 let mut archive = tar::Archive::new(decoder);
719
720 std::fs::create_dir_all(output_dir).map_err(|e| OciError::ArchiveError {
721 message: format!("cannot create output directory: {e}"),
722 })?;
723
724 let canonical_output = output_dir
728 .canonicalize()
729 .map_err(|e| OciError::ArchiveError {
730 message: format!("cannot canonicalize output directory: {e}"),
731 })?;
732
733 for entry in archive.entries().map_err(|e| OciError::ArchiveError {
734 message: format!("tar iteration failed: {e}"),
735 })? {
736 let mut entry = entry.map_err(|e| OciError::ArchiveError {
737 message: format!("tar entry read failed: {e}"),
738 })?;
739
740 if entry.header().entry_type().is_symlink() || entry.header().entry_type().is_hard_link() {
742 let path = entry.path().unwrap_or_default();
743 tracing::warn!(path = %path.display(), "skipping symlink/hardlink in OCI archive");
744 continue;
745 }
746
747 entry
748 .unpack_in(&canonical_output)
749 .map_err(|e| OciError::ArchiveError {
750 message: format!("tar entry extraction failed: {e}"),
751 })?;
752 }
753
754 Ok(())
755}
756
757pub fn push_module(
769 dir: &Path,
770 artifact_ref: &str,
771 platform: Option<&str>,
772 printer: Option<&Printer>,
773) -> Result<String, OciError> {
774 let oci_ref = OciReference::parse(artifact_ref)?;
775 let auth = RegistryAuth::resolve(&oci_ref.registry);
776 let agent = ureq::AgentBuilder::new()
777 .timeout(std::time::Duration::from_secs(300))
778 .build();
779 let spinner = printer.map(|p| p.spinner(&format!("Pushing module to {artifact_ref}...")));
780 let (digest, _size) = push_module_inner(&agent, dir, &oci_ref, auth.as_ref(), platform)?;
781 if let Some(s) = spinner {
782 s.finish_and_clear();
783 }
784 Ok(digest)
785}
786
787fn push_module_inner(
790 agent: &ureq::Agent,
791 dir: &Path,
792 oci_ref: &OciReference,
793 auth: Option<&RegistryAuth>,
794 platform: Option<&str>,
795) -> Result<(String, u64), OciError> {
796 let module_yaml_path = dir.join("module.yaml");
798 if !module_yaml_path.exists() {
799 return Err(OciError::ModuleYamlNotFound {
800 dir: dir.to_path_buf(),
801 });
802 }
803 let module_yaml = std::fs::read_to_string(&module_yaml_path)?;
804
805 let config_blob = serde_json::to_vec(&serde_json::json!({
807 "moduleYaml": module_yaml,
808 }))?;
809
810 let layer_data = create_tar_gz(dir)?;
812
813 let platform_str = platform.map(String::from).unwrap_or_else(current_platform);
815
816 let config_digest = upload_blob(agent, oci_ref, auth, &config_blob, MEDIA_TYPE_MODULE_CONFIG)?;
818
819 let layer_digest = upload_blob(agent, oci_ref, auth, &layer_data, MEDIA_TYPE_MODULE_LAYER)?;
821
822 let mut annotations = HashMap::new();
824 annotations.insert("cfgd.io/platform".to_string(), platform_str);
825 annotations.insert(
826 "org.opencontainers.image.created".to_string(),
827 crate::utc_now_iso8601(),
828 );
829
830 let manifest = OciManifest {
831 schema_version: 2,
832 media_type: MEDIA_TYPE_OCI_MANIFEST.to_string(),
833 config: OciDescriptor {
834 media_type: MEDIA_TYPE_MODULE_CONFIG.to_string(),
835 digest: config_digest,
836 size: config_blob.len() as u64,
837 annotations: HashMap::new(),
838 },
839 layers: vec![OciDescriptor {
840 media_type: MEDIA_TYPE_MODULE_LAYER.to_string(),
841 digest: layer_digest,
842 size: layer_data.len() as u64,
843 annotations: HashMap::new(),
844 }],
845 annotations,
846 };
847
848 let manifest_json = serde_json::to_vec(&manifest)?;
849
850 let manifest_url = format!(
852 "{}/{}/manifests/{}",
853 oci_ref.api_base(),
854 oci_ref.repository,
855 oci_ref.reference_str(),
856 );
857
858 authenticated_request(
859 agent,
860 "PUT",
861 &manifest_url,
862 auth,
863 None,
864 Some(MEDIA_TYPE_OCI_MANIFEST),
865 Some(&manifest_json),
866 )
867 .map_err(|e| OciError::ManifestPushFailed {
868 message: format!("{e}"),
869 })?;
870
871 let manifest_size = manifest_json.len() as u64;
872 let manifest_digest = sha256_digest(&manifest_json);
873 tracing::info!(
874 reference = %oci_ref,
875 digest = %manifest_digest,
876 "module pushed"
877 );
878
879 Ok((manifest_digest, manifest_size))
880}
881
882const MEDIA_TYPE_OCI_INDEX: &str = "application/vnd.oci.image.index.v1+json";
887
888#[derive(Debug, Serialize, Deserialize)]
889#[serde(rename_all = "camelCase")]
890struct OciIndex {
891 schema_version: u32,
892 media_type: String,
893 manifests: Vec<OciPlatformManifest>,
894}
895
896#[derive(Debug, Serialize, Deserialize)]
897#[serde(rename_all = "camelCase")]
898struct OciPlatformManifest {
899 media_type: String,
900 digest: String,
901 size: u64,
902 platform: OciPlatform,
903}
904
905#[derive(Debug, Serialize, Deserialize)]
906struct OciPlatform {
907 os: String,
908 architecture: String,
909}
910
911pub fn rust_arch_to_oci(arch: &str) -> &str {
913 match arch {
914 "x86_64" => "amd64",
915 "aarch64" => "arm64",
916 "arm" => "arm",
917 "s390x" => "s390x",
918 "powerpc64" => "ppc64le",
919 other => other,
920 }
921}
922
923pub fn current_platform() -> String {
925 format!(
926 "{}/{}",
927 std::env::consts::OS,
928 rust_arch_to_oci(std::env::consts::ARCH)
929 )
930}
931
932pub fn parse_platform_target(target: &str) -> Result<(&str, &str), OciError> {
934 target.split_once('/').ok_or_else(|| OciError::BuildError {
935 message: format!(
936 "invalid platform target '{target}' — expected os/arch (e.g. linux/amd64)"
937 ),
938 })
939}
940
941pub fn push_module_multiplatform(
946 builds: &[(&Path, &str)],
947 artifact_ref: &str,
948 printer: Option<&Printer>,
949) -> Result<String, OciError> {
950 let oci_ref = OciReference::parse(artifact_ref)?;
951 let auth = RegistryAuth::resolve(&oci_ref.registry);
952 let agent = ureq::AgentBuilder::new()
953 .timeout(std::time::Duration::from_secs(300))
954 .build();
955
956 let spinner = printer.map(|p| {
957 p.spinner(&format!(
958 "Pushing multi-platform module to {artifact_ref}..."
959 ))
960 });
961
962 let mut platform_manifests = Vec::new();
963
964 for (dir, platform) in builds {
965 let (os, arch) = parse_platform_target(platform)?;
966
967 let platform_tag = format!("{}-{}", oci_ref.reference_str(), platform.replace('/', "-"));
969 let platform_ref = OciReference {
970 registry: oci_ref.registry.clone(),
971 repository: oci_ref.repository.clone(),
972 reference: ReferenceKind::Tag(platform_tag),
973 };
974
975 let (digest, size) =
976 push_module_inner(&agent, dir, &platform_ref, auth.as_ref(), Some(platform))?;
977
978 platform_manifests.push(OciPlatformManifest {
979 media_type: MEDIA_TYPE_OCI_MANIFEST.to_string(),
980 digest,
981 size,
982 platform: OciPlatform {
983 os: os.to_string(),
984 architecture: arch.to_string(),
985 },
986 });
987 }
988
989 let index = OciIndex {
991 schema_version: 2,
992 media_type: MEDIA_TYPE_OCI_INDEX.to_string(),
993 manifests: platform_manifests,
994 };
995 let index_json = serde_json::to_vec(&index)?;
996
997 let index_url = format!(
998 "{}/{}/manifests/{}",
999 oci_ref.api_base(),
1000 oci_ref.repository,
1001 oci_ref.reference_str(),
1002 );
1003
1004 authenticated_request(
1005 &agent,
1006 "PUT",
1007 &index_url,
1008 auth.as_ref(),
1009 None,
1010 Some(MEDIA_TYPE_OCI_INDEX),
1011 Some(&index_json),
1012 )
1013 .map_err(|e| OciError::ManifestPushFailed {
1014 message: format!("index push failed: {e}"),
1015 })?;
1016
1017 let index_digest = sha256_digest(&index_json);
1018
1019 if let Some(s) = spinner {
1020 s.finish_and_clear();
1021 }
1022
1023 tracing::info!(
1024 reference = %oci_ref,
1025 digest = %index_digest,
1026 platforms = builds.len(),
1027 "multi-platform module pushed"
1028 );
1029
1030 Ok(index_digest)
1031}
1032
1033pub fn detect_container_runtime() -> Option<&'static str> {
1039 if crate::command_available("docker") {
1040 Some("docker")
1041 } else if crate::command_available("podman") {
1042 Some("podman")
1043 } else {
1044 None
1045 }
1046}
1047
1048fn detect_pkg_install_cmd(base_image: &str) -> &'static str {
1050 let lower = base_image.to_ascii_lowercase();
1051 if lower.starts_with("alpine") || lower.contains("/alpine") {
1052 "apk add --no-cache"
1053 } else if lower.starts_with("fedora")
1054 || lower.contains("/fedora")
1055 || lower.starts_with("rockylinux")
1056 || lower.contains("/rockylinux")
1057 || lower.starts_with("almalinux")
1058 || lower.contains("/almalinux")
1059 {
1060 "dnf install -y"
1061 } else if lower.starts_with("centos") || lower.contains("/centos") {
1062 "yum install -y"
1063 } else if lower.starts_with("archlinux") || lower.contains("/archlinux") {
1064 "pacman -Sy --noconfirm"
1065 } else {
1066 "apt-get update && apt-get install -y"
1068 }
1069}
1070
1071fn build_dockerfile(base_image: &str, packages: &[&str]) -> String {
1073 let mut lines = vec![format!("FROM {base_image}")];
1074 if !packages.is_empty() {
1075 let pkg_list = packages.join(" ");
1076 let install_cmd = detect_pkg_install_cmd(base_image);
1077 if install_cmd.starts_with("apt-get") {
1078 lines.push(format!(
1079 "RUN {install_cmd} {pkg_list} && rm -rf /var/lib/apt/lists/*"
1080 ));
1081 } else {
1082 lines.push(format!("RUN {install_cmd} {pkg_list}"));
1083 }
1084 }
1085 lines.push("WORKDIR /build".to_string());
1086 lines.push("COPY . /build/".to_string());
1087 lines.join("\n")
1088}
1089
1090pub fn build_module(
1097 dir: &Path,
1098 target_platform: Option<&str>,
1099 base_image: Option<&str>,
1100) -> Result<std::path::PathBuf, OciError> {
1101 let module_yaml_path = dir.join("module.yaml");
1102 if !module_yaml_path.exists() {
1103 return Err(OciError::ModuleYamlNotFound {
1104 dir: dir.to_path_buf(),
1105 });
1106 }
1107
1108 let runtime = detect_container_runtime().ok_or(OciError::ToolNotFound {
1109 tool: "docker or podman".to_string(),
1110 })?;
1111
1112 let module_yaml = std::fs::read_to_string(&module_yaml_path)?;
1113 let module_doc =
1114 crate::config::parse_module(&module_yaml).map_err(|e| OciError::BuildError {
1115 message: format!("invalid module.yaml: {e}"),
1116 })?;
1117
1118 let pkg_names: Vec<String> = module_doc
1120 .spec
1121 .packages
1122 .iter()
1123 .map(|p| p.name.clone())
1124 .collect();
1125 let packages: Vec<&str> = pkg_names.iter().map(|s| s.as_str()).collect();
1126
1127 let base = base_image.unwrap_or("ubuntu:22.04");
1128 let dockerfile_content = build_dockerfile(base, &packages);
1129
1130 let build_dir = tempfile::tempdir().map_err(|e| OciError::BuildError {
1133 message: format!("cannot create temp dir: {e}"),
1134 })?;
1135 crate::copy_dir_recursive(dir, build_dir.path())?;
1136 std::fs::write(build_dir.path().join("Dockerfile"), &dockerfile_content)?;
1137
1138 let tag = format!(
1140 "cfgd-build-{}:{}",
1141 module_doc.metadata.name,
1142 std::process::id(),
1143 );
1144 let container_name = format!(
1145 "cfgd-build-{}-{}",
1146 std::process::id(),
1147 crate::utc_now_iso8601().replace([':', '-', 'T', 'Z'], ""),
1148 );
1149
1150 let mut build_cmd = std::process::Command::new(runtime);
1151 build_cmd.arg("build").arg("-t").arg(&tag);
1152
1153 if let Some(platform) = target_platform {
1154 build_cmd.arg("--platform").arg(platform);
1155 }
1156
1157 build_cmd
1158 .arg("-f")
1159 .arg(build_dir.path().join("Dockerfile"))
1160 .arg(build_dir.path());
1161
1162 let build_output = build_cmd.output().map_err(|e| OciError::BuildError {
1163 message: format!("{runtime} build failed: {e}"),
1164 })?;
1165
1166 if !build_output.status.success() {
1167 return Err(OciError::BuildError {
1168 message: format!(
1169 "{runtime} build failed:\n{}",
1170 crate::stderr_lossy_trimmed(&build_output)
1171 ),
1172 });
1173 }
1174
1175 let output_dir = tempfile::tempdir().map_err(|e| OciError::BuildError {
1177 message: format!("cannot create output dir: {e}"),
1178 })?;
1179
1180 let create_output = std::process::Command::new(runtime)
1181 .args(["create", "--name", &container_name, &tag])
1182 .output()
1183 .map_err(|e| OciError::BuildError {
1184 message: format!("container create failed: {e}"),
1185 })?;
1186
1187 if !create_output.status.success() {
1188 return Err(OciError::BuildError {
1189 message: format!(
1190 "container create failed: {}",
1191 crate::stderr_lossy_trimmed(&create_output)
1192 ),
1193 });
1194 }
1195
1196 let cp_output = std::process::Command::new(runtime)
1198 .args([
1199 "cp",
1200 &format!("{container_name}:/build/."),
1201 &output_dir.path().display().to_string(),
1202 ])
1203 .output()
1204 .map_err(|e| OciError::BuildError {
1205 message: format!("container cp failed: {e}"),
1206 })?;
1207
1208 let _ = std::process::Command::new(runtime)
1210 .args(["rm", "-f", &container_name])
1211 .output();
1212 let _ = std::process::Command::new(runtime)
1213 .args(["rmi", "-f", &tag])
1214 .output();
1215
1216 if !cp_output.status.success() {
1217 return Err(OciError::BuildError {
1218 message: format!(
1219 "container cp failed: {}",
1220 crate::stderr_lossy_trimmed(&cp_output)
1221 ),
1222 });
1223 }
1224
1225 let out = output_dir.path().to_path_buf();
1226 let _keep = output_dir.keep();
1227 tracing::info!(output = %out.display(), "module built");
1228 Ok(out)
1229}
1230
1231pub fn sign_artifact(artifact_ref: &str, key_path: Option<&str>) -> Result<(), OciError> {
1240 if !crate::command_available("cosign") {
1241 return Err(OciError::ToolNotFound {
1242 tool: "cosign".to_string(),
1243 });
1244 }
1245
1246 let mut cmd = std::process::Command::new("cosign");
1247 cmd.arg("sign");
1248
1249 if let Some(key) = key_path {
1250 cmd.arg("--key").arg(key);
1251 } else {
1252 cmd.arg("--yes");
1253 }
1254
1255 cmd.arg(artifact_ref);
1256
1257 let output = cmd.output().map_err(|e| OciError::SigningError {
1258 message: format!("failed to run cosign: {e}"),
1259 })?;
1260
1261 if !output.status.success() {
1262 return Err(OciError::SigningError {
1263 message: format!(
1264 "cosign sign failed: {}",
1265 crate::stderr_lossy_trimmed(&output)
1266 ),
1267 });
1268 }
1269
1270 tracing::info!(reference = artifact_ref, "artifact signed with cosign");
1271 Ok(())
1272}
1273
1274pub struct VerifyOptions<'a> {
1276 pub key: Option<&'a str>,
1278 pub identity: Option<&'a str>,
1280 pub issuer: Option<&'a str>,
1282}
1283
1284fn validate_verify_options(opts: &VerifyOptions<'_>) -> Result<(), OciError> {
1286 if opts.key.is_none() && opts.identity.is_none() && opts.issuer.is_none() {
1287 return Err(OciError::VerificationFailed {
1288 reference: String::new(),
1289 message: "keyless verification requires identity or issuer constraint (use --key, or provide VerifyOptions.identity/issuer)".to_string(),
1290 });
1291 }
1292 Ok(())
1293}
1294
1295fn apply_verify_args(cmd: &mut std::process::Command, opts: &VerifyOptions<'_>) {
1297 if let Some(key) = opts.key {
1298 cmd.arg("--key").arg(key);
1299 } else {
1300 let identity = opts.identity.unwrap_or(".*");
1301 let issuer = opts.issuer.unwrap_or(".*");
1302 cmd.arg("--certificate-identity-regexp").arg(identity);
1303 cmd.arg("--certificate-oidc-issuer-regexp").arg(issuer);
1304 }
1305}
1306
1307pub fn verify_signature(artifact_ref: &str, opts: &VerifyOptions<'_>) -> Result<(), OciError> {
1312 validate_verify_options(opts)?;
1313
1314 if !crate::command_available("cosign") {
1315 return Err(OciError::ToolNotFound {
1316 tool: "cosign".to_string(),
1317 });
1318 }
1319
1320 let mut cmd = std::process::Command::new("cosign");
1321 cmd.arg("verify");
1322 apply_verify_args(&mut cmd, opts);
1323 cmd.arg(artifact_ref);
1324
1325 let output = cmd.output().map_err(|e| OciError::VerificationFailed {
1326 reference: artifact_ref.to_string(),
1327 message: format!("failed to run cosign: {e}"),
1328 })?;
1329
1330 if !output.status.success() {
1331 return Err(OciError::VerificationFailed {
1332 reference: artifact_ref.to_string(),
1333 message: format!(
1334 "cosign verify failed: {}",
1335 crate::stderr_lossy_trimmed(&output)
1336 ),
1337 });
1338 }
1339
1340 tracing::info!(reference = artifact_ref, "signature verified");
1341 Ok(())
1342}
1343
1344pub fn generate_slsa_provenance(
1350 artifact_ref: &str,
1351 digest: &str,
1352 source_repo: &str,
1353 source_commit: &str,
1354) -> Result<String, OciError> {
1355 let now = crate::utc_now_iso8601();
1356 serde_json::to_string_pretty(&serde_json::json!({
1357 "_type": "https://in-toto.io/Statement/v1",
1358 "predicateType": "https://slsa.dev/provenance/v1",
1359 "subject": [{
1360 "name": artifact_ref,
1361 "digest": {
1362 "sha256": digest.strip_prefix("sha256:").unwrap_or(digest),
1363 }
1364 }],
1365 "predicate": {
1366 "buildDefinition": {
1367 "buildType": "https://cfgd.io/ModuleBuild/v1",
1368 "externalParameters": {
1369 "source": {
1370 "uri": source_repo,
1371 "digest": { "gitCommit": source_commit },
1372 }
1373 },
1374 },
1375 "runDetails": {
1376 "builder": {
1377 "id": "https://cfgd.io/builder/v1",
1378 },
1379 "metadata": {
1380 "invocationId": &now,
1381 "startedOn": &now,
1382 }
1383 }
1384 }
1385 }))
1386 .map_err(|e| OciError::AttestationError {
1387 message: format!("failed to serialize SLSA provenance: {e}"),
1388 })
1389}
1390
1391pub fn attach_attestation(
1393 artifact_ref: &str,
1394 attestation_path: &str,
1395 key_path: Option<&str>,
1396) -> Result<(), OciError> {
1397 if !crate::command_available("cosign") {
1398 return Err(OciError::ToolNotFound {
1399 tool: "cosign".to_string(),
1400 });
1401 }
1402
1403 let mut cmd = std::process::Command::new("cosign");
1404 cmd.arg("attest");
1405
1406 if let Some(key) = key_path {
1407 cmd.arg("--key").arg(key);
1408 } else {
1409 cmd.arg("--yes");
1410 }
1411
1412 cmd.arg("--predicate")
1413 .arg(attestation_path)
1414 .arg("--type")
1415 .arg("slsaprovenance")
1416 .arg(artifact_ref);
1417
1418 let output = cmd.output().map_err(|e| OciError::AttestationError {
1419 message: format!("failed to run cosign attest: {e}"),
1420 })?;
1421
1422 if !output.status.success() {
1423 return Err(OciError::AttestationError {
1424 message: format!(
1425 "cosign attest failed: {}",
1426 crate::stderr_lossy_trimmed(&output)
1427 ),
1428 });
1429 }
1430
1431 tracing::info!(reference = artifact_ref, "attestation attached");
1432 Ok(())
1433}
1434
1435pub fn verify_attestation(
1437 artifact_ref: &str,
1438 predicate_type: &str,
1439 opts: &VerifyOptions<'_>,
1440) -> Result<(), OciError> {
1441 validate_verify_options(opts)?;
1442
1443 if !crate::command_available("cosign") {
1444 return Err(OciError::ToolNotFound {
1445 tool: "cosign".to_string(),
1446 });
1447 }
1448
1449 let mut cmd = std::process::Command::new("cosign");
1450 cmd.arg("verify-attestation");
1451 apply_verify_args(&mut cmd, opts);
1452 cmd.arg("--type").arg(predicate_type).arg(artifact_ref);
1453
1454 let output = cmd.output().map_err(|e| OciError::AttestationError {
1455 message: format!("failed to run cosign verify-attestation: {e}"),
1456 })?;
1457
1458 if !output.status.success() {
1459 return Err(OciError::AttestationError {
1460 message: format!(
1461 "attestation verification failed: {}",
1462 crate::stderr_lossy_trimmed(&output)
1463 ),
1464 });
1465 }
1466
1467 tracing::info!(reference = artifact_ref, "attestation verified");
1468 Ok(())
1469}
1470
1471pub fn pull_module(
1480 artifact_ref: &str,
1481 output_dir: &Path,
1482 require_signature: bool,
1483 printer: Option<&Printer>,
1484) -> Result<(), OciError> {
1485 let oci_ref = OciReference::parse(artifact_ref)?;
1486 let auth = RegistryAuth::resolve(&oci_ref.registry);
1487 let agent = ureq::AgentBuilder::new()
1488 .timeout(std::time::Duration::from_secs(300))
1489 .build();
1490
1491 let spinner = printer.map(|p| p.spinner(&format!("Pulling module from {artifact_ref}...")));
1492
1493 if require_signature {
1495 check_signature_exists(&agent, &oci_ref, auth.as_ref())?;
1496 }
1497
1498 let manifest_url = format!(
1500 "{}/{}/manifests/{}",
1501 oci_ref.api_base(),
1502 oci_ref.repository,
1503 oci_ref.reference_str(),
1504 );
1505
1506 let resp = authenticated_request(
1507 &agent,
1508 "GET",
1509 &manifest_url,
1510 auth.as_ref(),
1511 Some(MEDIA_TYPE_OCI_MANIFEST),
1512 None,
1513 None,
1514 )
1515 .map_err(|e| OciError::ManifestNotFound {
1516 reference: format!("{}: {e}", oci_ref),
1517 })?;
1518
1519 let manifest_body = resp.into_string().map_err(|e| OciError::RequestFailed {
1520 message: format!("cannot read manifest body: {e}"),
1521 })?;
1522 let manifest: OciManifest =
1523 serde_json::from_str(&manifest_body).map_err(|e| OciError::RequestFailed {
1524 message: format!("invalid manifest JSON: {e}"),
1525 })?;
1526
1527 let layer = manifest
1529 .layers
1530 .first()
1531 .ok_or_else(|| OciError::RequestFailed {
1532 message: "manifest has no layers".to_string(),
1533 })?;
1534
1535 let blob_url = format!(
1537 "{}/{}/blobs/{}",
1538 oci_ref.api_base(),
1539 oci_ref.repository,
1540 layer.digest,
1541 );
1542
1543 let resp = authenticated_request(
1544 &agent,
1545 "GET",
1546 &blob_url,
1547 auth.as_ref(),
1548 Some("application/octet-stream"),
1549 None,
1550 None,
1551 )
1552 .map_err(|e| OciError::BlobNotFound {
1553 digest: format!("{}: {e}", layer.digest),
1554 })?;
1555
1556 const MAX_BLOB_SIZE: u64 = 512 * 1024 * 1024;
1558 if layer.size > MAX_BLOB_SIZE {
1559 return Err(OciError::RequestFailed {
1560 message: format!(
1561 "layer size {} exceeds maximum allowed size ({} bytes)",
1562 layer.size, MAX_BLOB_SIZE
1563 ),
1564 });
1565 }
1566 let mut blob_data = Vec::with_capacity(layer.size as usize);
1567 resp.into_reader()
1568 .take(MAX_BLOB_SIZE + 1024)
1569 .read_to_end(&mut blob_data)?;
1570
1571 let actual_digest = sha256_digest(&blob_data);
1573 if actual_digest != layer.digest {
1574 return Err(OciError::RequestFailed {
1575 message: format!(
1576 "layer digest mismatch: expected {}, got {}",
1577 layer.digest, actual_digest
1578 ),
1579 });
1580 }
1581
1582 extract_tar_gz(&blob_data, output_dir)?;
1584
1585 if let Some(s) = spinner {
1586 s.finish_and_clear();
1587 }
1588
1589 tracing::info!(
1590 reference = %oci_ref,
1591 output = %output_dir.display(),
1592 "module pulled"
1593 );
1594
1595 Ok(())
1596}
1597
1598fn check_signature_exists(
1601 agent: &ureq::Agent,
1602 oci_ref: &OciReference,
1603 auth: Option<&RegistryAuth>,
1604) -> Result<(), OciError> {
1605 let sig_tag = match &oci_ref.reference {
1606 ReferenceKind::Tag(tag) => format!("{tag}.sig"),
1607 ReferenceKind::Digest(digest) => {
1608 digest.replace(':', "-") + ".sig"
1610 }
1611 };
1612
1613 let sig_url = format!(
1614 "{}/{}/manifests/{}",
1615 oci_ref.api_base(),
1616 oci_ref.repository,
1617 sig_tag,
1618 );
1619
1620 let result = authenticated_request(agent, "HEAD", &sig_url, auth, None, None, None);
1621
1622 match result {
1623 Ok(_) => Ok(()),
1624 Err(_) => Err(OciError::SignatureRequired {
1625 reference: oci_ref.to_string(),
1626 }),
1627 }
1628}
1629
1630fn base64_encode(data: &[u8]) -> String {
1635 const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1636 let mut result = String::new();
1637 for chunk in data.chunks(3) {
1638 let b0 = chunk[0] as u32;
1639 let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
1640 let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
1641 let triple = (b0 << 16) | (b1 << 8) | b2;
1642
1643 result.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
1644 result.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);
1645
1646 if chunk.len() > 1 {
1647 result.push(CHARS[((triple >> 6) & 0x3F) as usize] as char);
1648 } else {
1649 result.push('=');
1650 }
1651
1652 if chunk.len() > 2 {
1653 result.push(CHARS[(triple & 0x3F) as usize] as char);
1654 } else {
1655 result.push('=');
1656 }
1657 }
1658 result
1659}
1660
1661fn base64_decode(input: &str) -> Option<Vec<u8>> {
1662 fn char_val(c: u8) -> Option<u8> {
1663 match c {
1664 b'A'..=b'Z' => Some(c - b'A'),
1665 b'a'..=b'z' => Some(c - b'a' + 26),
1666 b'0'..=b'9' => Some(c - b'0' + 52),
1667 b'+' => Some(62),
1668 b'/' => Some(63),
1669 b'=' => Some(0),
1670 _ => None,
1671 }
1672 }
1673
1674 let input = input.trim();
1675 if input.is_empty() {
1676 return Some(Vec::new());
1677 }
1678
1679 let bytes = input.as_bytes();
1680 if !bytes.len().is_multiple_of(4) {
1681 return None;
1682 }
1683
1684 let mut result = Vec::with_capacity(bytes.len() * 3 / 4);
1685 for chunk in bytes.chunks(4) {
1686 let a = char_val(chunk[0])?;
1687 let b = char_val(chunk[1])?;
1688 let c = char_val(chunk[2])?;
1689 let d = char_val(chunk[3])?;
1690
1691 let triple = ((a as u32) << 18) | ((b as u32) << 12) | ((c as u32) << 6) | (d as u32);
1692
1693 result.push(((triple >> 16) & 0xFF) as u8);
1694 if chunk[2] != b'=' {
1695 result.push(((triple >> 8) & 0xFF) as u8);
1696 }
1697 if chunk[3] != b'=' {
1698 result.push((triple & 0xFF) as u8);
1699 }
1700 }
1701
1702 Some(result)
1703}
1704
1705#[cfg(test)]
1710mod tests {
1711 use super::*;
1712
1713 #[test]
1716 fn parse_full_reference_with_tag() {
1717 let r = OciReference::parse("ghcr.io/myorg/mymodule:v1.0.0").unwrap();
1718 assert_eq!(r.registry, "ghcr.io");
1719 assert_eq!(r.repository, "myorg/mymodule");
1720 assert_eq!(r.reference, ReferenceKind::Tag("v1.0.0".to_string()));
1721 }
1722
1723 #[test]
1724 fn parse_reference_with_digest() {
1725 let r = OciReference::parse(
1726 "ghcr.io/myorg/mymodule@sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
1727 )
1728 .unwrap();
1729 assert_eq!(r.registry, "ghcr.io");
1730 assert_eq!(r.repository, "myorg/mymodule");
1731 assert_eq!(
1732 r.reference,
1733 ReferenceKind::Digest(
1734 "sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
1735 .to_string()
1736 )
1737 );
1738 }
1739
1740 #[test]
1741 fn parse_reference_default_tag() {
1742 let r = OciReference::parse("ghcr.io/myorg/mymodule").unwrap();
1743 assert_eq!(r.registry, "ghcr.io");
1744 assert_eq!(r.repository, "myorg/mymodule");
1745 assert_eq!(r.reference, ReferenceKind::Tag("latest".to_string()));
1746 }
1747
1748 #[test]
1749 fn parse_reference_default_registry() {
1750 let r = OciReference::parse("myorg/mymodule:v2").unwrap();
1751 assert_eq!(r.registry, "docker.io");
1752 assert_eq!(r.repository, "myorg/mymodule");
1753 assert_eq!(r.reference, ReferenceKind::Tag("v2".to_string()));
1754 }
1755
1756 #[test]
1757 fn parse_reference_docker_library() {
1758 let r = OciReference::parse("ubuntu").unwrap();
1759 assert_eq!(r.registry, "docker.io");
1760 assert_eq!(r.repository, "library/ubuntu");
1761 assert_eq!(r.reference, ReferenceKind::Tag("latest".to_string()));
1762 }
1763
1764 #[test]
1765 fn parse_reference_localhost() {
1766 let r = OciReference::parse("localhost:5000/mymodule:dev").unwrap();
1767 assert_eq!(r.registry, "localhost:5000");
1768 assert_eq!(r.repository, "mymodule");
1769 assert_eq!(r.reference, ReferenceKind::Tag("dev".to_string()));
1770 }
1771
1772 #[test]
1773 fn parse_reference_nested_repo() {
1774 let r = OciReference::parse("registry.example.com/a/b/c:latest").unwrap();
1775 assert_eq!(r.registry, "registry.example.com");
1776 assert_eq!(r.repository, "a/b/c");
1777 assert_eq!(r.reference, ReferenceKind::Tag("latest".to_string()));
1778 }
1779
1780 #[test]
1781 fn parse_empty_reference_fails() {
1782 assert!(OciReference::parse("").is_err());
1783 }
1784
1785 #[test]
1786 fn reference_display() {
1787 let r = OciReference {
1788 registry: "ghcr.io".to_string(),
1789 repository: "myorg/mymod".to_string(),
1790 reference: ReferenceKind::Tag("v1".to_string()),
1791 };
1792 assert_eq!(r.to_string(), "ghcr.io/myorg/mymod:v1");
1793
1794 let r2 = OciReference {
1795 registry: "ghcr.io".to_string(),
1796 repository: "myorg/mymod".to_string(),
1797 reference: ReferenceKind::Digest("sha256:abc".to_string()),
1798 };
1799 assert_eq!(r2.to_string(), "ghcr.io/myorg/mymod@sha256:abc");
1800 }
1801
1802 #[test]
1803 fn api_base_https() {
1804 let r = OciReference::parse("ghcr.io/test/repo:v1").unwrap();
1805 assert_eq!(r.api_base(), "https://ghcr.io/v2");
1806 }
1807
1808 #[test]
1809 fn api_base_localhost_http() {
1810 let r = OciReference::parse("localhost:5000/test:v1").unwrap();
1811 assert!(r.api_base().starts_with("http://"));
1812 }
1813
1814 #[test]
1817 fn config_blob_round_trip() {
1818 let module_yaml = "apiVersion: cfgd.io/v1alpha1\nkind: Module\nmetadata:\n name: test\nspec:\n packages:\n - name: curl\n";
1819 let config_blob = serde_json::to_vec(&serde_json::json!({
1820 "moduleYaml": module_yaml,
1821 }))
1822 .unwrap();
1823
1824 let parsed: serde_json::Value = serde_json::from_slice(&config_blob).unwrap();
1825 assert_eq!(parsed["moduleYaml"].as_str().unwrap(), module_yaml);
1826 }
1827
1828 #[test]
1831 fn tar_gz_round_trip() {
1832 let dir = tempfile::tempdir().unwrap();
1833 let dir_path = dir.path();
1834
1835 std::fs::write(dir_path.join("module.yaml"), "name: test\n").unwrap();
1837 std::fs::create_dir(dir_path.join("files")).unwrap();
1838 std::fs::write(
1839 dir_path.join("files/config.toml"),
1840 "[section]\nkey = \"value\"\n",
1841 )
1842 .unwrap();
1843
1844 let archive = create_tar_gz(dir_path).unwrap();
1846 assert!(!archive.is_empty());
1847
1848 let out_dir = tempfile::tempdir().unwrap();
1850 extract_tar_gz(&archive, out_dir.path()).unwrap();
1851
1852 let module_yaml = std::fs::read_to_string(out_dir.path().join("module.yaml")).unwrap();
1854 assert_eq!(module_yaml, "name: test\n");
1855
1856 let config = std::fs::read_to_string(out_dir.path().join("files/config.toml")).unwrap();
1857 assert_eq!(config, "[section]\nkey = \"value\"\n");
1858 }
1859
1860 #[test]
1863 fn parse_docker_config_auth() {
1864 let config_json = r#"{
1865 "auths": {
1866 "ghcr.io": {
1867 "auth": "dXNlcjpwYXNz"
1868 }
1869 }
1870 }"#;
1871 let config: DockerConfig = serde_json::from_str(config_json).unwrap();
1872 let auth = resolve_from_docker_auths(&config.auths, "ghcr.io").unwrap();
1873 assert_eq!(auth.username, "user");
1874 assert_eq!(auth.password, "pass");
1875 }
1876
1877 #[test]
1878 fn parse_docker_config_with_url_key() {
1879 let config_json = r#"{
1880 "auths": {
1881 "https://index.docker.io/v1/": {
1882 "auth": "ZG9ja2VyOnNlY3JldA=="
1883 }
1884 }
1885 }"#;
1886 let config: DockerConfig = serde_json::from_str(config_json).unwrap();
1887 let auth = resolve_from_docker_auths(&config.auths, "docker.io").unwrap();
1888 assert_eq!(auth.username, "docker");
1889 assert_eq!(auth.password, "secret");
1890 }
1891
1892 #[test]
1893 fn parse_docker_config_no_auth() {
1894 let config_json = r#"{ "auths": {} }"#;
1895 let config: DockerConfig = serde_json::from_str(config_json).unwrap();
1896 let auth = resolve_from_docker_auths(&config.auths, "ghcr.io");
1897 assert!(auth.is_none());
1898 }
1899
1900 #[test]
1901 fn parse_docker_config_cred_helpers() {
1902 let config_json = r#"{
1903 "auths": {},
1904 "credHelpers": {
1905 "gcr.io": "gcloud",
1906 "ghcr.io": "gh"
1907 }
1908 }"#;
1909 let config: DockerConfig = serde_json::from_str(config_json).unwrap();
1910 assert_eq!(config.cred_helpers.get("gcr.io").unwrap(), "gcloud");
1911 assert_eq!(config.cred_helpers.get("ghcr.io").unwrap(), "gh");
1912 }
1913
1914 #[test]
1917 fn base64_encode_decode() {
1918 let original = "user:password";
1919 let encoded = base64_encode(original.as_bytes());
1920 let decoded = base64_decode(&encoded).unwrap();
1921 assert_eq!(String::from_utf8(decoded).unwrap(), original);
1922 }
1923
1924 #[test]
1925 fn base64_decode_known() {
1926 let decoded = base64_decode("dXNlcjpwYXNz").unwrap();
1928 assert_eq!(String::from_utf8(decoded).unwrap(), "user:pass");
1929 }
1930
1931 #[test]
1932 fn base64_empty() {
1933 assert_eq!(base64_encode(b""), "");
1934 assert_eq!(base64_decode("").unwrap(), Vec::<u8>::new());
1935 }
1936
1937 #[test]
1940 fn registry_auth_from_env() {
1941 let old_user = std::env::var("REGISTRY_USERNAME").ok();
1943 let old_pass = std::env::var("REGISTRY_PASSWORD").ok();
1944
1945 unsafe {
1947 std::env::set_var("REGISTRY_USERNAME", "testuser");
1948 std::env::set_var("REGISTRY_PASSWORD", "testpass");
1949 }
1950
1951 let auth = RegistryAuth::resolve("any-registry.io").unwrap();
1952 assert_eq!(auth.username, "testuser");
1953 assert_eq!(auth.password, "testpass");
1954
1955 unsafe {
1957 match old_user {
1958 Some(v) => std::env::set_var("REGISTRY_USERNAME", v),
1959 None => std::env::remove_var("REGISTRY_USERNAME"),
1960 }
1961 match old_pass {
1962 Some(v) => std::env::set_var("REGISTRY_PASSWORD", v),
1963 None => std::env::remove_var("REGISTRY_PASSWORD"),
1964 }
1965 }
1966 }
1967
1968 #[test]
1971 fn sha256_digest_known() {
1972 let digest = sha256_digest(b"hello");
1973 assert!(digest.starts_with("sha256:"));
1974 assert_eq!(digest.len(), 7 + 64); }
1976
1977 #[test]
1980 fn oci_manifest_serialization() {
1981 let manifest = OciManifest {
1982 schema_version: 2,
1983 media_type: MEDIA_TYPE_OCI_MANIFEST.to_string(),
1984 config: OciDescriptor {
1985 media_type: MEDIA_TYPE_MODULE_CONFIG.to_string(),
1986 digest: "sha256:abc123".to_string(),
1987 size: 100,
1988 annotations: HashMap::new(),
1989 },
1990 layers: vec![OciDescriptor {
1991 media_type: MEDIA_TYPE_MODULE_LAYER.to_string(),
1992 digest: "sha256:def456".to_string(),
1993 size: 2048,
1994 annotations: HashMap::new(),
1995 }],
1996 annotations: HashMap::new(),
1997 };
1998
1999 let json = serde_json::to_string(&manifest).unwrap();
2000 assert!(json.contains("schemaVersion"));
2001 assert!(json.contains(MEDIA_TYPE_MODULE_CONFIG));
2002 assert!(json.contains(MEDIA_TYPE_MODULE_LAYER));
2003
2004 let parsed: OciManifest = serde_json::from_str(&json).unwrap();
2006 assert_eq!(parsed.schema_version, 2);
2007 assert_eq!(parsed.config.digest, "sha256:abc123");
2008 assert_eq!(parsed.layers.len(), 1);
2009 assert_eq!(parsed.layers[0].digest, "sha256:def456");
2010 }
2011
2012 #[test]
2015 fn extract_auth_params() {
2016 let header = r#"Bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:myorg/mymod:pull""#;
2017 assert_eq!(
2018 extract_auth_param(header, "realm"),
2019 Some("https://ghcr.io/token")
2020 );
2021 assert_eq!(extract_auth_param(header, "service"), Some("ghcr.io"));
2022 assert_eq!(
2023 extract_auth_param(header, "scope"),
2024 Some("repository:myorg/mymod:pull")
2025 );
2026 assert_eq!(extract_auth_param(header, "nonexistent"), None);
2027 }
2028
2029 #[test]
2032 fn build_module_rejects_missing_module_yaml() {
2033 let dir = tempfile::tempdir().unwrap();
2034 let result = build_module(dir.path(), None, None);
2035 assert!(matches!(result, Err(OciError::ModuleYamlNotFound { .. })));
2036 }
2037
2038 #[test]
2039 fn detect_container_runtime_returns_option() {
2040 let rt = detect_container_runtime();
2041 if let Some(name) = rt {
2042 assert!(name == "docker" || name == "podman");
2043 }
2044 }
2045
2046 #[test]
2047 fn generate_build_dockerfile_content() {
2048 let dockerfile = build_dockerfile("ubuntu:22.04", &["curl", "wget"]);
2049 assert!(dockerfile.contains("FROM ubuntu:22.04"));
2050 assert!(dockerfile.contains("curl"));
2051 assert!(dockerfile.contains("wget"));
2052 assert!(dockerfile.contains("WORKDIR /build"));
2053 }
2054
2055 #[test]
2056 fn generate_build_dockerfile_no_packages() {
2057 let dockerfile = build_dockerfile("alpine:3.18", &[]);
2058 assert!(dockerfile.contains("FROM alpine:3.18"));
2059 assert!(!dockerfile.contains("apt-get"));
2060 }
2061
2062 #[test]
2065 fn oci_index_manifest_serialization() {
2066 let index = OciIndex {
2067 schema_version: 2,
2068 media_type: MEDIA_TYPE_OCI_INDEX.to_string(),
2069 manifests: vec![
2070 OciPlatformManifest {
2071 media_type: MEDIA_TYPE_OCI_MANIFEST.to_string(),
2072 digest: "sha256:abc".to_string(),
2073 size: 1024,
2074 platform: OciPlatform {
2075 os: "linux".to_string(),
2076 architecture: "amd64".to_string(),
2077 },
2078 },
2079 OciPlatformManifest {
2080 media_type: MEDIA_TYPE_OCI_MANIFEST.to_string(),
2081 digest: "sha256:def".to_string(),
2082 size: 2048,
2083 platform: OciPlatform {
2084 os: "linux".to_string(),
2085 architecture: "arm64".to_string(),
2086 },
2087 },
2088 ],
2089 };
2090 let json = serde_json::to_string(&index).unwrap();
2091 assert!(json.contains("schemaVersion"));
2092 assert!(json.contains("amd64"));
2093 assert!(json.contains("arm64"));
2094
2095 let parsed: OciIndex = serde_json::from_str(&json).unwrap();
2096 assert_eq!(parsed.manifests.len(), 2);
2097 }
2098
2099 #[test]
2100 fn parse_platform_target_valid() {
2101 let (os, arch) = parse_platform_target("linux/amd64").unwrap();
2102 assert_eq!(os, "linux");
2103 assert_eq!(arch, "amd64");
2104 }
2105
2106 #[test]
2107 fn parse_platform_target_invalid() {
2108 assert!(parse_platform_target("invalid").is_err());
2109 }
2110
2111 #[test]
2114 fn build_dockerfile_debian_default() {
2115 let df = build_dockerfile("ubuntu:22.04", &["curl", "jq"]);
2116 assert!(df.contains("FROM ubuntu:22.04"));
2117 assert!(df.contains("apt-get"));
2118 assert!(df.contains("curl jq"));
2119 assert!(df.contains("rm -rf /var/lib/apt/lists"));
2120 }
2121
2122 #[test]
2123 fn build_dockerfile_alpine() {
2124 let df = build_dockerfile("alpine:3.18", &["curl"]);
2125 assert!(df.contains("apk add --no-cache"));
2126 assert!(!df.contains("apt-get"));
2127 }
2128
2129 #[test]
2130 fn build_dockerfile_fedora() {
2131 let df = build_dockerfile("fedora:39", &["strace"]);
2132 assert!(df.contains("dnf install -y"));
2133 }
2134
2135 #[test]
2136 fn build_dockerfile_no_packages() {
2137 let df = build_dockerfile("ubuntu:22.04", &[]);
2138 assert!(!df.contains("RUN"));
2139 assert!(df.contains("WORKDIR /build"));
2140 }
2141
2142 #[test]
2145 fn sign_artifact_rejects_when_cosign_missing() {
2146 if crate::command_available("cosign") {
2147 return;
2148 }
2149 let result = sign_artifact("ghcr.io/test/mod:v1", None);
2150 assert!(matches!(result, Err(OciError::ToolNotFound { .. })));
2151 }
2152
2153 #[test]
2154 fn verify_signature_rejects_keyless_without_identity() {
2155 let result = verify_signature(
2156 "ghcr.io/test/mod:v1",
2157 &VerifyOptions {
2158 key: None,
2159 identity: None,
2160 issuer: None,
2161 },
2162 );
2163 assert!(matches!(result, Err(OciError::VerificationFailed { .. })));
2164 }
2165
2166 #[test]
2167 fn verify_signature_rejects_when_cosign_missing() {
2168 if crate::command_available("cosign") {
2169 return;
2170 }
2171 let result = verify_signature(
2172 "ghcr.io/test/mod:v1",
2173 &VerifyOptions {
2174 key: Some("cosign.pub"),
2175 identity: None,
2176 issuer: None,
2177 },
2178 );
2179 assert!(matches!(result, Err(OciError::ToolNotFound { .. })));
2180 }
2181
2182 #[test]
2185 fn attach_attestation_rejects_when_cosign_missing() {
2186 if crate::command_available("cosign") {
2187 return;
2188 }
2189 let result = attach_attestation("ghcr.io/test/mod:v1", "provenance.json", None);
2190 assert!(matches!(result, Err(OciError::ToolNotFound { .. })));
2191 }
2192
2193 #[test]
2194 fn verify_attestation_rejects_keyless_without_identity() {
2195 let result = verify_attestation(
2196 "ghcr.io/test/mod:v1",
2197 "slsaprovenance",
2198 &VerifyOptions {
2199 key: None,
2200 identity: None,
2201 issuer: None,
2202 },
2203 );
2204 assert!(matches!(result, Err(OciError::VerificationFailed { .. })));
2205 }
2206
2207 #[test]
2208 fn verify_attestation_rejects_when_cosign_missing() {
2209 if crate::command_available("cosign") {
2210 return;
2211 }
2212 let result = verify_attestation(
2213 "ghcr.io/test/mod:v1",
2214 "slsaprovenance",
2215 &VerifyOptions {
2216 key: Some("cosign.pub"),
2217 identity: None,
2218 issuer: None,
2219 },
2220 );
2221 assert!(matches!(result, Err(OciError::ToolNotFound { .. })));
2222 }
2223
2224 #[test]
2225 fn generate_slsa_provenance_creates_valid_json() {
2226 let prov = generate_slsa_provenance(
2227 "ghcr.io/test/mod:v1",
2228 "sha256:abc123",
2229 "https://github.com/myorg/myrepo",
2230 "abc123def",
2231 )
2232 .unwrap();
2233 let parsed: serde_json::Value = serde_json::from_str(&prov).unwrap();
2234 assert_eq!(parsed["predicateType"], "https://slsa.dev/provenance/v1");
2235 assert_eq!(parsed["subject"][0]["name"], "ghcr.io/test/mod:v1");
2236 assert_eq!(parsed["subject"][0]["digest"]["sha256"], "abc123");
2237 }
2238
2239 #[test]
2240 fn insecure_registry_env_var() {
2241 let prev = std::env::var("OCI_INSECURE_REGISTRIES").ok();
2243
2244 unsafe {
2246 std::env::set_var(
2247 "OCI_INSECURE_REGISTRIES",
2248 "myregistry:5000,other.local:8080",
2249 );
2250 }
2251 assert!(is_insecure_registry("myregistry:5000"));
2252 assert!(is_insecure_registry("other.local:8080"));
2253 assert!(!is_insecure_registry("ghcr.io"));
2254 assert!(!is_insecure_registry("myregistry:5001"));
2255
2256 let r = OciReference::parse("myregistry:5000/test/mod:v1").unwrap();
2258 assert!(r.api_base().starts_with("http://"));
2259
2260 let r2 = OciReference::parse("ghcr.io/test/mod:v1").unwrap();
2261 assert!(r2.api_base().starts_with("https://"));
2262
2263 unsafe {
2265 match prev {
2266 Some(v) => std::env::set_var("OCI_INSECURE_REGISTRIES", v),
2267 None => std::env::remove_var("OCI_INSECURE_REGISTRIES"),
2268 }
2269 }
2270 }
2271
2272 #[test]
2275 fn decode_docker_auth_valid() {
2276 let result = decode_docker_auth("dXNlcjpwYXNz");
2278 assert!(result.is_some());
2279 let auth = result.unwrap();
2280 assert_eq!(auth.username, "user");
2281 assert_eq!(auth.password, "pass");
2282 }
2283
2284 #[test]
2285 fn decode_docker_auth_no_colon() {
2286 let result = decode_docker_auth("bm9jb2xvbg=="); assert!(result.is_none());
2288 }
2289
2290 #[test]
2291 fn decode_docker_auth_empty_password() {
2292 let result = decode_docker_auth("dXNlcjo=");
2294 assert!(result.is_some());
2295 let auth = result.unwrap();
2296 assert_eq!(auth.username, "user");
2297 assert_eq!(auth.password, "");
2298 }
2299
2300 #[test]
2301 fn decode_docker_auth_invalid_base64() {
2302 let result = decode_docker_auth("!!!invalid!!!");
2303 assert!(result.is_none());
2304 }
2305
2306 #[test]
2307 fn decode_docker_auth_password_with_colons() {
2308 let result = decode_docker_auth("dXNlcjpwYTpzczp3b3Jk");
2310 assert!(result.is_some());
2311 let auth = result.unwrap();
2312 assert_eq!(auth.username, "user");
2313 assert_eq!(auth.password, "pa:ss:word");
2314 }
2315
2316 #[test]
2317 fn decode_docker_auth_empty_username_rejected() {
2318 let result = decode_docker_auth("OnBhc3N3b3Jk");
2320 assert!(result.is_none());
2321 }
2322
2323 #[test]
2326 fn rust_arch_to_oci_known() {
2327 assert_eq!(rust_arch_to_oci("x86_64"), "amd64");
2328 assert_eq!(rust_arch_to_oci("aarch64"), "arm64");
2329 assert_eq!(rust_arch_to_oci("arm"), "arm");
2330 assert_eq!(rust_arch_to_oci("s390x"), "s390x");
2331 assert_eq!(rust_arch_to_oci("powerpc64"), "ppc64le");
2332 }
2333
2334 #[test]
2335 fn rust_arch_to_oci_unknown_passes_through() {
2336 assert_eq!(rust_arch_to_oci("mips64"), "mips64");
2337 assert_eq!(rust_arch_to_oci("riscv64"), "riscv64");
2338 }
2339
2340 #[test]
2343 fn detect_pkg_install_cmd_centos() {
2344 assert_eq!(detect_pkg_install_cmd("centos:8"), "yum install -y");
2345 }
2346
2347 #[test]
2348 fn detect_pkg_install_cmd_archlinux() {
2349 assert_eq!(
2350 detect_pkg_install_cmd("archlinux:latest"),
2351 "pacman -Sy --noconfirm"
2352 );
2353 }
2354
2355 #[test]
2356 fn detect_pkg_install_cmd_unknown_defaults_to_apt() {
2357 let cmd = detect_pkg_install_cmd("someunknownimage:latest");
2358 assert!(
2359 cmd.contains("apt-get"),
2360 "unknown image should default to apt-get, got: {cmd}"
2361 );
2362 }
2363
2364 #[test]
2365 fn detect_pkg_install_cmd_rockylinux() {
2366 assert_eq!(detect_pkg_install_cmd("rockylinux:9"), "dnf install -y");
2367 }
2368
2369 #[test]
2370 fn detect_pkg_install_cmd_almalinux() {
2371 assert_eq!(detect_pkg_install_cmd("almalinux:8"), "dnf install -y");
2372 }
2373
2374 #[test]
2375 fn detect_pkg_install_cmd_fedora() {
2376 assert_eq!(detect_pkg_install_cmd("fedora:40"), "dnf install -y");
2377 }
2378
2379 #[test]
2380 fn detect_pkg_install_cmd_alpine_with_registry() {
2381 assert_eq!(
2382 detect_pkg_install_cmd("docker.io/library/alpine:3.19"),
2383 "apk add --no-cache"
2384 );
2385 }
2386
2387 #[test]
2390 fn resolve_from_docker_auths_tries_https_prefix() {
2391 let mut auths = HashMap::new();
2392 auths.insert(
2393 "https://registry.example.com".to_string(),
2394 DockerAuthEntry {
2395 auth: Some("dXNlcjpwYXNz".to_string()), },
2397 );
2398 let result = resolve_from_docker_auths(&auths, "registry.example.com");
2399 assert!(result.is_some());
2400 let auth = result.unwrap();
2401 assert_eq!(auth.username, "user");
2402 assert_eq!(auth.password, "pass");
2403 }
2404
2405 #[test]
2406 fn resolve_from_docker_auths_tries_v2_suffix() {
2407 let mut auths = HashMap::new();
2408 auths.insert(
2409 "https://myregistry.io/v2/".to_string(),
2410 DockerAuthEntry {
2411 auth: Some("dXNlcjpwYXNz".to_string()),
2412 },
2413 );
2414 let result = resolve_from_docker_auths(&auths, "myregistry.io");
2415 assert!(result.is_some());
2416 assert_eq!(result.unwrap().username, "user");
2417 }
2418
2419 #[test]
2420 fn resolve_from_docker_auths_no_match() {
2421 let mut auths = HashMap::new();
2422 auths.insert(
2423 "https://other.io".to_string(),
2424 DockerAuthEntry {
2425 auth: Some("dXNlcjpwYXNz".to_string()),
2426 },
2427 );
2428 let result = resolve_from_docker_auths(&auths, "registry.example.com");
2429 assert!(result.is_none());
2430 }
2431
2432 #[test]
2435 fn current_platform_returns_valid_format() {
2436 let platform = current_platform();
2437 assert!(
2438 platform.contains('/'),
2439 "platform should be os/arch format: {platform}"
2440 );
2441 let parts: Vec<&str> = platform.split('/').collect();
2442 assert_eq!(parts.len(), 2);
2443 assert!(!parts[0].is_empty());
2444 assert!(!parts[1].is_empty());
2445 }
2446
2447 #[test]
2450 fn parse_platform_target_three_parts_gives_arch_with_slash() {
2451 let result = parse_platform_target("linux/amd64/extra");
2453 assert!(result.is_ok());
2454 let (os, arch) = result.unwrap();
2455 assert_eq!(os, "linux");
2456 assert_eq!(arch, "amd64/extra");
2457 }
2458
2459 #[test]
2460 fn parse_platform_target_no_slash_fails() {
2461 let result = parse_platform_target("linuxamd64");
2462 assert!(
2463 matches!(result, Err(OciError::BuildError { .. })),
2464 "expected BuildError, got: {result:?}"
2465 );
2466 let err_msg = format!("{}", result.unwrap_err());
2467 assert!(
2468 err_msg.contains("invalid platform target"),
2469 "expected 'invalid platform target' message, got: {err_msg}"
2470 );
2471 }
2472
2473 #[test]
2476 fn sha256_digest_known_empty() {
2477 let digest = sha256_digest(b"");
2478 assert!(digest.starts_with("sha256:"));
2479 assert_eq!(digest.len(), 7 + 64); assert_eq!(
2481 digest,
2482 "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
2483 );
2484 }
2485
2486 #[test]
2487 fn sha256_digest_known_hello() {
2488 let digest = sha256_digest(b"hello");
2489 assert_eq!(
2491 digest,
2492 "sha256:2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
2493 );
2494 }
2495
2496 #[test]
2499 fn verify_options_default_keyless() {
2500 let opts = VerifyOptions {
2501 key: None,
2502 identity: Some("user@example.com"),
2503 issuer: Some("https://accounts.google.com"),
2504 };
2505 assert!(opts.key.is_none());
2506 assert_eq!(opts.identity.unwrap(), "user@example.com");
2507 assert_eq!(opts.issuer.unwrap(), "https://accounts.google.com");
2508 }
2509
2510 fn registry_from_url(url: &str) -> String {
2514 url.trim_start_matches("http://")
2515 .trim_start_matches("https://")
2516 .trim_end_matches('/')
2517 .to_string()
2518 }
2519
2520 fn create_test_module_dir() -> tempfile::TempDir {
2522 let dir = tempfile::tempdir().unwrap();
2523 std::fs::write(
2524 dir.path().join("module.yaml"),
2525 "apiVersion: cfgd.io/v1alpha1\nkind: Module\nmetadata:\n name: test-mod\nspec:\n packages:\n - name: curl\n",
2526 )
2527 .unwrap();
2528 std::fs::write(dir.path().join("README.md"), "# Test module\n").unwrap();
2529 dir
2530 }
2531
2532 #[test]
2533 fn upload_blob_posts_then_puts() {
2534 let mut server = mockito::Server::new();
2535 let registry = registry_from_url(&server.url());
2536
2537 let oci_ref = OciReference {
2538 registry: registry.clone(),
2539 repository: "test/mod".to_string(),
2540 reference: ReferenceKind::Tag("v1".to_string()),
2541 };
2542
2543 let data = b"hello blob data";
2544 let expected_digest = sha256_digest(data);
2545
2546 let head_mock = server
2548 .mock(
2549 "HEAD",
2550 mockito::Matcher::Regex(r"/v2/test/mod/blobs/sha256:.*".to_string()),
2551 )
2552 .with_status(404)
2553 .create();
2554
2555 let upload_location = format!("{}/v2/test/mod/blobs/uploads/some-uuid", server.url());
2557 let post_mock = server
2558 .mock("POST", "/v2/test/mod/blobs/uploads/")
2559 .with_status(202)
2560 .with_header("Location", &upload_location)
2561 .create();
2562
2563 let put_mock = server
2565 .mock(
2566 "PUT",
2567 mockito::Matcher::Regex(
2568 r"/v2/test/mod/blobs/uploads/some-uuid\?digest=sha256:.*".to_string(),
2569 ),
2570 )
2571 .with_status(201)
2572 .create();
2573
2574 let agent = ureq::AgentBuilder::new()
2575 .timeout(std::time::Duration::from_secs(10))
2576 .build();
2577
2578 let result = upload_blob(&agent, &oci_ref, None, data, "application/octet-stream");
2579 assert!(result.is_ok(), "upload_blob failed: {:?}", result.err());
2580 assert_eq!(result.unwrap(), expected_digest);
2581
2582 head_mock.assert();
2583 post_mock.assert();
2584 put_mock.assert();
2585 }
2586
2587 #[test]
2588 fn upload_blob_skips_when_already_exists() {
2589 let mut server = mockito::Server::new();
2590 let registry = registry_from_url(&server.url());
2591
2592 let oci_ref = OciReference {
2593 registry,
2594 repository: "test/mod".to_string(),
2595 reference: ReferenceKind::Tag("v1".to_string()),
2596 };
2597
2598 let data = b"existing blob";
2599 let expected_digest = sha256_digest(data);
2600
2601 let head_mock = server
2603 .mock(
2604 "HEAD",
2605 mockito::Matcher::Regex(r"/v2/test/mod/blobs/sha256:.*".to_string()),
2606 )
2607 .with_status(200)
2608 .create();
2609
2610 let agent = ureq::AgentBuilder::new()
2611 .timeout(std::time::Duration::from_secs(10))
2612 .build();
2613
2614 let result = upload_blob(&agent, &oci_ref, None, data, "application/octet-stream");
2615 assert!(result.is_ok());
2616 assert_eq!(result.unwrap(), expected_digest);
2617
2618 head_mock.assert();
2619 }
2621
2622 #[test]
2623 fn upload_blob_handles_relative_location() {
2624 let mut server = mockito::Server::new();
2625 let registry = registry_from_url(&server.url());
2626
2627 let oci_ref = OciReference {
2628 registry,
2629 repository: "test/mod".to_string(),
2630 reference: ReferenceKind::Tag("v1".to_string()),
2631 };
2632
2633 let data = b"blob with relative location";
2634
2635 server
2637 .mock(
2638 "HEAD",
2639 mockito::Matcher::Regex(r"/v2/test/mod/blobs/sha256:.*".to_string()),
2640 )
2641 .with_status(404)
2642 .create();
2643
2644 server
2646 .mock("POST", "/v2/test/mod/blobs/uploads/")
2647 .with_status(202)
2648 .with_header("Location", "/v2/test/mod/blobs/uploads/rel-uuid")
2649 .create();
2650
2651 let put_mock = server
2653 .mock(
2654 "PUT",
2655 mockito::Matcher::Regex(
2656 r"/v2/test/mod/blobs/uploads/rel-uuid\?digest=sha256:.*".to_string(),
2657 ),
2658 )
2659 .with_status(201)
2660 .create();
2661
2662 let agent = ureq::AgentBuilder::new()
2663 .timeout(std::time::Duration::from_secs(10))
2664 .build();
2665
2666 let result = upload_blob(&agent, &oci_ref, None, data, "application/octet-stream");
2667 assert!(
2668 result.is_ok(),
2669 "upload_blob with relative location failed: {:?}",
2670 result.err()
2671 );
2672 put_mock.assert();
2673 }
2674
2675 #[test]
2676 fn push_module_inner_uploads_blobs_and_manifest() {
2677 let mut server = mockito::Server::new();
2678 let registry = registry_from_url(&server.url());
2679
2680 let oci_ref = OciReference {
2681 registry,
2682 repository: "test/pushmod".to_string(),
2683 reference: ReferenceKind::Tag("v1".to_string()),
2684 };
2685
2686 let module_dir = create_test_module_dir();
2687
2688 server
2690 .mock(
2691 "HEAD",
2692 mockito::Matcher::Regex(r"/v2/test/pushmod/blobs/sha256:.*".to_string()),
2693 )
2694 .with_status(404)
2695 .expect_at_least(2)
2696 .create();
2697
2698 let upload_location = format!("{}/v2/test/pushmod/blobs/uploads/upload-id", server.url());
2700 server
2701 .mock("POST", "/v2/test/pushmod/blobs/uploads/")
2702 .with_status(202)
2703 .with_header("Location", &upload_location)
2704 .expect_at_least(2)
2705 .create();
2706
2707 server
2709 .mock(
2710 "PUT",
2711 mockito::Matcher::Regex(
2712 r"/v2/test/pushmod/blobs/uploads/upload-id\?digest=sha256:.*".to_string(),
2713 ),
2714 )
2715 .with_status(201)
2716 .expect_at_least(2)
2717 .create();
2718
2719 let manifest_mock = server
2721 .mock("PUT", "/v2/test/pushmod/manifests/v1")
2722 .with_status(201)
2723 .create();
2724
2725 let agent = ureq::AgentBuilder::new()
2726 .timeout(std::time::Duration::from_secs(10))
2727 .build();
2728
2729 let result = push_module_inner(
2730 &agent,
2731 module_dir.path(),
2732 &oci_ref,
2733 None,
2734 Some("linux/amd64"),
2735 );
2736 assert!(
2737 result.is_ok(),
2738 "push_module_inner failed: {:?}",
2739 result.err()
2740 );
2741
2742 let (digest, size) = result.unwrap();
2743 assert!(digest.starts_with("sha256:"));
2744 assert!(size > 0);
2745 manifest_mock.assert();
2746 }
2747
2748 #[test]
2749 fn push_module_inner_rejects_missing_module_yaml() {
2750 let dir = tempfile::tempdir().unwrap();
2751 let oci_ref = OciReference {
2754 registry: "localhost:9999".to_string(),
2755 repository: "test/mod".to_string(),
2756 reference: ReferenceKind::Tag("v1".to_string()),
2757 };
2758
2759 let agent = ureq::AgentBuilder::new().build();
2760 let result = push_module_inner(&agent, dir.path(), &oci_ref, None, None);
2761 assert!(matches!(result, Err(OciError::ModuleYamlNotFound { .. })));
2762 }
2763
2764 #[test]
2765 fn pull_module_downloads_and_verifies_digest() {
2766 let mut server = mockito::Server::new();
2767 let registry = registry_from_url(&server.url());
2768
2769 let src_dir = create_test_module_dir();
2771 let layer_data = create_tar_gz(src_dir.path()).unwrap();
2772 let layer_digest = sha256_digest(&layer_data);
2773
2774 let config_blob = serde_json::to_vec(&serde_json::json!({
2776 "moduleYaml": "name: test",
2777 }))
2778 .unwrap();
2779 let config_digest = sha256_digest(&config_blob);
2780
2781 let manifest = serde_json::json!({
2782 "schemaVersion": 2,
2783 "mediaType": MEDIA_TYPE_OCI_MANIFEST,
2784 "config": {
2785 "mediaType": MEDIA_TYPE_MODULE_CONFIG,
2786 "digest": config_digest,
2787 "size": config_blob.len(),
2788 },
2789 "layers": [{
2790 "mediaType": MEDIA_TYPE_MODULE_LAYER,
2791 "digest": layer_digest,
2792 "size": layer_data.len(),
2793 }],
2794 });
2795
2796 server
2798 .mock("GET", "/v2/test/pullmod/manifests/v1")
2799 .with_status(200)
2800 .with_header("Content-Type", MEDIA_TYPE_OCI_MANIFEST)
2801 .with_body(serde_json::to_string(&manifest).unwrap())
2802 .create();
2803
2804 server
2806 .mock(
2807 "GET",
2808 mockito::Matcher::Regex(r"/v2/test/pullmod/blobs/sha256:.*".to_string()),
2809 )
2810 .with_status(200)
2811 .with_body(layer_data)
2812 .create();
2813
2814 let output_dir = tempfile::tempdir().unwrap();
2815 let artifact_ref = format!("{}/test/pullmod:v1", registry);
2816 let result = pull_module(&artifact_ref, output_dir.path(), false, None);
2817 assert!(result.is_ok(), "pull_module failed: {:?}", result.err());
2818
2819 assert!(output_dir.path().join("module.yaml").exists());
2821 assert!(output_dir.path().join("README.md").exists());
2822 }
2823
2824 #[test]
2825 fn pull_module_detects_digest_mismatch() {
2826 let mut server = mockito::Server::new();
2827 let registry = registry_from_url(&server.url());
2828
2829 let real_layer_data = b"real layer content";
2830 let fake_digest = "sha256:0000000000000000000000000000000000000000000000000000000000000000";
2832
2833 let manifest = serde_json::json!({
2834 "schemaVersion": 2,
2835 "mediaType": MEDIA_TYPE_OCI_MANIFEST,
2836 "config": {
2837 "mediaType": MEDIA_TYPE_MODULE_CONFIG,
2838 "digest": "sha256:cfgcfg",
2839 "size": 10,
2840 },
2841 "layers": [{
2842 "mediaType": MEDIA_TYPE_MODULE_LAYER,
2843 "digest": fake_digest,
2844 "size": real_layer_data.len(),
2845 }],
2846 });
2847
2848 server
2849 .mock("GET", "/v2/test/badmod/manifests/v1")
2850 .with_status(200)
2851 .with_body(serde_json::to_string(&manifest).unwrap())
2852 .create();
2853
2854 server
2855 .mock(
2856 "GET",
2857 mockito::Matcher::Regex(r"/v2/test/badmod/blobs/sha256:.*".to_string()),
2858 )
2859 .with_status(200)
2860 .with_body(real_layer_data.as_slice())
2861 .create();
2862
2863 let output_dir = tempfile::tempdir().unwrap();
2864 let artifact_ref = format!("{}/test/badmod:v1", registry);
2865 let result = pull_module(&artifact_ref, output_dir.path(), false, None);
2866 assert!(result.is_err());
2867 let err_msg = format!("{}", result.unwrap_err());
2868 assert!(
2869 err_msg.contains("digest mismatch"),
2870 "expected digest mismatch error, got: {err_msg}"
2871 );
2872 }
2873
2874 #[test]
2875 fn pull_module_checks_signature_when_required() {
2876 let mut server = mockito::Server::new();
2877 let registry = registry_from_url(&server.url());
2878
2879 server
2881 .mock("HEAD", "/v2/test/sigmod/manifests/v1.sig")
2882 .with_status(404)
2883 .create();
2884
2885 let output_dir = tempfile::tempdir().unwrap();
2886 let artifact_ref = format!("{}/test/sigmod:v1", registry);
2887 let result = pull_module(&artifact_ref, output_dir.path(), true, None);
2888 assert!(result.is_err());
2889 assert!(
2890 matches!(result, Err(OciError::SignatureRequired { .. })),
2891 "expected SignatureRequired, got: {:?}",
2892 result.err()
2893 );
2894 }
2895
2896 #[test]
2897 fn authenticated_request_handles_401_token_exchange() {
2898 let mut server = mockito::Server::new();
2899
2900 let token_url = format!("{}/token", server.url());
2901 let www_auth = format!(
2902 r#"Bearer realm="{token_url}",service="test.io",scope="repository:test/repo:pull""#,
2903 );
2904
2905 server
2907 .mock("GET", "/v2/test/repo/manifests/v1")
2908 .with_status(401)
2909 .with_header("Www-Authenticate", &www_auth)
2910 .expect(1)
2911 .create();
2912
2913 server
2915 .mock(
2916 "GET",
2917 mockito::Matcher::Regex(r"/token\?service=.*&scope=.*".to_string()),
2918 )
2919 .with_status(200)
2920 .with_body(r#"{"token":"my-bearer-token"}"#)
2921 .create();
2922
2923 server
2925 .mock("GET", "/v2/test/repo/manifests/v1")
2926 .match_header("Authorization", "Bearer my-bearer-token")
2927 .with_status(200)
2928 .with_body("manifest content")
2929 .create();
2930
2931 let agent = ureq::AgentBuilder::new()
2932 .timeout(std::time::Duration::from_secs(10))
2933 .build();
2934
2935 let url = format!("{}/v2/test/repo/manifests/v1", server.url());
2936 let result = authenticated_request(&agent, "GET", &url, None, None, None, None);
2937 assert!(
2938 result.is_ok(),
2939 "authenticated_request failed: {:?}",
2940 result.err()
2941 );
2942 }
2943
2944 #[test]
2945 fn authenticated_request_fails_on_401_without_bearer() {
2946 let mut server = mockito::Server::new();
2947
2948 server
2950 .mock("GET", "/v2/test/repo/manifests/v1")
2951 .with_status(401)
2952 .with_header("Www-Authenticate", "Basic realm=\"test\"")
2953 .create();
2954
2955 let agent = ureq::AgentBuilder::new()
2956 .timeout(std::time::Duration::from_secs(10))
2957 .build();
2958
2959 let url = format!("{}/v2/test/repo/manifests/v1", server.url());
2960 let result = authenticated_request(&agent, "GET", &url, None, None, None, None);
2961 assert!(result.is_err());
2962 let err_msg = format!("{}", result.unwrap_err());
2963 assert!(
2964 err_msg.contains("no Bearer challenge"),
2965 "expected no Bearer challenge error, got: {err_msg}"
2966 );
2967 }
2968
2969 #[test]
2970 fn authenticated_request_passes_basic_auth() {
2971 let mut server = mockito::Server::new();
2972
2973 let auth = RegistryAuth {
2974 username: "user".to_string(),
2975 password: "pass".to_string(),
2976 };
2977
2978 server
2979 .mock("GET", "/v2/test/repo/tags/list")
2980 .match_header(
2981 "Authorization",
2982 mockito::Matcher::Regex("Basic .*".to_string()),
2983 )
2984 .with_status(200)
2985 .with_body("{}")
2986 .create();
2987
2988 let agent = ureq::AgentBuilder::new()
2989 .timeout(std::time::Duration::from_secs(10))
2990 .build();
2991
2992 let url = format!("{}/v2/test/repo/tags/list", server.url());
2993 let result = authenticated_request(&agent, "GET", &url, Some(&auth), None, None, None);
2994 let resp = result.expect("authenticated request should succeed with basic auth");
2995 assert_eq!(resp.status(), 200);
2996 }
2997
2998 #[test]
2999 fn get_bearer_token_parses_www_authenticate() {
3000 let mut server = mockito::Server::new();
3001
3002 let www_auth = format!(
3003 r#"Bearer realm="{}/auth/token",service="registry.example.com",scope="repository:lib/test:pull,push""#,
3004 server.url()
3005 );
3006
3007 server
3008 .mock(
3009 "GET",
3010 mockito::Matcher::Regex(r"/auth/token\?service=.*&scope=.*".to_string()),
3011 )
3012 .with_status(200)
3013 .with_body(r#"{"token":"tok-abc-123"}"#)
3014 .create();
3015
3016 let agent = ureq::AgentBuilder::new()
3017 .timeout(std::time::Duration::from_secs(10))
3018 .build();
3019
3020 let result = get_bearer_token(&agent, &www_auth, None);
3021 assert!(
3022 result.is_ok(),
3023 "get_bearer_token failed: {:?}",
3024 result.err()
3025 );
3026 assert_eq!(result.unwrap(), "tok-abc-123");
3027 }
3028
3029 #[test]
3030 fn get_bearer_token_uses_access_token_field() {
3031 let mut server = mockito::Server::new();
3032
3033 let www_auth = format!(r#"Bearer realm="{}/token",service="svc""#, server.url());
3034
3035 server
3036 .mock(
3037 "GET",
3038 mockito::Matcher::Regex(r"/token\?service=.*".to_string()),
3039 )
3040 .with_status(200)
3041 .with_body(r#"{"access_token":"alt-token-456"}"#)
3042 .create();
3043
3044 let agent = ureq::AgentBuilder::new()
3045 .timeout(std::time::Duration::from_secs(10))
3046 .build();
3047
3048 let result = get_bearer_token(&agent, &www_auth, None);
3049 assert!(result.is_ok());
3050 assert_eq!(result.unwrap(), "alt-token-456");
3051 }
3052
3053 #[test]
3054 fn get_bearer_token_fails_without_realm() {
3055 let agent = ureq::AgentBuilder::new().build();
3056 let result = get_bearer_token(&agent, "Bearer service=\"svc\"", None);
3057 assert!(result.is_err());
3058 let err_msg = format!("{}", result.unwrap_err());
3059 assert!(
3060 err_msg.contains("missing realm"),
3061 "expected missing realm error, got: {err_msg}"
3062 );
3063 }
3064
3065 #[test]
3066 fn get_bearer_token_sends_basic_auth_when_provided() {
3067 let mut server = mockito::Server::new();
3068
3069 let www_auth = format!(r#"Bearer realm="{}/token",service="svc""#, server.url());
3070
3071 let auth = RegistryAuth {
3072 username: "user".to_string(),
3073 password: "pass".to_string(),
3074 };
3075
3076 server
3077 .mock(
3078 "GET",
3079 mockito::Matcher::Regex(r"/token\?service=.*".to_string()),
3080 )
3081 .match_header(
3082 "Authorization",
3083 mockito::Matcher::Regex("Basic .*".to_string()),
3084 )
3085 .with_status(200)
3086 .with_body(r#"{"token":"authed-token"}"#)
3087 .create();
3088
3089 let agent = ureq::AgentBuilder::new()
3090 .timeout(std::time::Duration::from_secs(10))
3091 .build();
3092
3093 let result = get_bearer_token(&agent, &www_auth, Some(&auth));
3094 assert!(result.is_ok());
3095 assert_eq!(result.unwrap(), "authed-token");
3096 }
3097
3098 #[test]
3099 fn check_signature_exists_ok_when_present() {
3100 let mut server = mockito::Server::new();
3101 let registry = registry_from_url(&server.url());
3102
3103 let oci_ref = OciReference {
3104 registry,
3105 repository: "test/sigexist".to_string(),
3106 reference: ReferenceKind::Tag("v1".to_string()),
3107 };
3108
3109 server
3110 .mock("HEAD", "/v2/test/sigexist/manifests/v1.sig")
3111 .with_status(200)
3112 .create();
3113
3114 let agent = ureq::AgentBuilder::new()
3115 .timeout(std::time::Duration::from_secs(10))
3116 .build();
3117
3118 let result = check_signature_exists(&agent, &oci_ref, None);
3119 assert_eq!(result.unwrap(), (), "signature check should return Ok(())");
3120 }
3121
3122 #[test]
3123 fn check_signature_exists_fails_when_missing() {
3124 let mut server = mockito::Server::new();
3125 let registry = registry_from_url(&server.url());
3126
3127 let oci_ref = OciReference {
3128 registry,
3129 repository: "test/nosig".to_string(),
3130 reference: ReferenceKind::Tag("v1".to_string()),
3131 };
3132
3133 server
3134 .mock("HEAD", "/v2/test/nosig/manifests/v1.sig")
3135 .with_status(404)
3136 .create();
3137
3138 let agent = ureq::AgentBuilder::new()
3139 .timeout(std::time::Duration::from_secs(10))
3140 .build();
3141
3142 let result = check_signature_exists(&agent, &oci_ref, None);
3143 assert!(matches!(result, Err(OciError::SignatureRequired { .. })));
3144 }
3145
3146 #[test]
3147 fn check_signature_exists_digest_reference() {
3148 let mut server = mockito::Server::new();
3149 let registry = registry_from_url(&server.url());
3150
3151 let oci_ref = OciReference {
3152 registry,
3153 repository: "test/digsig".to_string(),
3154 reference: ReferenceKind::Digest("sha256:abc123".to_string()),
3155 };
3156
3157 server
3159 .mock("HEAD", "/v2/test/digsig/manifests/sha256-abc123.sig")
3160 .with_status(200)
3161 .create();
3162
3163 let agent = ureq::AgentBuilder::new()
3164 .timeout(std::time::Duration::from_secs(10))
3165 .build();
3166
3167 let result = check_signature_exists(&agent, &oci_ref, None);
3168 assert_eq!(
3169 result.unwrap(),
3170 (),
3171 "digest-referenced signature check should return Ok(())"
3172 );
3173 }
3174
3175 #[test]
3176 fn authenticated_request_sends_body_with_put() {
3177 let mut server = mockito::Server::new();
3178
3179 let body_content = b"test manifest content";
3180 server
3181 .mock("PUT", "/v2/test/repo/manifests/v1")
3182 .match_header("Content-Type", "application/vnd.oci.image.manifest.v1+json")
3183 .match_body(mockito::Matcher::Any)
3184 .with_status(201)
3185 .create();
3186
3187 let agent = ureq::AgentBuilder::new()
3188 .timeout(std::time::Duration::from_secs(10))
3189 .build();
3190
3191 let url = format!("{}/v2/test/repo/manifests/v1", server.url());
3192 let result = authenticated_request(
3193 &agent,
3194 "PUT",
3195 &url,
3196 None,
3197 None,
3198 Some("application/vnd.oci.image.manifest.v1+json"),
3199 Some(body_content),
3200 );
3201 let resp = result.expect("PUT with body should succeed");
3202 assert_eq!(resp.status(), 201);
3203 }
3204
3205 #[test]
3206 fn authenticated_request_returns_error_on_500() {
3207 let mut server = mockito::Server::new();
3208
3209 server
3210 .mock("GET", "/v2/test/repo/tags/list")
3211 .with_status(500)
3212 .create();
3213
3214 let agent = ureq::AgentBuilder::new()
3215 .timeout(std::time::Duration::from_secs(10))
3216 .build();
3217
3218 let url = format!("{}/v2/test/repo/tags/list", server.url());
3219 let result = authenticated_request(&agent, "GET", &url, None, None, None, None);
3220 assert!(result.is_err());
3221 let err_msg = format!("{}", result.unwrap_err());
3222 assert!(
3223 err_msg.contains("HTTP 500"),
3224 "expected HTTP 500 error, got: {err_msg}"
3225 );
3226 }
3227
3228 #[test]
3231 fn tar_gz_round_trip_via_create_and_extract() {
3232 let dir = tempfile::tempdir().unwrap();
3233 let subdir = dir.path().join("module");
3234 std::fs::create_dir_all(&subdir).unwrap();
3235 std::fs::write(subdir.join("module.yaml"), "name: test").unwrap();
3236
3237 let archive = create_tar_gz(&subdir).unwrap();
3238 assert!(!archive.is_empty());
3239
3240 let output = tempfile::tempdir().unwrap();
3241 let result = extract_tar_gz(&archive, output.path());
3242 assert!(result.is_ok());
3243 assert!(output.path().join("module.yaml").exists());
3244 let content = std::fs::read_to_string(output.path().join("module.yaml")).unwrap();
3245 assert_eq!(content, "name: test");
3246 }
3247
3248 #[test]
3251 fn extract_tar_gz_prevents_path_traversal_via_dotdot() {
3252 let mut buf = Vec::new();
3256 {
3257 let encoder = flate2::write::GzEncoder::new(&mut buf, flate2::Compression::default());
3258 let mut archive = tar::Builder::new(encoder);
3259
3260 let data = b"malicious content";
3261 let mut header = tar::Header::new_gnu();
3262 header.set_path("placeholder.txt").unwrap();
3264 header.set_size(data.len() as u64);
3265 header.set_mode(0o644);
3266
3267 let path_bytes = b"../escaped.txt";
3269 header.as_mut_bytes()[..path_bytes.len()].copy_from_slice(path_bytes);
3270 header.as_mut_bytes()[path_bytes.len()] = 0; header.set_cksum();
3272
3273 archive.append(&header, &data[..]).unwrap();
3274
3275 let encoder = archive.into_inner().unwrap();
3276 encoder.finish().unwrap();
3277 }
3278
3279 let output = tempfile::tempdir().unwrap();
3280 let _ = extract_tar_gz(&buf, output.path());
3284
3285 let parent = output.path().parent().unwrap();
3287 assert!(
3288 !parent.join("escaped.txt").exists(),
3289 "path traversal file should not have been created outside output dir"
3290 );
3291
3292 assert!(
3294 !output.path().join("escaped.txt").exists(),
3295 "traversal entry should not have been extracted"
3296 );
3297 }
3298
3299 #[test]
3300 fn extract_tar_gz_skips_symlinks() {
3301 let mut buf = Vec::new();
3303 {
3304 let encoder = flate2::write::GzEncoder::new(&mut buf, flate2::Compression::default());
3305 let mut archive = tar::Builder::new(encoder);
3306
3307 let data = b"normal file content";
3309 let mut header = tar::Header::new_gnu();
3310 header.set_path("normal.txt").unwrap();
3311 header.set_size(data.len() as u64);
3312 header.set_mode(0o644);
3313 header.set_cksum();
3314 archive.append(&header, &data[..]).unwrap();
3315
3316 let mut sym_header = tar::Header::new_gnu();
3318 sym_header.set_path("evil_link").unwrap();
3319 sym_header.set_entry_type(tar::EntryType::Symlink);
3320 sym_header.set_link_name("/etc/passwd").unwrap();
3321 sym_header.set_size(0);
3322 sym_header.set_mode(0o777);
3323 sym_header.set_cksum();
3324 archive.append(&sym_header, &[][..]).unwrap();
3325
3326 let encoder = archive.into_inner().unwrap();
3327 encoder.finish().unwrap();
3328 }
3329
3330 let output = tempfile::tempdir().unwrap();
3331 let result = extract_tar_gz(&buf, output.path());
3332 assert!(
3333 result.is_ok(),
3334 "extraction should succeed, skipping symlinks"
3335 );
3336
3337 let normal_content = std::fs::read_to_string(output.path().join("normal.txt")).unwrap();
3339 assert_eq!(normal_content, "normal file content");
3340
3341 assert!(
3343 !output.path().join("evil_link").exists(),
3344 "symlink entry should have been skipped during extraction"
3345 );
3346 }
3347
3348 #[test]
3349 fn extract_tar_gz_skips_hardlinks() {
3350 let mut buf = Vec::new();
3352 {
3353 let encoder = flate2::write::GzEncoder::new(&mut buf, flate2::Compression::default());
3354 let mut archive = tar::Builder::new(encoder);
3355
3356 let data = b"target content";
3358 let mut header = tar::Header::new_gnu();
3359 header.set_path("target.txt").unwrap();
3360 header.set_size(data.len() as u64);
3361 header.set_mode(0o644);
3362 header.set_cksum();
3363 archive.append(&header, &data[..]).unwrap();
3364
3365 let mut link_header = tar::Header::new_gnu();
3367 link_header.set_path("hardlink.txt").unwrap();
3368 link_header.set_entry_type(tar::EntryType::Link);
3369 link_header.set_link_name("target.txt").unwrap();
3370 link_header.set_size(0);
3371 link_header.set_mode(0o644);
3372 link_header.set_cksum();
3373 archive.append(&link_header, &[][..]).unwrap();
3374
3375 let encoder = archive.into_inner().unwrap();
3376 encoder.finish().unwrap();
3377 }
3378
3379 let output = tempfile::tempdir().unwrap();
3380 let result = extract_tar_gz(&buf, output.path());
3381 assert!(
3382 result.is_ok(),
3383 "extraction should succeed, skipping hardlinks"
3384 );
3385
3386 assert!(output.path().join("target.txt").exists());
3388 let content = std::fs::read_to_string(output.path().join("target.txt")).unwrap();
3389 assert_eq!(content, "target content");
3390
3391 assert!(
3393 !output.path().join("hardlink.txt").exists(),
3394 "hardlink entry should have been skipped during extraction"
3395 );
3396 }
3397
3398 #[test]
3399 fn extract_tar_gz_extracts_multiple_files_preserving_directories() {
3400 let mut buf = Vec::new();
3402 {
3403 let encoder = flate2::write::GzEncoder::new(&mut buf, flate2::Compression::default());
3404 let mut archive = tar::Builder::new(encoder);
3405
3406 let mut dir_header = tar::Header::new_gnu();
3408 dir_header.set_path("subdir/").unwrap();
3409 dir_header.set_entry_type(tar::EntryType::Directory);
3410 dir_header.set_size(0);
3411 dir_header.set_mode(0o755);
3412 dir_header.set_cksum();
3413 archive.append(&dir_header, &[][..]).unwrap();
3414
3415 let data1 = b"file one";
3417 let mut h1 = tar::Header::new_gnu();
3418 h1.set_path("subdir/one.txt").unwrap();
3419 h1.set_size(data1.len() as u64);
3420 h1.set_mode(0o644);
3421 h1.set_cksum();
3422 archive.append(&h1, &data1[..]).unwrap();
3423
3424 let data2 = b"file two";
3425 let mut h2 = tar::Header::new_gnu();
3426 h2.set_path("subdir/two.txt").unwrap();
3427 h2.set_size(data2.len() as u64);
3428 h2.set_mode(0o644);
3429 h2.set_cksum();
3430 archive.append(&h2, &data2[..]).unwrap();
3431
3432 let encoder = archive.into_inner().unwrap();
3433 encoder.finish().unwrap();
3434 }
3435
3436 let output = tempfile::tempdir().unwrap();
3437 extract_tar_gz(&buf, output.path()).unwrap();
3438
3439 assert_eq!(
3440 std::fs::read_to_string(output.path().join("subdir/one.txt")).unwrap(),
3441 "file one"
3442 );
3443 assert_eq!(
3444 std::fs::read_to_string(output.path().join("subdir/two.txt")).unwrap(),
3445 "file two"
3446 );
3447 }
3448
3449 #[test]
3450 fn extract_tar_gz_empty_archive() {
3451 let mut buf = Vec::new();
3453 {
3454 let encoder = flate2::write::GzEncoder::new(&mut buf, flate2::Compression::default());
3455 let archive = tar::Builder::new(encoder);
3456 let encoder = archive.into_inner().unwrap();
3457 encoder.finish().unwrap();
3458 }
3459
3460 let output = tempfile::tempdir().unwrap();
3461 let result = extract_tar_gz(&buf, output.path());
3462 assert!(result.is_ok(), "empty archive should extract successfully");
3463 assert!(output.path().exists());
3465 let entries: Vec<_> = std::fs::read_dir(output.path()).unwrap().collect();
3466 assert_eq!(entries.len(), 0, "output directory should be empty");
3467 }
3468
3469 #[test]
3472 fn resolve_from_docker_auths_v1_suffix() {
3473 let mut auths = HashMap::new();
3474 auths.insert(
3475 "https://myregistry.io/v1/".to_string(),
3476 DockerAuthEntry {
3477 auth: Some("dXNlcjpwYXNz".to_string()), },
3479 );
3480 let result = resolve_from_docker_auths(&auths, "myregistry.io");
3481 assert!(result.is_some(), "should match v1 URL suffix");
3482 let auth = result.unwrap();
3483 assert_eq!(auth.username, "user");
3484 assert_eq!(auth.password, "pass");
3485 }
3486
3487 #[test]
3488 fn resolve_from_docker_auths_exact_hostname_match() {
3489 let mut auths = HashMap::new();
3490 auths.insert(
3491 "ghcr.io".to_string(),
3492 DockerAuthEntry {
3493 auth: Some("dXNlcjpwYXNz".to_string()),
3494 },
3495 );
3496 let result = resolve_from_docker_auths(&auths, "ghcr.io");
3498 assert!(result.is_some(), "should match exact hostname");
3499 assert_eq!(result.unwrap().username, "user");
3500 }
3501
3502 #[test]
3503 fn resolve_from_docker_auths_docker_io_fallback_to_index() {
3504 let mut auths = HashMap::new();
3506 auths.insert(
3507 "index.docker.io".to_string(),
3508 DockerAuthEntry {
3509 auth: Some("ZG9ja2VyOnNlY3JldA==".to_string()), },
3511 );
3512 let result = resolve_from_docker_auths(&auths, "docker.io");
3513 assert!(
3514 result.is_some(),
3515 "docker.io should fall back to index.docker.io"
3516 );
3517 let auth = result.unwrap();
3518 assert_eq!(auth.username, "docker");
3519 assert_eq!(auth.password, "secret");
3520 }
3521
3522 #[test]
3523 fn resolve_from_docker_auths_index_docker_io_fallback() {
3524 let mut auths = HashMap::new();
3526 auths.insert(
3527 "https://index.docker.io/v1/".to_string(),
3528 DockerAuthEntry {
3529 auth: Some("ZG9ja2VyOnNlY3JldA==".to_string()), },
3531 );
3532 let result = resolve_from_docker_auths(&auths, "index.docker.io");
3533 assert!(
3534 result.is_some(),
3535 "index.docker.io should match https://index.docker.io/v1/ entry"
3536 );
3537 assert_eq!(result.unwrap().username, "docker");
3538 }
3539
3540 #[test]
3541 fn resolve_from_docker_auths_entry_with_null_auth_field() {
3542 let mut auths = HashMap::new();
3543 auths.insert("ghcr.io".to_string(), DockerAuthEntry { auth: None });
3544 let result = resolve_from_docker_auths(&auths, "ghcr.io");
3545 assert!(
3546 result.is_none(),
3547 "entry with null auth field should not resolve"
3548 );
3549 }
3550
3551 #[test]
3552 fn resolve_from_docker_auths_entry_with_invalid_base64() {
3553 let mut auths = HashMap::new();
3554 auths.insert(
3555 "ghcr.io".to_string(),
3556 DockerAuthEntry {
3557 auth: Some("not-valid-base64!!!".to_string()),
3558 },
3559 );
3560 let result = resolve_from_docker_auths(&auths, "ghcr.io");
3561 assert!(
3562 result.is_none(),
3563 "entry with invalid base64 should not resolve"
3564 );
3565 }
3566
3567 #[test]
3568 fn resolve_from_docker_auths_prefers_exact_match_over_url() {
3569 let mut auths = HashMap::new();
3570 auths.insert(
3572 "ghcr.io".to_string(),
3573 DockerAuthEntry {
3574 auth: Some("ZXhhY3Q6bWF0Y2g=".to_string()), },
3576 );
3577 auths.insert(
3579 "https://ghcr.io".to_string(),
3580 DockerAuthEntry {
3581 auth: Some("dXJsOm1hdGNo".to_string()), },
3583 );
3584 let result = resolve_from_docker_auths(&auths, "ghcr.io");
3585 assert!(result.is_some());
3586 assert_eq!(result.unwrap().username, "exact");
3589 }
3590
3591 #[test]
3594 fn validate_verify_options_accepts_key_only() {
3595 let opts = VerifyOptions {
3596 key: Some("cosign.pub"),
3597 identity: None,
3598 issuer: None,
3599 };
3600 let result = validate_verify_options(&opts);
3601 assert!(result.is_ok(), "key-only verification should be valid");
3602 }
3603
3604 #[test]
3605 fn validate_verify_options_accepts_identity_only() {
3606 let opts = VerifyOptions {
3607 key: None,
3608 identity: Some("user@example.com"),
3609 issuer: None,
3610 };
3611 let result = validate_verify_options(&opts);
3612 assert!(result.is_ok(), "identity-only verification should be valid");
3613 }
3614
3615 #[test]
3616 fn validate_verify_options_accepts_issuer_only() {
3617 let opts = VerifyOptions {
3618 key: None,
3619 identity: None,
3620 issuer: Some("https://accounts.google.com"),
3621 };
3622 let result = validate_verify_options(&opts);
3623 assert!(result.is_ok(), "issuer-only verification should be valid");
3624 }
3625
3626 #[test]
3627 fn validate_verify_options_accepts_all_fields() {
3628 let opts = VerifyOptions {
3629 key: Some("cosign.pub"),
3630 identity: Some("user@example.com"),
3631 issuer: Some("https://accounts.google.com"),
3632 };
3633 let result = validate_verify_options(&opts);
3634 assert!(result.is_ok(), "all-fields verification should be valid");
3635 }
3636
3637 #[test]
3638 fn validate_verify_options_rejects_all_none() {
3639 let opts = VerifyOptions {
3640 key: None,
3641 identity: None,
3642 issuer: None,
3643 };
3644 let result = validate_verify_options(&opts);
3645 assert!(result.is_err(), "all-none should be rejected");
3646 let err = result.unwrap_err();
3647 let err_msg = format!("{err}");
3648 assert!(
3649 err_msg.contains("keyless verification requires identity or issuer constraint"),
3650 "error message should explain the constraint, got: {err_msg}"
3651 );
3652 }
3653
3654 #[test]
3657 fn apply_verify_args_with_key() {
3658 let mut cmd = std::process::Command::new("echo");
3659 let opts = VerifyOptions {
3660 key: Some("/path/to/cosign.pub"),
3661 identity: None,
3662 issuer: None,
3663 };
3664 apply_verify_args(&mut cmd, &opts);
3665 let args: Vec<_> = cmd.get_args().map(|a| a.to_str().unwrap()).collect();
3667 assert_eq!(args, vec!["--key", "/path/to/cosign.pub"]);
3668 }
3669
3670 #[test]
3671 fn apply_verify_args_keyless_with_identity_and_issuer() {
3672 let mut cmd = std::process::Command::new("echo");
3673 let opts = VerifyOptions {
3674 key: None,
3675 identity: Some("user@example.com"),
3676 issuer: Some("https://accounts.google.com"),
3677 };
3678 apply_verify_args(&mut cmd, &opts);
3679 let args: Vec<_> = cmd.get_args().map(|a| a.to_str().unwrap()).collect();
3680 assert_eq!(
3681 args,
3682 vec![
3683 "--certificate-identity-regexp",
3684 "user@example.com",
3685 "--certificate-oidc-issuer-regexp",
3686 "https://accounts.google.com"
3687 ]
3688 );
3689 }
3690
3691 #[test]
3692 fn apply_verify_args_keyless_with_identity_only_defaults_issuer() {
3693 let mut cmd = std::process::Command::new("echo");
3694 let opts = VerifyOptions {
3695 key: None,
3696 identity: Some("ci@github.com"),
3697 issuer: None,
3698 };
3699 apply_verify_args(&mut cmd, &opts);
3700 let args: Vec<_> = cmd.get_args().map(|a| a.to_str().unwrap()).collect();
3701 assert_eq!(
3703 args,
3704 vec![
3705 "--certificate-identity-regexp",
3706 "ci@github.com",
3707 "--certificate-oidc-issuer-regexp",
3708 ".*"
3709 ]
3710 );
3711 }
3712
3713 #[test]
3714 fn apply_verify_args_keyless_with_issuer_only_defaults_identity() {
3715 let mut cmd = std::process::Command::new("echo");
3716 let opts = VerifyOptions {
3717 key: None,
3718 identity: None,
3719 issuer: Some("https://token.actions.githubusercontent.com"),
3720 };
3721 apply_verify_args(&mut cmd, &opts);
3722 let args: Vec<_> = cmd.get_args().map(|a| a.to_str().unwrap()).collect();
3723 assert_eq!(
3725 args,
3726 vec![
3727 "--certificate-identity-regexp",
3728 ".*",
3729 "--certificate-oidc-issuer-regexp",
3730 "https://token.actions.githubusercontent.com"
3731 ]
3732 );
3733 }
3734
3735 #[test]
3736 fn apply_verify_args_key_takes_precedence_over_keyless() {
3737 let mut cmd = std::process::Command::new("echo");
3738 let opts = VerifyOptions {
3739 key: Some("my.pub"),
3740 identity: Some("user@example.com"),
3741 issuer: Some("https://issuer.example.com"),
3742 };
3743 apply_verify_args(&mut cmd, &opts);
3744 let args: Vec<_> = cmd.get_args().map(|a| a.to_str().unwrap()).collect();
3745 assert_eq!(args, vec!["--key", "my.pub"]);
3747 }
3748
3749 #[test]
3752 fn build_dockerfile_centos() {
3753 let df = build_dockerfile("centos:8", &["vim", "git"]);
3754 assert!(df.contains("FROM centos:8"));
3755 assert!(df.contains("yum install -y"));
3756 assert!(df.contains("vim git"));
3757 assert!(!df.contains("apt-get"));
3758 assert!(!df.contains("rm -rf /var/lib/apt/lists"));
3759 }
3760
3761 #[test]
3762 fn build_dockerfile_archlinux() {
3763 let df = build_dockerfile("archlinux:latest", &["base-devel"]);
3764 assert!(df.contains("FROM archlinux:latest"));
3765 assert!(df.contains("pacman -Sy --noconfirm"));
3766 assert!(df.contains("base-devel"));
3767 }
3768
3769 #[test]
3770 fn build_dockerfile_rockylinux() {
3771 let df = build_dockerfile("rockylinux:9", &["httpd"]);
3772 assert!(df.contains("FROM rockylinux:9"));
3773 assert!(df.contains("dnf install -y"));
3774 assert!(df.contains("httpd"));
3775 }
3776
3777 #[test]
3778 fn build_dockerfile_almalinux() {
3779 let df = build_dockerfile("almalinux:8", &["nginx"]);
3780 assert!(df.contains("FROM almalinux:8"));
3781 assert!(df.contains("dnf install -y"));
3782 }
3783
3784 #[test]
3785 fn build_dockerfile_debian_cleans_apt_lists() {
3786 let df = build_dockerfile("debian:bookworm", &["curl"]);
3787 assert!(df.contains("FROM debian:bookworm"));
3788 assert!(df.contains("apt-get update && apt-get install -y"));
3789 assert!(
3790 df.contains("rm -rf /var/lib/apt/lists"),
3791 "debian-based images should clean apt lists"
3792 );
3793 }
3794
3795 #[test]
3796 fn build_dockerfile_always_includes_workdir_and_copy() {
3797 let df = build_dockerfile("scratch", &[]);
3799 assert!(df.contains("WORKDIR /build"));
3800 assert!(df.contains("COPY . /build/"));
3801 assert!(!df.contains("RUN"));
3802 }
3803
3804 #[test]
3805 fn build_dockerfile_registry_prefixed_alpine() {
3806 let df = build_dockerfile("docker.io/library/alpine:3.19", &["curl"]);
3808 assert!(df.contains("apk add --no-cache"));
3809 assert!(!df.contains("apt-get"));
3810 }
3811
3812 #[test]
3813 fn build_dockerfile_multiple_packages() {
3814 let df = build_dockerfile("ubuntu:22.04", &["curl", "jq", "vim", "git"]);
3815 assert!(df.contains("curl jq vim git"));
3816 }
3817
3818 #[test]
3821 fn oci_index_serializes_with_correct_field_names() {
3822 let index = OciIndex {
3823 schema_version: 2,
3824 media_type: MEDIA_TYPE_OCI_INDEX.to_string(),
3825 manifests: vec![OciPlatformManifest {
3826 media_type: MEDIA_TYPE_OCI_MANIFEST.to_string(),
3827 digest: "sha256:abc123".to_string(),
3828 size: 1024,
3829 platform: OciPlatform {
3830 os: "linux".to_string(),
3831 architecture: "amd64".to_string(),
3832 },
3833 }],
3834 };
3835
3836 let json_str = serde_json::to_string_pretty(&index).unwrap();
3837 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
3838
3839 assert_eq!(parsed["schemaVersion"], 2);
3841 assert_eq!(
3842 parsed["mediaType"],
3843 "application/vnd.oci.image.index.v1+json"
3844 );
3845 assert!(parsed["manifests"].is_array());
3846 assert_eq!(parsed["manifests"][0]["size"], 1024);
3847 assert_eq!(parsed["manifests"][0]["digest"], "sha256:abc123");
3848 assert_eq!(parsed["manifests"][0]["platform"]["os"], "linux");
3849 assert_eq!(parsed["manifests"][0]["platform"]["architecture"], "amd64");
3850 }
3851
3852 #[test]
3853 fn oci_index_roundtrips_multiple_platforms() {
3854 let index = OciIndex {
3855 schema_version: 2,
3856 media_type: MEDIA_TYPE_OCI_INDEX.to_string(),
3857 manifests: vec![
3858 OciPlatformManifest {
3859 media_type: MEDIA_TYPE_OCI_MANIFEST.to_string(),
3860 digest: "sha256:aaa".to_string(),
3861 size: 100,
3862 platform: OciPlatform {
3863 os: "linux".to_string(),
3864 architecture: "amd64".to_string(),
3865 },
3866 },
3867 OciPlatformManifest {
3868 media_type: MEDIA_TYPE_OCI_MANIFEST.to_string(),
3869 digest: "sha256:bbb".to_string(),
3870 size: 200,
3871 platform: OciPlatform {
3872 os: "linux".to_string(),
3873 architecture: "arm64".to_string(),
3874 },
3875 },
3876 OciPlatformManifest {
3877 media_type: MEDIA_TYPE_OCI_MANIFEST.to_string(),
3878 digest: "sha256:ccc".to_string(),
3879 size: 300,
3880 platform: OciPlatform {
3881 os: "darwin".to_string(),
3882 architecture: "arm64".to_string(),
3883 },
3884 },
3885 ],
3886 };
3887
3888 let json_bytes = serde_json::to_vec(&index).unwrap();
3889 let roundtripped: OciIndex = serde_json::from_slice(&json_bytes).unwrap();
3890
3891 assert_eq!(roundtripped.schema_version, 2);
3892 assert_eq!(roundtripped.manifests.len(), 3);
3893 assert_eq!(roundtripped.manifests[0].platform.os, "linux");
3894 assert_eq!(roundtripped.manifests[0].platform.architecture, "amd64");
3895 assert_eq!(roundtripped.manifests[0].digest, "sha256:aaa");
3896 assert_eq!(roundtripped.manifests[0].size, 100);
3897 assert_eq!(roundtripped.manifests[1].platform.architecture, "arm64");
3898 assert_eq!(roundtripped.manifests[1].digest, "sha256:bbb");
3899 assert_eq!(roundtripped.manifests[2].platform.os, "darwin");
3900 assert_eq!(roundtripped.manifests[2].digest, "sha256:ccc");
3901 assert_eq!(roundtripped.manifests[2].size, 300);
3902 }
3903
3904 #[test]
3905 fn oci_index_empty_manifests_list() {
3906 let index = OciIndex {
3907 schema_version: 2,
3908 media_type: MEDIA_TYPE_OCI_INDEX.to_string(),
3909 manifests: vec![],
3910 };
3911
3912 let json_str = serde_json::to_string(&index).unwrap();
3913 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
3914 assert_eq!(parsed["manifests"].as_array().unwrap().len(), 0);
3915
3916 let roundtripped: OciIndex = serde_json::from_str(&json_str).unwrap();
3917 assert_eq!(roundtripped.manifests.len(), 0);
3918 }
3919
3920 #[test]
3923 fn registry_auth_basic_auth_header_format() {
3924 let auth = RegistryAuth {
3925 username: "testuser".to_string(),
3926 password: "testpass".to_string(),
3927 };
3928 let header = auth.basic_auth_header();
3929 assert!(header.starts_with("Basic "));
3930 let encoded_part = header.strip_prefix("Basic ").unwrap();
3932 let decoded = base64_decode(encoded_part).unwrap();
3933 let decoded_str = String::from_utf8(decoded).unwrap();
3934 assert_eq!(decoded_str, "testuser:testpass");
3935 }
3936
3937 #[test]
3938 fn registry_auth_basic_auth_header_special_chars() {
3939 let auth = RegistryAuth {
3940 username: "user".to_string(),
3941 password: "p@ss:w0rd!".to_string(),
3942 };
3943 let header = auth.basic_auth_header();
3944 let encoded_part = header.strip_prefix("Basic ").unwrap();
3945 let decoded = base64_decode(encoded_part).unwrap();
3946 let decoded_str = String::from_utf8(decoded).unwrap();
3947 assert_eq!(decoded_str, "user:p@ss:w0rd!");
3948 }
3949
3950 #[test]
3953 fn base64_encode_single_byte() {
3954 let encoded = base64_encode(b"a");
3955 let decoded = base64_decode(&encoded).unwrap();
3956 assert_eq!(decoded, b"a");
3957 }
3958
3959 #[test]
3960 fn base64_encode_two_bytes() {
3961 let encoded = base64_encode(b"ab");
3962 let decoded = base64_decode(&encoded).unwrap();
3963 assert_eq!(decoded, b"ab");
3964 }
3965
3966 #[test]
3967 fn base64_encode_three_bytes() {
3968 let encoded = base64_encode(b"abc");
3970 assert!(!encoded.contains('='));
3971 let decoded = base64_decode(&encoded).unwrap();
3972 assert_eq!(decoded, b"abc");
3973 }
3974
3975 #[test]
3976 fn base64_encode_binary_data() {
3977 let data: Vec<u8> = (0..=255).collect();
3978 let encoded = base64_encode(&data);
3979 let decoded = base64_decode(&encoded).unwrap();
3980 assert_eq!(decoded, data);
3981 }
3982
3983 #[test]
3984 fn base64_decode_with_whitespace() {
3985 let decoded = base64_decode(" dXNlcjpwYXNz ").unwrap();
3987 assert_eq!(String::from_utf8(decoded).unwrap(), "user:pass");
3988 }
3989
3990 #[test]
3991 fn base64_decode_invalid_length() {
3992 assert!(base64_decode("abc").is_none());
3994 assert!(base64_decode("abcde").is_none());
3995 }
3996
3997 #[test]
3998 fn base64_decode_invalid_chars() {
3999 assert!(base64_decode("ab~d").is_none());
4001 }
4002
4003 #[test]
4006 fn parse_reference_with_whitespace_fails() {
4007 assert!(OciReference::parse("ghcr.io/my repo:v1").is_err());
4008 }
4009
4010 #[test]
4011 fn parse_reference_with_control_chars_fails() {
4012 assert!(OciReference::parse("ghcr.io/repo\x00:v1").is_err());
4013 }
4014
4015 #[test]
4016 fn parse_reference_host_colon_port_only_fails() {
4017 assert!(OciReference::parse("host:5000").is_err());
4019 }
4020
4021 #[test]
4022 fn parse_reference_localhost_no_port() {
4023 let r = OciReference::parse("localhost/myrepo:v1").unwrap();
4024 assert_eq!(r.registry, "localhost");
4025 assert_eq!(r.repository, "myrepo");
4026 assert_eq!(r.reference, ReferenceKind::Tag("v1".to_string()));
4027 }
4028
4029 #[test]
4030 fn parse_reference_registry_with_port_and_nested_repo() {
4031 let r = OciReference::parse("myregistry.io:5000/org/repo:v2").unwrap();
4032 assert_eq!(r.registry, "myregistry.io:5000");
4033 assert_eq!(r.repository, "org/repo");
4034 assert_eq!(r.reference, ReferenceKind::Tag("v2".to_string()));
4035 }
4036
4037 #[test]
4038 fn parse_reference_127_0_0_1() {
4039 let r = OciReference::parse("127.0.0.1:5000/test:dev").unwrap();
4040 assert_eq!(r.registry, "127.0.0.1:5000");
4041 assert_eq!(r.repository, "test");
4042 assert!(r.api_base().starts_with("http://"));
4044 }
4045
4046 #[test]
4047 fn reference_str_tag() {
4048 let r = OciReference {
4049 registry: "ghcr.io".to_string(),
4050 repository: "test/mod".to_string(),
4051 reference: ReferenceKind::Tag("v1.0".to_string()),
4052 };
4053 assert_eq!(r.reference_str(), "v1.0");
4054 }
4055
4056 #[test]
4057 fn reference_str_digest() {
4058 let r = OciReference {
4059 registry: "ghcr.io".to_string(),
4060 repository: "test/mod".to_string(),
4061 reference: ReferenceKind::Digest("sha256:abc".to_string()),
4062 };
4063 assert_eq!(r.reference_str(), "sha256:abc");
4064 }
4065
4066 #[test]
4069 fn docker_config_path_uses_env() {
4070 let prev = std::env::var("DOCKER_CONFIG").ok();
4071
4072 let tmp = tempfile::tempdir().unwrap();
4073 unsafe {
4074 std::env::set_var("DOCKER_CONFIG", tmp.path().to_str().unwrap());
4075 }
4076
4077 let path = docker_config_path();
4078 assert_eq!(path, tmp.path().join("config.json"));
4079
4080 unsafe {
4081 match prev {
4082 Some(v) => std::env::set_var("DOCKER_CONFIG", v),
4083 None => std::env::remove_var("DOCKER_CONFIG"),
4084 }
4085 }
4086 }
4087
4088 #[test]
4091 #[cfg(unix)]
4092 fn create_tar_gz_skips_symlinks_in_source_dir() {
4093 let dir = tempfile::tempdir().unwrap();
4094 std::fs::write(dir.path().join("file.txt"), "content").unwrap();
4095 std::os::unix::fs::symlink("/etc/passwd", dir.path().join("link")).unwrap();
4097
4098 let archive = create_tar_gz(dir.path()).unwrap();
4099
4100 let out = tempfile::tempdir().unwrap();
4102 extract_tar_gz(&archive, out.path()).unwrap();
4103
4104 assert!(out.path().join("file.txt").exists());
4105 assert!(
4107 !out.path().join("link").exists(),
4108 "symlinks should be skipped during archive creation"
4109 );
4110 }
4111
4112 #[test]
4115 fn oci_manifest_with_annotations_round_trips() {
4116 let mut annotations = HashMap::new();
4117 annotations.insert("cfgd.io/platform".to_string(), "linux/amd64".to_string());
4118 annotations.insert(
4119 "org.opencontainers.image.created".to_string(),
4120 "2026-01-01T00:00:00Z".to_string(),
4121 );
4122
4123 let manifest = OciManifest {
4124 schema_version: 2,
4125 media_type: MEDIA_TYPE_OCI_MANIFEST.to_string(),
4126 config: OciDescriptor {
4127 media_type: MEDIA_TYPE_MODULE_CONFIG.to_string(),
4128 digest: "sha256:cfg123".to_string(),
4129 size: 50,
4130 annotations: HashMap::new(),
4131 },
4132 layers: vec![OciDescriptor {
4133 media_type: MEDIA_TYPE_MODULE_LAYER.to_string(),
4134 digest: "sha256:layer123".to_string(),
4135 size: 1024,
4136 annotations: HashMap::new(),
4137 }],
4138 annotations,
4139 };
4140
4141 let json = serde_json::to_string_pretty(&manifest).unwrap();
4142 let parsed: OciManifest = serde_json::from_str(&json).unwrap();
4143 assert_eq!(parsed.annotations.len(), 2);
4144 assert_eq!(
4145 parsed.annotations.get("cfgd.io/platform").unwrap(),
4146 "linux/amd64"
4147 );
4148 assert_eq!(
4149 parsed
4150 .annotations
4151 .get("org.opencontainers.image.created")
4152 .unwrap(),
4153 "2026-01-01T00:00:00Z"
4154 );
4155 }
4156
4157 #[test]
4158 fn oci_manifest_empty_annotations_skipped_in_json() {
4159 let manifest = OciManifest {
4160 schema_version: 2,
4161 media_type: MEDIA_TYPE_OCI_MANIFEST.to_string(),
4162 config: OciDescriptor {
4163 media_type: MEDIA_TYPE_MODULE_CONFIG.to_string(),
4164 digest: "sha256:cfg".to_string(),
4165 size: 10,
4166 annotations: HashMap::new(),
4167 },
4168 layers: vec![],
4169 annotations: HashMap::new(),
4170 };
4171
4172 let json = serde_json::to_string(&manifest).unwrap();
4173 assert!(
4175 !json.contains("annotations"),
4176 "empty annotations should be skipped in serialization"
4177 );
4178 }
4179
4180 #[test]
4183 fn oci_descriptor_with_annotations_round_trips() {
4184 let mut anns = HashMap::new();
4185 anns.insert(
4186 "org.opencontainers.image.title".to_string(),
4187 "my-module".to_string(),
4188 );
4189
4190 let desc = OciDescriptor {
4191 media_type: MEDIA_TYPE_MODULE_LAYER.to_string(),
4192 digest: "sha256:abc".to_string(),
4193 size: 512,
4194 annotations: anns,
4195 };
4196
4197 let json = serde_json::to_string(&desc).unwrap();
4198 let parsed: OciDescriptor = serde_json::from_str(&json).unwrap();
4199 assert_eq!(parsed.size, 512);
4200 assert_eq!(
4201 parsed
4202 .annotations
4203 .get("org.opencontainers.image.title")
4204 .unwrap(),
4205 "my-module"
4206 );
4207 }
4208
4209 #[test]
4212 fn generate_slsa_provenance_strips_sha256_prefix() {
4213 let prov = generate_slsa_provenance(
4214 "ghcr.io/test/mod:v1",
4215 "sha256:deadbeef1234",
4216 "https://github.com/org/repo",
4217 "abc123",
4218 )
4219 .unwrap();
4220 let parsed: serde_json::Value = serde_json::from_str(&prov).unwrap();
4221 assert_eq!(parsed["subject"][0]["digest"]["sha256"], "deadbeef1234");
4223 }
4224
4225 #[test]
4226 fn generate_slsa_provenance_handles_plain_digest() {
4227 let prov = generate_slsa_provenance(
4228 "ghcr.io/test/mod:v1",
4229 "plaindigest",
4230 "https://github.com/org/repo",
4231 "abc123",
4232 )
4233 .unwrap();
4234 let parsed: serde_json::Value = serde_json::from_str(&prov).unwrap();
4235 assert_eq!(parsed["subject"][0]["digest"]["sha256"], "plaindigest");
4237 }
4238
4239 #[test]
4240 fn generate_slsa_provenance_includes_source_info() {
4241 let prov = generate_slsa_provenance(
4242 "ghcr.io/myorg/mymod:v2",
4243 "sha256:abcdef",
4244 "https://github.com/myorg/myrepo",
4245 "deadbeef123",
4246 )
4247 .unwrap();
4248 let parsed: serde_json::Value = serde_json::from_str(&prov).unwrap();
4249 assert_eq!(parsed["_type"], "https://in-toto.io/Statement/v1");
4250 assert_eq!(
4251 parsed["predicate"]["buildDefinition"]["externalParameters"]["source"]["uri"],
4252 "https://github.com/myorg/myrepo"
4253 );
4254 assert_eq!(
4255 parsed["predicate"]["buildDefinition"]["externalParameters"]["source"]["digest"]["gitCommit"],
4256 "deadbeef123"
4257 );
4258 assert_eq!(
4259 parsed["predicate"]["runDetails"]["builder"]["id"],
4260 "https://cfgd.io/builder/v1"
4261 );
4262 }
4263
4264 #[test]
4267 fn extract_auth_param_empty_value() {
4268 let header = r#"Bearer realm="",service="svc""#;
4269 assert_eq!(extract_auth_param(header, "realm"), Some(""));
4270 }
4271
4272 #[test]
4273 fn extract_auth_param_no_quotes() {
4274 let header = "Bearer realm_value=noquotes";
4275 assert_eq!(extract_auth_param(header, "realm"), None);
4276 }
4277
4278 #[test]
4279 fn extract_auth_param_url_with_special_chars() {
4280 let header = r#"Bearer realm="https://auth.example.com/token?foo=bar",service="svc""#;
4281 let realm = extract_auth_param(header, "realm").unwrap();
4282 assert_eq!(realm, "https://auth.example.com/token?foo=bar");
4283 }
4284
4285 #[test]
4288 fn detect_pkg_install_cmd_registry_prefixed_fedora() {
4289 assert_eq!(
4290 detect_pkg_install_cmd("registry.example.com/fedora:39"),
4291 "dnf install -y"
4292 );
4293 }
4294
4295 #[test]
4296 fn detect_pkg_install_cmd_registry_prefixed_centos() {
4297 assert_eq!(
4298 detect_pkg_install_cmd("quay.io/centos/centos:stream8"),
4299 "yum install -y"
4300 );
4301 }
4302
4303 #[test]
4304 fn detect_pkg_install_cmd_registry_prefixed_archlinux() {
4305 assert_eq!(
4306 detect_pkg_install_cmd("docker.io/library/archlinux:base"),
4307 "pacman -Sy --noconfirm"
4308 );
4309 }
4310
4311 #[test]
4314 fn docker_config_empty_json() {
4315 let config: DockerConfig = serde_json::from_str("{}").unwrap();
4316 assert!(config.auths.is_empty());
4317 assert!(config.cred_helpers.is_empty());
4318 }
4319
4320 #[test]
4321 fn docker_config_with_only_cred_helpers() {
4322 let json = r#"{"credHelpers":{"gcr.io":"gcloud"}}"#;
4323 let config: DockerConfig = serde_json::from_str(json).unwrap();
4324 assert!(config.auths.is_empty());
4325 assert_eq!(config.cred_helpers.len(), 1);
4326 assert_eq!(config.cred_helpers.get("gcr.io").unwrap(), "gcloud");
4327 }
4328
4329 #[test]
4332 fn create_tar_gz_nested_dirs() {
4333 let dir = tempfile::tempdir().unwrap();
4334 let deep = dir.path().join("a/b/c");
4335 std::fs::create_dir_all(&deep).unwrap();
4336 std::fs::write(deep.join("deep.txt"), "nested content").unwrap();
4337 std::fs::write(dir.path().join("root.txt"), "top level").unwrap();
4338
4339 let archive = create_tar_gz(dir.path()).unwrap();
4340
4341 let out = tempfile::tempdir().unwrap();
4342 extract_tar_gz(&archive, out.path()).unwrap();
4343
4344 let root_content = std::fs::read_to_string(out.path().join("root.txt")).unwrap();
4345 assert_eq!(root_content, "top level");
4346
4347 let deep_content = std::fs::read_to_string(out.path().join("a/b/c/deep.txt")).unwrap();
4348 assert_eq!(deep_content, "nested content");
4349 }
4350
4351 #[test]
4354 fn create_tar_gz_empty_dir() {
4355 let dir = tempfile::tempdir().unwrap();
4356 let archive = create_tar_gz(dir.path()).unwrap();
4357 assert!(
4358 !archive.is_empty(),
4359 "even empty dir should produce a valid gzip stream"
4360 );
4361
4362 let out = tempfile::tempdir().unwrap();
4363 let result = extract_tar_gz(&archive, out.path());
4364 assert!(result.is_ok());
4365 }
4366
4367 #[test]
4370 fn is_insecure_registry_false_when_env_not_set() {
4371 assert!(!is_insecure_registry("totally-not-insecure.example.com"));
4375 }
4376
4377 #[test]
4380 fn oci_reference_display_tag() {
4381 let r = OciReference {
4382 registry: "registry.example.com".to_string(),
4383 repository: "org/repo".to_string(),
4384 reference: ReferenceKind::Tag("v1.2.3".to_string()),
4385 };
4386 assert_eq!(format!("{}", r), "registry.example.com/org/repo:v1.2.3");
4387 }
4388
4389 #[test]
4390 fn oci_reference_display_digest() {
4391 let r = OciReference {
4392 registry: "ghcr.io".to_string(),
4393 repository: "myorg/mymod".to_string(),
4394 reference: ReferenceKind::Digest("sha256:abcdef1234".to_string()),
4395 };
4396 assert_eq!(format!("{}", r), "ghcr.io/myorg/mymod@sha256:abcdef1234");
4397 }
4398
4399 #[test]
4402 fn media_type_constants_are_correct() {
4403 assert_eq!(
4404 MEDIA_TYPE_MODULE_CONFIG,
4405 "application/vnd.cfgd.module.config.v1+json"
4406 );
4407 assert_eq!(
4408 MEDIA_TYPE_MODULE_LAYER,
4409 "application/vnd.cfgd.module.layer.v1.tar+gzip"
4410 );
4411 assert_eq!(
4412 MEDIA_TYPE_OCI_MANIFEST,
4413 "application/vnd.oci.image.manifest.v1+json"
4414 );
4415 assert_eq!(
4416 MEDIA_TYPE_OCI_INDEX,
4417 "application/vnd.oci.image.index.v1+json"
4418 );
4419 }
4420
4421 #[test]
4424 fn sha256_digest_deterministic() {
4425 let data = b"test data for determinism check";
4426 let d1 = sha256_digest(data);
4427 let d2 = sha256_digest(data);
4428 assert_eq!(d1, d2);
4429 }
4430
4431 #[test]
4432 fn sha256_digest_different_inputs_different_outputs() {
4433 let d1 = sha256_digest(b"input one");
4434 let d2 = sha256_digest(b"input two");
4435 assert_ne!(d1, d2);
4436 }
4437
4438 #[test]
4441 fn build_dockerfile_ubuntu_with_packages_cleans_apt_lists() {
4442 let df = build_dockerfile("ubuntu:24.04", &["git", "curl", "make"]);
4443 assert_eq!(df.lines().count(), 4, "FROM + RUN + WORKDIR + COPY");
4444 assert!(df.starts_with("FROM ubuntu:24.04"));
4445 assert!(df.contains("apt-get update && apt-get install -y git curl make"));
4446 assert!(df.contains("rm -rf /var/lib/apt/lists/*"));
4447 assert!(df.contains("WORKDIR /build"));
4448 assert!(df.contains("COPY . /build/"));
4449 }
4450
4451 #[test]
4452 fn build_dockerfile_debian_uses_apt() {
4453 let df = build_dockerfile("debian:bookworm", &["vim"]);
4454 assert!(df.contains("apt-get"), "debian should use apt-get");
4455 assert!(df.contains("rm -rf /var/lib/apt/lists/*"));
4456 }
4457
4458 #[test]
4459 fn build_dockerfile_alpine_uses_apk() {
4460 let df = build_dockerfile("alpine:3.20", &["bash", "coreutils"]);
4461 assert!(df.contains("apk add --no-cache bash coreutils"));
4462 assert!(!df.contains("apt-get"));
4463 assert!(!df.contains("rm -rf"));
4464 }
4465
4466 #[test]
4467 fn build_dockerfile_fedora_uses_dnf() {
4468 let df = build_dockerfile("fedora:41", &["gcc", "gdb"]);
4469 assert!(df.contains("dnf install -y gcc gdb"));
4470 }
4471
4472 #[test]
4473 fn build_dockerfile_rockylinux_uses_dnf() {
4474 let df = build_dockerfile("rockylinux:9.3", &["python3"]);
4475 assert!(df.contains("dnf install -y python3"));
4476 }
4477
4478 #[test]
4479 fn build_dockerfile_almalinux_uses_dnf() {
4480 let df = build_dockerfile("almalinux:9", &["wget"]);
4481 assert!(df.contains("dnf install -y wget"));
4482 }
4483
4484 #[test]
4485 fn build_dockerfile_centos_uses_yum() {
4486 let df = build_dockerfile("centos:7", &["nmap"]);
4487 assert!(df.contains("yum install -y nmap"));
4488 }
4489
4490 #[test]
4491 fn build_dockerfile_archlinux_uses_pacman() {
4492 let df = build_dockerfile("archlinux:base", &["neovim"]);
4493 assert!(df.contains("pacman -Sy --noconfirm neovim"));
4494 }
4495
4496 #[test]
4497 fn build_dockerfile_registry_prefixed_alpine_custom() {
4498 let df = build_dockerfile("ghcr.io/custom/alpine:edge", &["jq"]);
4499 assert!(df.contains("apk add --no-cache jq"));
4500 assert!(df.starts_with("FROM ghcr.io/custom/alpine:edge"));
4501 }
4502
4503 #[test]
4504 fn build_dockerfile_registry_prefixed_fedora() {
4505 let df = build_dockerfile("quay.io/fedora/fedora:40", &["strace"]);
4506 assert!(df.contains("dnf install -y strace"));
4507 }
4508
4509 #[test]
4510 fn build_dockerfile_empty_packages_has_no_run() {
4511 let df = build_dockerfile("scratch", &[]);
4512 assert_eq!(df.lines().count(), 3, "FROM + WORKDIR + COPY, no RUN");
4513 assert!(!df.contains("RUN"));
4514 }
4515
4516 #[test]
4517 fn build_dockerfile_single_package() {
4518 let df = build_dockerfile("ubuntu:22.04", &["curl"]);
4519 assert!(df.contains("curl"));
4520 let run_count = df.lines().filter(|l| l.starts_with("RUN")).count();
4522 assert_eq!(run_count, 1);
4523 }
4524
4525 #[test]
4528 fn detect_pkg_install_cmd_alpine() {
4529 assert_eq!(detect_pkg_install_cmd("alpine:3.19"), "apk add --no-cache");
4530 }
4531
4532 #[test]
4533 fn detect_pkg_install_cmd_ubuntu() {
4534 let cmd = detect_pkg_install_cmd("ubuntu:22.04");
4535 assert!(
4536 cmd.starts_with("apt-get"),
4537 "ubuntu should use apt-get: {cmd}"
4538 );
4539 }
4540
4541 #[test]
4542 fn detect_pkg_install_cmd_debian() {
4543 let cmd = detect_pkg_install_cmd("debian:12");
4544 assert!(
4545 cmd.starts_with("apt-get"),
4546 "debian should use apt-get: {cmd}"
4547 );
4548 }
4549
4550 #[test]
4551 fn detect_pkg_install_cmd_case_insensitive() {
4552 assert_eq!(detect_pkg_install_cmd("ALPINE:3.19"), "apk add --no-cache");
4554 assert_eq!(detect_pkg_install_cmd("Fedora:39"), "dnf install -y");
4555 }
4556
4557 #[test]
4560 fn generate_slsa_provenance_complete_structure() {
4561 let prov = generate_slsa_provenance(
4562 "ghcr.io/org/mod:v2.0.0",
4563 "sha256:deadbeef1234",
4564 "https://github.com/org/config",
4565 "abc123def456",
4566 )
4567 .unwrap();
4568
4569 let parsed: serde_json::Value = serde_json::from_str(&prov).unwrap();
4570
4571 assert_eq!(
4573 parsed["_type"], "https://in-toto.io/Statement/v1",
4574 "should be in-toto v1 statement"
4575 );
4576 assert_eq!(
4577 parsed["predicateType"], "https://slsa.dev/provenance/v1",
4578 "should be SLSA v1 provenance"
4579 );
4580
4581 let subject = &parsed["subject"][0];
4583 assert_eq!(subject["name"], "ghcr.io/org/mod:v2.0.0");
4584 assert_eq!(
4585 subject["digest"]["sha256"], "deadbeef1234",
4586 "sha256: prefix should be stripped"
4587 );
4588
4589 let predicate = &parsed["predicate"];
4591 assert_eq!(
4592 predicate["buildDefinition"]["buildType"],
4593 "https://cfgd.io/ModuleBuild/v1"
4594 );
4595 assert_eq!(
4596 predicate["buildDefinition"]["externalParameters"]["source"]["uri"],
4597 "https://github.com/org/config"
4598 );
4599 assert_eq!(
4600 predicate["buildDefinition"]["externalParameters"]["source"]["digest"]["gitCommit"],
4601 "abc123def456"
4602 );
4603 assert_eq!(
4604 predicate["runDetails"]["builder"]["id"],
4605 "https://cfgd.io/builder/v1"
4606 );
4607
4608 let invocation_id = predicate["runDetails"]["metadata"]["invocationId"]
4610 .as_str()
4611 .unwrap();
4612 assert!(
4613 !invocation_id.is_empty(),
4614 "invocationId should not be empty"
4615 );
4616 }
4617
4618 #[test]
4619 fn generate_slsa_provenance_strips_sha256_prefix_v2() {
4620 let prov =
4621 generate_slsa_provenance("ghcr.io/test:v1", "sha256:abcdef", "repo", "commit").unwrap();
4622 let parsed: serde_json::Value = serde_json::from_str(&prov).unwrap();
4623 assert_eq!(
4624 parsed["subject"][0]["digest"]["sha256"], "abcdef",
4625 "sha256: prefix should be stripped from digest"
4626 );
4627 }
4628
4629 #[test]
4630 fn generate_slsa_provenance_bare_digest() {
4631 let prov = generate_slsa_provenance("ghcr.io/test:v1", "abcdef", "repo", "commit").unwrap();
4632 let parsed: serde_json::Value = serde_json::from_str(&prov).unwrap();
4633 assert_eq!(
4634 parsed["subject"][0]["digest"]["sha256"], "abcdef",
4635 "bare digest should pass through as-is"
4636 );
4637 }
4638
4639 #[test]
4642 fn oci_manifest_round_trip_with_annotations() {
4643 let mut annotations = HashMap::new();
4644 annotations.insert("cfgd.io/platform".to_string(), "linux/amd64".to_string());
4645 annotations.insert(
4646 "org.opencontainers.image.created".to_string(),
4647 "2026-01-01T00:00:00Z".to_string(),
4648 );
4649
4650 let manifest = OciManifest {
4651 schema_version: 2,
4652 media_type: MEDIA_TYPE_OCI_MANIFEST.to_string(),
4653 config: OciDescriptor {
4654 media_type: MEDIA_TYPE_MODULE_CONFIG.to_string(),
4655 digest: "sha256:configdigest123".to_string(),
4656 size: 512,
4657 annotations: HashMap::new(),
4658 },
4659 layers: vec![OciDescriptor {
4660 media_type: MEDIA_TYPE_MODULE_LAYER.to_string(),
4661 digest: "sha256:layer1digest".to_string(),
4662 size: 4096,
4663 annotations: HashMap::new(),
4664 }],
4665 annotations: annotations.clone(),
4666 };
4667
4668 let json = serde_json::to_string_pretty(&manifest).unwrap();
4669 let parsed: OciManifest = serde_json::from_str(&json).unwrap();
4670
4671 assert_eq!(parsed.schema_version, 2);
4672 assert_eq!(parsed.media_type, MEDIA_TYPE_OCI_MANIFEST);
4673 assert_eq!(parsed.config.size, 512);
4674 assert_eq!(parsed.layers.len(), 1);
4675 assert_eq!(parsed.layers[0].size, 4096);
4676 assert_eq!(parsed.annotations.len(), 2);
4677 assert_eq!(
4678 parsed.annotations.get("cfgd.io/platform").unwrap(),
4679 "linux/amd64"
4680 );
4681 }
4682
4683 #[test]
4684 fn oci_manifest_camel_case_keys() {
4685 let manifest = OciManifest {
4686 schema_version: 2,
4687 media_type: MEDIA_TYPE_OCI_MANIFEST.to_string(),
4688 config: OciDescriptor {
4689 media_type: MEDIA_TYPE_MODULE_CONFIG.to_string(),
4690 digest: "sha256:abc".to_string(),
4691 size: 100,
4692 annotations: HashMap::new(),
4693 },
4694 layers: vec![],
4695 annotations: HashMap::new(),
4696 };
4697
4698 let json = serde_json::to_string(&manifest).unwrap();
4699 assert!(
4700 json.contains("\"schemaVersion\""),
4701 "should use camelCase: {json}"
4702 );
4703 assert!(
4704 json.contains("\"mediaType\""),
4705 "should use camelCase: {json}"
4706 );
4707 assert!(
4708 !json.contains("\"schema_version\""),
4709 "should not use snake_case: {json}"
4710 );
4711 }
4712
4713 #[test]
4716 fn docker_config_with_empty_auth_field() {
4717 let config_json = r#"{
4718 "auths": {
4719 "ghcr.io": {
4720 "auth": ""
4721 }
4722 }
4723 }"#;
4724 let config: DockerConfig = serde_json::from_str(config_json).unwrap();
4725 let auth = resolve_from_docker_auths(&config.auths, "ghcr.io");
4727 assert!(auth.is_none(), "empty auth field should not resolve");
4728 }
4729
4730 #[test]
4731 fn docker_config_index_docker_io_fallback() {
4732 let config_json = r#"{
4733 "auths": {
4734 "index.docker.io": {
4735 "auth": "dXNlcjpwYXNz"
4736 }
4737 }
4738 }"#;
4739 let config: DockerConfig = serde_json::from_str(config_json).unwrap();
4740 let auth = resolve_from_docker_auths(&config.auths, "docker.io");
4742 assert!(
4743 auth.is_some(),
4744 "docker.io should fall back to index.docker.io"
4745 );
4746 assert_eq!(auth.unwrap().username, "user");
4747 }
4748
4749 #[test]
4750 fn docker_config_v1_suffix_match() {
4751 let config_json = r#"{
4752 "auths": {
4753 "https://ghcr.io/v1/": {
4754 "auth": "dXNlcjpwYXNz"
4755 }
4756 }
4757 }"#;
4758 let config: DockerConfig = serde_json::from_str(config_json).unwrap();
4759 let auth = resolve_from_docker_auths(&config.auths, "ghcr.io");
4760 assert!(auth.is_some(), "should match https://ghcr.io/v1/");
4761 }
4762
4763 #[test]
4766 fn base64_encode_padding_one_byte() {
4767 let encoded = base64_encode(b"a");
4769 assert_eq!(encoded, "YQ==");
4770 let decoded = base64_decode(&encoded).unwrap();
4771 assert_eq!(decoded, b"a");
4772 }
4773
4774 #[test]
4775 fn base64_encode_padding_two_bytes() {
4776 let encoded = base64_encode(b"ab");
4778 assert_eq!(encoded, "YWI=");
4779 let decoded = base64_decode(&encoded).unwrap();
4780 assert_eq!(decoded, b"ab");
4781 }
4782
4783 #[test]
4784 fn base64_encode_no_padding_three_bytes() {
4785 let encoded = base64_encode(b"abc");
4787 assert_eq!(encoded, "YWJj");
4788 let decoded = base64_decode(&encoded).unwrap();
4789 assert_eq!(decoded, b"abc");
4790 }
4791
4792 #[test]
4793 fn base64_decode_invalid_length_short() {
4794 let result = base64_decode("abc");
4796 assert!(result.is_none(), "invalid length should fail");
4797 }
4798
4799 #[test]
4800 fn base64_decode_invalid_chars_special() {
4801 let result = base64_decode("!!!!");
4802 assert!(result.is_none(), "invalid base64 characters should fail");
4803 }
4804
4805 #[test]
4806 fn base64_roundtrip_binary_data() {
4807 let data: Vec<u8> = (0..=255).collect();
4808 let encoded = base64_encode(&data);
4809 let decoded = base64_decode(&encoded).unwrap();
4810 assert_eq!(decoded, data);
4811 }
4812
4813 #[test]
4816 fn registry_auth_basic_header_format() {
4817 let auth = RegistryAuth {
4818 username: "myuser".to_string(),
4819 password: "mypass".to_string(),
4820 };
4821 let header = auth.basic_auth_header();
4822 assert!(header.starts_with("Basic "), "should start with 'Basic '");
4823
4824 let encoded = header.strip_prefix("Basic ").unwrap();
4826 let decoded = base64_decode(encoded).unwrap();
4827 let cred = String::from_utf8(decoded).unwrap();
4828 assert_eq!(cred, "myuser:mypass");
4829 }
4830
4831 #[test]
4834 fn parse_reference_whitespace_rejected() {
4835 assert!(OciReference::parse("ghcr.io/repo name:v1").is_err());
4836 assert!(OciReference::parse("ghcr.io/repo\tname:v1").is_err());
4837 }
4838
4839 #[test]
4840 fn parse_reference_host_port_no_repo_rejected() {
4841 let result = OciReference::parse("localhost:5000");
4843 assert!(
4845 result.is_err(),
4846 "bare host:port without repo should be rejected, got: {result:?}"
4847 );
4848 }
4849
4850 #[test]
4851 fn reference_str_returns_tag_or_digest() {
4852 let tag_ref = OciReference {
4853 registry: "ghcr.io".to_string(),
4854 repository: "test".to_string(),
4855 reference: ReferenceKind::Tag("v1.0.0".to_string()),
4856 };
4857 assert_eq!(tag_ref.reference_str(), "v1.0.0");
4858
4859 let digest_ref = OciReference {
4860 registry: "ghcr.io".to_string(),
4861 repository: "test".to_string(),
4862 reference: ReferenceKind::Digest("sha256:abc".to_string()),
4863 };
4864 assert_eq!(digest_ref.reference_str(), "sha256:abc");
4865 }
4866
4867 #[test]
4870 fn create_tar_gz_skips_symlinks() {
4871 let dir = tempfile::tempdir().unwrap();
4872 std::fs::write(dir.path().join("real.txt"), "real content").unwrap();
4873 #[cfg(unix)]
4874 std::os::unix::fs::symlink("real.txt", dir.path().join("link.txt")).unwrap();
4875
4876 let archive = create_tar_gz(dir.path()).unwrap();
4877 assert!(!archive.is_empty());
4878
4879 let out = tempfile::tempdir().unwrap();
4881 extract_tar_gz(&archive, out.path()).unwrap();
4882 assert!(out.path().join("real.txt").exists());
4883 assert!(
4885 !out.path().join("link.txt").exists(),
4886 "symlinks should be skipped in create_tar_gz"
4887 );
4888 }
4889
4890 #[test]
4893 fn extract_tar_gz_skips_symlinks_in_archive() {
4894 use flate2::write::GzEncoder;
4896
4897 let buf = Vec::new();
4898 let encoder = GzEncoder::new(buf, flate2::Compression::default());
4899 let mut archive = tar::Builder::new(encoder);
4900
4901 let content = b"normal file";
4903 let mut header = tar::Header::new_gnu();
4904 header.set_size(content.len() as u64);
4905 header.set_mode(0o644);
4906 header.set_cksum();
4907 archive
4908 .append_data(&mut header, "normal.txt", &content[..])
4909 .unwrap();
4910
4911 let mut sym_header = tar::Header::new_gnu();
4913 sym_header.set_entry_type(tar::EntryType::Symlink);
4914 sym_header.set_size(0);
4915 sym_header.set_mode(0o777);
4916 sym_header.set_cksum();
4917 archive
4918 .append_link(&mut sym_header, "evil-link", "/etc/passwd")
4919 .unwrap();
4920
4921 let encoder = archive.into_inner().unwrap();
4922 let compressed = encoder.finish().unwrap();
4923
4924 let out = tempfile::tempdir().unwrap();
4925 extract_tar_gz(&compressed, out.path()).unwrap();
4926
4927 assert!(out.path().join("normal.txt").exists());
4928 assert!(
4929 !out.path().join("evil-link").exists(),
4930 "symlinks should be skipped during extraction"
4931 );
4932 }
4933
4934 #[test]
4937 fn extract_auth_param_missing_param() {
4938 let header = r#"Bearer realm="https://auth.example.com/token""#;
4939 assert!(extract_auth_param(header, "service").is_none());
4940 assert!(extract_auth_param(header, "scope").is_none());
4941 assert!(extract_auth_param(header, "realm").is_some());
4942 }
4943
4944 #[test]
4945 fn extract_auth_param_empty_value_parsed() {
4946 let header = r#"Bearer realm="",service="svc""#;
4947 assert_eq!(extract_auth_param(header, "realm"), Some(""));
4948 assert_eq!(extract_auth_param(header, "service"), Some("svc"));
4949 }
4950
4951 #[test]
4954 fn validate_verify_options_key_only_passes() {
4955 let opts = VerifyOptions {
4956 key: Some("cosign.pub"),
4957 identity: None,
4958 issuer: None,
4959 };
4960 assert!(validate_verify_options(&opts).is_ok());
4961 }
4962
4963 #[test]
4964 fn validate_verify_options_identity_only_passes() {
4965 let opts = VerifyOptions {
4966 key: None,
4967 identity: Some("user@example.com"),
4968 issuer: None,
4969 };
4970 assert!(validate_verify_options(&opts).is_ok());
4971 }
4972
4973 #[test]
4974 fn validate_verify_options_issuer_only_passes() {
4975 let opts = VerifyOptions {
4976 key: None,
4977 identity: None,
4978 issuer: Some("https://accounts.google.com"),
4979 };
4980 assert!(validate_verify_options(&opts).is_ok());
4981 }
4982
4983 #[test]
4984 fn validate_verify_options_all_none_fails() {
4985 let opts = VerifyOptions {
4986 key: None,
4987 identity: None,
4988 issuer: None,
4989 };
4990 let result = validate_verify_options(&opts);
4991 assert!(result.is_err());
4992 let msg = format!("{}", result.unwrap_err());
4993 assert!(
4994 msg.contains("keyless verification requires"),
4995 "should explain the requirement: {msg}"
4996 );
4997 }
4998
4999 #[test]
5002 fn is_insecure_registry_without_env_var() {
5003 let prev = std::env::var("OCI_INSECURE_REGISTRIES").ok();
5005 unsafe {
5006 std::env::remove_var("OCI_INSECURE_REGISTRIES");
5007 }
5008 assert!(!is_insecure_registry("localhost:5000"));
5009 assert!(!is_insecure_registry("ghcr.io"));
5010
5011 unsafe {
5013 if let Some(v) = prev {
5014 std::env::set_var("OCI_INSECURE_REGISTRIES", v);
5015 }
5016 }
5017 }
5018
5019 #[test]
5022 fn oci_index_camel_case_and_round_trip() {
5023 let index = OciIndex {
5024 schema_version: 2,
5025 media_type: MEDIA_TYPE_OCI_INDEX.to_string(),
5026 manifests: vec![OciPlatformManifest {
5027 media_type: MEDIA_TYPE_OCI_MANIFEST.to_string(),
5028 digest: "sha256:abc".to_string(),
5029 size: 100,
5030 platform: OciPlatform {
5031 os: "linux".to_string(),
5032 architecture: "amd64".to_string(),
5033 },
5034 }],
5035 };
5036
5037 let json = serde_json::to_string(&index).unwrap();
5038 assert!(json.contains("\"schemaVersion\""));
5039 assert!(json.contains("\"mediaType\""));
5040 assert!(!json.contains("\"schema_version\""));
5041
5042 let parsed: OciIndex = serde_json::from_str(&json).unwrap();
5043 assert_eq!(parsed.manifests[0].platform.os, "linux");
5044 assert_eq!(parsed.manifests[0].platform.architecture, "amd64");
5045 }
5046
5047 #[test]
5050 fn decode_docker_auth_with_token_password() {
5051 let input = "user:ghp_abc123xyz";
5053 let encoded = base64_encode(input.as_bytes());
5054 let result = decode_docker_auth(&encoded);
5055 assert!(result.is_some());
5056 let auth = result.unwrap();
5057 assert_eq!(auth.username, "user");
5058 assert_eq!(auth.password, "ghp_abc123xyz");
5059 }
5060
5061 #[test]
5064 fn apply_verify_args_with_key_only() {
5065 let mut cmd = std::process::Command::new("echo");
5066 let opts = VerifyOptions {
5067 key: Some("/path/to/cosign.pub"),
5068 identity: None,
5069 issuer: None,
5070 };
5071 apply_verify_args(&mut cmd, &opts);
5072 }
5074
5075 #[test]
5076 fn apply_verify_args_keyless_defaults() {
5077 let mut cmd = std::process::Command::new("echo");
5078 let opts = VerifyOptions {
5079 key: None,
5080 identity: None,
5081 issuer: Some("https://issuer.example.com"),
5082 };
5083 apply_verify_args(&mut cmd, &opts);
5084 }
5086}