1use std::collections::{BTreeMap, HashMap};
4use std::path::{Path, PathBuf};
5
6use anyhow::{Context, Result, bail};
7use docker_credential::DockerCredential;
8use futures_util::future;
9use futures_util::stream::{self, StreamExt, TryStreamExt};
10use itertools::Itertools;
11use oci_client::{
12 Reference, RegistryOperation, client::ImageLayer, config::ConfigFile,
13 manifest::OciImageManifest, secrets::RegistryAuth, token_cache::RegistryTokenType,
14};
15use reqwest::Url;
16use spin_common::sha256;
17use spin_common::ui::quoted_path;
18use spin_common::url::parse_file_url;
19use spin_compose::ComponentSourceLoaderFs;
20use spin_loader::FilesMountStrategy;
21use spin_loader::cache::Cache;
22use spin_locked_app::locked::{
23 ContentPath, ContentRef, LockedApp, LockedComponent, LockedComponentDependency,
24};
25use tokio::fs;
26use walkdir::WalkDir;
27
28use crate::auth::AuthConfig;
29use crate::validate;
30
31pub const SPIN_APPLICATION_MEDIA_TYPE: &str = "application/vnd.fermyon.spin.application.v1+config";
34pub const DATA_MEDIATYPE: &str = "application/vnd.wasm.content.layer.v1+data";
36pub const ARCHIVE_MEDIATYPE: &str = "application/vnd.wasm.content.bundle.v1.tar+gzip";
38const WASM_LAYER_MEDIA_TYPE: &str = "application/vnd.wasm.content.layer.v1+wasm";
40const WASM_LAYER_MEDIA_TYPE_WKG: &str = "application/wasm";
42
43const CONFIG_FILE: &str = "config.json";
44const LATEST_TAG: &str = "latest";
45const MANIFEST_FILE: &str = "manifest.json";
46
47const SPIN_OCI_ARCHIVE_LAYERS_OPT: &str = "SPIN_OCI_ARCHIVE_LAYERS";
49
50const MAX_PARALLEL_PULL: usize = 16;
51const MAX_LAYER_COUNT: usize = 500;
55
56const DEFAULT_CONTENT_REF_INLINE_MAX_SIZE: usize = 128;
59
60const DEFAULT_TOKEN_EXPIRATION_SECS: usize = 300;
65
66#[derive(Copy, Clone)]
68enum AssemblyMode {
69 Simple,
72 Archive,
75}
76
77#[derive(Copy, Clone)]
79pub enum ComposeMode {
80 All,
82 Skip,
84}
85
86pub struct Client {
88 pub cache: Cache,
90 oci: oci_client::Client,
92 pub opts: ClientOpts,
94}
95
96#[derive(Clone)]
97pub struct ClientOpts {
99 pub content_ref_inline_max_size: usize,
101}
102
103#[derive(Debug, PartialEq)]
107pub enum InferPredefinedAnnotations {
108 All,
110 None,
112}
113
114impl Client {
115 pub async fn new(insecure: bool, cache_root: Option<PathBuf>) -> Result<Self> {
117 let client = oci_client::Client::new(Self::build_config(insecure));
118 let cache = Cache::new(cache_root).await?;
119 let opts = ClientOpts {
120 content_ref_inline_max_size: DEFAULT_CONTENT_REF_INLINE_MAX_SIZE,
121 };
122
123 Ok(Self {
124 oci: client,
125 cache,
126 opts,
127 })
128 }
129
130 pub async fn push(
133 &mut self,
134 manifest_path: &Path,
135 profile: Option<&str>,
136 reference: impl AsRef<str>,
137 annotations: Option<BTreeMap<String, String>>,
138 infer_annotations: InferPredefinedAnnotations,
139 compose_mode: ComposeMode,
140 ) -> Result<Option<String>> {
141 let reference: Reference = reference
142 .as_ref()
143 .parse()
144 .with_context(|| format!("cannot parse reference {}", reference.as_ref()))?;
145 let auth = Self::auth(&reference).await?;
146 let working_dir = tempfile::tempdir()?;
147
148 let locked = spin_loader::from_file(
152 manifest_path,
153 FilesMountStrategy::Copy(working_dir.path().into()),
154 profile,
155 None,
156 )
157 .await?;
158
159 for locked_component in &locked.components {
162 validate::ensure_wasms(locked_component).await?;
163 }
164
165 self.push_locked_core(
166 locked,
167 auth,
168 reference,
169 annotations,
170 infer_annotations,
171 compose_mode,
172 )
173 .await
174 }
175
176 pub async fn push_locked(
179 &mut self,
180 locked: LockedApp,
181 reference: impl AsRef<str>,
182 annotations: Option<BTreeMap<String, String>>,
183 infer_annotations: InferPredefinedAnnotations,
184 compose_mode: ComposeMode,
185 ) -> Result<Option<String>> {
186 let reference: Reference = reference
187 .as_ref()
188 .parse()
189 .with_context(|| format!("cannot parse reference {}", reference.as_ref()))?;
190 let auth = Self::auth(&reference).await?;
191
192 self.push_locked_core(
193 locked,
194 auth,
195 reference,
196 annotations,
197 infer_annotations,
198 compose_mode,
199 )
200 .await
201 }
202
203 async fn push_locked_core(
206 &mut self,
207 locked: LockedApp,
208 auth: RegistryAuth,
209 reference: Reference,
210 annotations: Option<BTreeMap<String, String>>,
211 infer_annotations: InferPredefinedAnnotations,
212 compose_mode: ComposeMode,
213 ) -> Result<Option<String>> {
214 let mut locked_app = locked.clone();
215 let mut layers = self
216 .assemble_layers(&mut locked_app, AssemblyMode::Simple, compose_mode)
217 .await
218 .context("could not assemble layers for locked application")?;
219
220 if std::env::var(SPIN_OCI_ARCHIVE_LAYERS_OPT).is_ok() || layers.len() > MAX_LAYER_COUNT - 1
224 {
225 locked_app = locked.clone();
226 layers = self
227 .assemble_layers(&mut locked_app, AssemblyMode::Archive, compose_mode)
228 .await
229 .context("could not assemble archive layers for locked application")?;
230 }
231
232 let annotations = all_annotations(&locked_app, annotations, infer_annotations);
233
234 let locked_config_layer = ImageLayer::new(
236 serde_json::to_vec(&locked_app).context("could not serialize locked config")?,
237 SPIN_APPLICATION_MEDIA_TYPE.to_string(),
238 None,
239 );
240 let config_layer_digest = locked_config_layer.sha256_digest().clone();
241 layers.push(locked_config_layer);
242
243 let mut labels = HashMap::new();
244 labels.insert(
245 "com.fermyon.spin.lockedAppDigest".to_string(),
246 config_layer_digest,
247 );
248 let cfg = oci_client::config::Config {
249 labels: Some(labels),
250 ..Default::default()
251 };
252
253 let oci_config_file = ConfigFile {
258 architecture: oci_client::config::Architecture::Wasm,
259 os: oci_client::config::Os::Other("Wasip1".to_string()),
260 config: Some(cfg),
265 ..Default::default()
266 };
267 let oci_config =
268 oci_client::client::Config::oci_v1_from_config_file(oci_config_file, None)?;
269 let manifest = OciImageManifest::build(&layers, &oci_config, annotations);
270
271 let response = self
272 .oci
273 .push(&reference, &layers, oci_config, &auth, Some(manifest))
274 .await
275 .map(|push_response| push_response.manifest_url)
276 .context("cannot push Spin application")?;
277
278 tracing::info!("Pushed {:?}", response);
279
280 let digest = digest_from_url(&response);
281 Ok(digest)
282 }
283
284 async fn assemble_layers(
287 &mut self,
288 locked: &mut LockedApp,
289 assembly_mode: AssemblyMode,
290 compose_mode: ComposeMode,
291 ) -> Result<Vec<ImageLayer>> {
292 let (mut layers, components) = match compose_mode {
293 ComposeMode::All => {
294 self.assemble_layers_composed(assembly_mode, locked.clone())
295 .await?
296 }
297 ComposeMode::Skip => {
298 self.assemble_layers_uncomposed(assembly_mode, locked.clone())
299 .await?
300 }
301 };
302
303 locked.components = components;
304 locked.metadata.remove("origin");
305
306 layers = layers.into_iter().unique().collect();
308
309 Ok(layers)
310 }
311
312 async fn assemble_layers_uncomposed(
313 &mut self,
314 assembly_mode: AssemblyMode,
315 locked: LockedApp,
316 ) -> Result<(Vec<ImageLayer>, Vec<LockedComponent>)> {
317 let mut components = Vec::new();
318 let mut layers = Vec::new();
319
320 for mut c in locked.components {
321 let source = c
323 .source
324 .content
325 .source
326 .as_ref()
327 .context("component loaded from disk should contain a file source")?;
328
329 let source = parse_file_url(source.as_str())?;
330 let layer = Self::wasm_layer(&source).await?;
331
332 c.source.content = self.content_ref_for_layer(&layer);
334
335 layers.push(layer);
336
337 for dep in c.dependencies.values_mut() {
338 self.assemble_dependency_layer(dep, &mut layers).await?;
339 }
340
341 for deps in c.trigger_dependencies.values_mut() {
342 for dep in deps {
343 self.assemble_dependency_layer(dep, &mut layers).await?;
344 }
345 }
346
347 c.files = self
348 .assemble_content_layers(assembly_mode, &mut layers, c.files.as_slice())
349 .await?;
350 components.push(c);
351 }
352
353 Ok((layers, components))
354 }
355
356 async fn assemble_dependency_layer(
359 &self,
360 dep: &mut LockedComponentDependency,
361 layers: &mut Vec<ImageLayer>,
362 ) -> Result<()> {
363 let source = dep
364 .source
365 .content
366 .source
367 .as_ref()
368 .context("dependency loaded from disk should contain a file source")?;
369 let source = parse_file_url(source.as_str())?;
370
371 let layer = Self::wasm_layer(&source).await?;
372
373 dep.source.content = self.content_ref_for_layer(&layer);
374 layers.push(layer);
375
376 Ok(())
377 }
378
379 async fn assemble_layers_composed(
380 &mut self,
381 assembly_mode: AssemblyMode,
382 locked: LockedApp,
383 ) -> Result<(Vec<ImageLayer>, Vec<LockedComponent>)> {
384 let mut components = Vec::new();
385 let mut layers = Vec::new();
386
387 let temp_dir =
388 tempfile::tempdir().context("unable to create tempdir for precomposition")?;
389 let working_dir = temp_dir.path();
390 let locked_url = write_locked_app(&locked, working_dir)
391 .await
392 .context("unable to write locked app for precomposition")?;
393
394 for mut c in locked.components {
395 let trigger_deps = &c.trigger_dependencies;
396
397 let composed = if trigger_deps.is_empty() {
398 spin_compose::compose(&ComponentSourceLoaderFs, &c, async |a| Ok(a)).await?
399 } else {
400 precompose_using_trigger(&c, &locked_url, working_dir).await?
403 };
404
405 let layer = ImageLayer::new(composed, WASM_LAYER_MEDIA_TYPE.to_string(), None);
406 c.source.content = self.content_ref_for_layer(&layer);
407 c.dependencies.clear();
408 c.trigger_dependencies.clear();
409 layers.push(layer);
410
411 c.files = self
412 .assemble_content_layers(assembly_mode, &mut layers, c.files.as_slice())
413 .await?;
414 components.push(c);
415 }
416
417 async fn write_locked_app(
419 locked_app: &LockedApp,
420 working_dir: &Path,
421 ) -> Result<String, anyhow::Error> {
422 let locked_path = working_dir.join("spin.lock");
423 let locked_app_contents =
424 serde_json::to_vec_pretty(&locked_app).context("failed to serialize locked app")?;
425 tokio::fs::write(&locked_path, locked_app_contents)
426 .await
427 .with_context(|| format!("failed to write {}", quoted_path(&locked_path)))?;
428 let locked_url = Url::from_file_path(&locked_path)
429 .map_err(|_| {
430 anyhow::anyhow!("cannot convert to file URL: {}", quoted_path(&locked_path))
431 })?
432 .to_string();
433
434 Ok(locked_url)
435 }
436
437 Ok((layers, components))
438 }
439
440 async fn assemble_content_layers(
441 &mut self,
442 assembly_mode: AssemblyMode,
443 layers: &mut Vec<ImageLayer>,
444 contents: &[ContentPath],
445 ) -> Result<Vec<ContentPath>> {
446 let mut files = Vec::new();
447 for f in contents {
448 let source = f
449 .content
450 .source
451 .as_ref()
452 .context("file mount loaded from disk should contain a file source")?;
453 let source = parse_file_url(source.as_str())?;
454
455 match assembly_mode {
456 AssemblyMode::Archive => self
457 .push_archive_layer(&source, &mut files, layers)
458 .await
459 .context(format!(
460 "cannot push archive layer for source {}",
461 quoted_path(&source)
462 ))?,
463 AssemblyMode::Simple => self
464 .push_file_layers(&source, &mut files, layers)
465 .await
466 .context(format!(
467 "cannot push file layers for source {}",
468 quoted_path(&source)
469 ))?,
470 }
471 }
472 Ok(files)
473 }
474
475 async fn push_archive_layer(
478 &mut self,
479 source: &PathBuf,
480 files: &mut Vec<ContentPath>,
481 layers: &mut Vec<ImageLayer>,
482 ) -> Result<()> {
483 for entry in WalkDir::new(source) {
485 let entry = entry?;
486 if !entry.file_type().is_file() {
487 continue;
488 }
489 let rel_path = entry.path().strip_prefix(source).unwrap();
491 tracing::trace!("Adding asset {rel_path:?} to component files list");
492 let layer = Self::data_layer(entry.path(), DATA_MEDIATYPE.to_string()).await?;
494 let content = self.content_ref_for_layer(&layer);
495 files.push(ContentPath {
496 content,
497 path: rel_path.into(),
498 });
499 }
500
501 tracing::trace!("Adding archive layer for all files in source {:?}", &source);
503 let working_dir = tempfile::tempdir()?;
504 let archive_path = crate::utils::archive(source, &working_dir.keep())
505 .await
506 .context(format!(
507 "Unable to create compressed archive for source {source:?}"
508 ))?;
509 let layer = Self::data_layer(archive_path.as_path(), ARCHIVE_MEDIATYPE.to_string()).await?;
510 layers.push(layer);
511 Ok(())
512 }
513
514 async fn push_file_layers(
516 &mut self,
517 source: &PathBuf,
518 files: &mut Vec<ContentPath>,
519 layers: &mut Vec<ImageLayer>,
520 ) -> Result<()> {
521 tracing::trace!("Adding new layer per file under source {:?}", source);
524 for entry in WalkDir::new(source) {
525 let entry = entry?;
526 if !entry.file_type().is_file() {
527 continue;
528 }
529 let rel_path = entry.path().strip_prefix(source).unwrap();
531 let rel_path = portable_path(rel_path);
534
535 tracing::trace!("Adding new layer for asset {rel_path:?}");
536 let layer = Self::data_layer(entry.path(), DATA_MEDIATYPE.to_string()).await?;
538 let content = self.content_ref_for_layer(&layer);
539 let content_inline = content.inline.is_some();
540 files.push(ContentPath {
541 content,
542 path: rel_path,
543 });
544 let skip_layer = content_inline;
548 if !skip_layer {
549 layers.push(layer);
550 }
551 }
552 Ok(())
553 }
554
555 pub async fn pull(&mut self, reference: &str) -> Result<OciImageManifest> {
557 let (manifest, _digest) = self.pull_with_digest(reference).await?;
558 Ok(manifest)
559 }
560
561 pub async fn pull_with_digest(
568 &mut self,
569 reference: &str,
570 ) -> Result<(OciImageManifest, String)> {
571 let reference: Reference = reference.parse().context("cannot parse reference")?;
572 let auth = Self::auth(&reference).await?;
573
574 let (manifest, digest) = self.oci.pull_image_manifest(&reference, &auth).await?;
576
577 let manifest_json = serde_json::to_string(&manifest)?;
578 tracing::debug!("Pulled manifest: {}", manifest_json);
579
580 let pinned = Reference::with_digest(
584 reference.registry().to_owned(),
585 reference.repository().to_owned(),
586 digest.clone(),
587 );
588 let mut cache_refs = vec![pinned.whole()];
589 if reference.digest().is_none() {
590 cache_refs.push(reference.whole());
591 }
592
593 let locked_app_descriptor = locked_app_descriptor(&manifest)?;
596 let mut locked_app_bytes = Vec::new();
597 self.oci
598 .pull_blob(&reference, locked_app_descriptor, &mut locked_app_bytes)
599 .await?;
600
601 for cache_ref in &cache_refs {
602 let m = self.manifest_path(cache_ref).await?;
603 write_atomically(&m, manifest_json.as_bytes()).await?;
604 self.write_locked_app_config(cache_ref, &locked_app_bytes)
605 .await
606 .context("unable to write locked app config to cache")?;
607 }
608
609 stream::iter(&manifest.layers)
612 .filter(|layer| future::ready(layer.media_type != SPIN_APPLICATION_MEDIA_TYPE))
613 .map(|layer| {
614 let this = &self;
615 let reference = reference.clone();
616 async move {
617 if this.cache.wasm_file(&layer.digest).is_ok()
619 || this.cache.data_file(&layer.digest).is_ok()
620 {
621 tracing::debug!("Layer {} already exists in cache", &layer.digest);
622 return anyhow::Ok(());
623 }
624
625 tracing::debug!("Pulling layer {}", &layer.digest);
626 let mut bytes = Vec::with_capacity(layer.size.try_into()?);
627 this.oci.pull_blob(&reference, layer, &mut bytes).await?;
628 match layer.media_type.as_str() {
629 WASM_LAYER_MEDIA_TYPE | WASM_LAYER_MEDIA_TYPE_WKG => {
630 this.cache.write_wasm(&bytes, &layer.digest).await?;
631 }
632 ARCHIVE_MEDIATYPE => {
633 unpack_archive_layer(&this.cache, &bytes, &layer.digest).await?;
634 }
635 _ => {
636 this.cache.write_data(&bytes, &layer.digest).await?;
637 }
638 }
639 Ok(())
640 }
641 })
642 .buffer_unordered(MAX_PARALLEL_PULL)
643 .try_for_each(future::ok)
644 .await?;
645 tracing::info!("Pulled {}@{}", reference, digest);
646
647 Ok((manifest, digest))
648 }
649
650 async fn reference_cache_dir(&self, reference: impl AsRef<str>) -> Result<PathBuf> {
656 let reference: Reference = reference
657 .as_ref()
658 .parse()
659 .context("cannot parse OCI reference")?;
660 let segment = match reference.digest() {
661 Some(digest) => digest_cache_segment(digest)?,
662 None => reference.tag().unwrap_or(LATEST_TAG).to_owned(),
663 };
664 let p = self
665 .cache
666 .manifests_dir()
667 .join(fs_safe_segment(reference.registry()))
668 .join(reference.repository())
669 .join(segment);
670
671 if !p.is_dir() {
672 fs::create_dir_all(&p).await.with_context(|| {
673 format!(
674 "cannot create cache directory {} for OCI reference",
675 p.display()
676 )
677 })?;
678 }
679
680 Ok(p)
681 }
682
683 async fn manifest_path(&self, reference: impl AsRef<str>) -> Result<PathBuf> {
686 Ok(self
687 .reference_cache_dir(reference)
688 .await?
689 .join(MANIFEST_FILE))
690 }
691
692 pub async fn lockfile_path(&self, reference: impl AsRef<str>) -> Result<PathBuf> {
701 Ok(self.reference_cache_dir(reference).await?.join(CONFIG_FILE))
702 }
703
704 async fn write_locked_app_config(
706 &self,
707 reference: impl AsRef<str>,
708 bytes: impl AsRef<[u8]>,
709 ) -> Result<()> {
710 let cfg = std::str::from_utf8(bytes.as_ref())?;
711 tracing::debug!("Pulled config: {}", cfg);
712
713 let c = self.lockfile_path(reference).await?;
714 write_atomically(&c, cfg.as_bytes()).await
715 }
716
717 async fn wasm_layer(file: &Path) -> Result<ImageLayer> {
719 tracing::trace!("Reading wasm module from {:?}", file);
720 Ok(ImageLayer::new(
721 fs::read(file)
722 .await
723 .with_context(|| format!("cannot read wasm module {}", quoted_path(file)))?,
724 WASM_LAYER_MEDIA_TYPE.to_string(),
725 None,
726 ))
727 }
728
729 async fn data_layer(file: &Path, media_type: String) -> Result<ImageLayer> {
731 tracing::trace!("Reading data file from {:?}", file);
732 Ok(ImageLayer::new(
733 fs::read(&file)
734 .await
735 .with_context(|| format!("cannot read file {}", quoted_path(file)))?,
736 media_type,
737 None,
738 ))
739 }
740
741 fn content_ref_for_layer(&self, layer: &ImageLayer) -> ContentRef {
742 ContentRef {
743 inline: (layer.data.len() <= self.opts.content_ref_inline_max_size)
746 .then(|| layer.data.to_vec()),
747 digest: Some(layer.sha256_digest()),
748 ..Default::default()
749 }
750 }
751
752 pub async fn login(
754 server: impl AsRef<str>,
755 username: impl AsRef<str>,
756 password: impl AsRef<str>,
757 ) -> Result<()> {
758 let registry = registry_from_input(server);
759
760 Self::validate_credentials(®istry, &username, &password).await?;
764
765 let mut auth = AuthConfig::load_default().await?;
767 auth.insert(registry, username, password)?;
768 auth.save_default().await
769 }
770
771 pub async fn insert_token(
773 &mut self,
774 reference: &Reference,
775 op: RegistryOperation,
776 token: RegistryTokenType,
777 ) {
778 self.oci.tokens.insert(reference, op, token).await;
779 }
780
781 async fn validate_credentials(
783 server: impl AsRef<str>,
784 username: impl AsRef<str>,
785 password: impl AsRef<str>,
786 ) -> Result<()> {
787 let client = docker_registry::v2::Client::configure()
788 .registry(server.as_ref())
789 .insecure_registry(false)
790 .username(Some(username.as_ref().into()))
791 .password(Some(password.as_ref().into()))
792 .build()
793 .context("cannot create client to send authentication request to the registry")?;
794
795 match client
796 .authenticate(&[""])
799 .await
800 {
801 Ok(_) => Ok(()),
802 Err(e) => bail!(format!(
803 "cannot authenticate as {} to registry {}: {}",
804 username.as_ref(),
805 server.as_ref(),
806 e
807 )),
808 }
809 }
810
811 async fn auth(reference: &Reference) -> Result<RegistryAuth> {
813 let server = reference
814 .resolve_registry()
815 .strip_suffix('/')
816 .unwrap_or_else(|| reference.resolve_registry());
817
818 match AuthConfig::get_auth_from_default(server).await {
819 Ok(c) => Ok(c),
820 Err(_) => match docker_credential::get_credential(server) {
821 Err(e) => {
822 tracing::trace!(
823 "Cannot retrieve credentials from Docker, attempting to use anonymous auth: {}",
824 e
825 );
826 Ok(RegistryAuth::Anonymous)
827 }
828
829 Ok(DockerCredential::UsernamePassword(username, password)) => {
830 tracing::trace!("Found Docker credentials");
831 Ok(RegistryAuth::Basic(username, password))
832 }
833 Ok(DockerCredential::IdentityToken(_)) => {
834 tracing::trace!(
835 "Cannot use contents of Docker config, identity token not supported. Using anonymous auth"
836 );
837 Ok(RegistryAuth::Anonymous)
838 }
839 },
840 }
841 }
842
843 fn build_config(insecure: bool) -> oci_client::client::ClientConfig {
845 let protocol = if insecure {
846 oci_client::client::ClientProtocol::Http
847 } else {
848 oci_client::client::ClientProtocol::Https
849 };
850
851 oci_client::client::ClientConfig {
852 protocol,
853 default_token_expiration_secs: DEFAULT_TOKEN_EXPIRATION_SECS,
854 ..Default::default()
855 }
856 }
857}
858
859pub async fn unpack_archive_layer(
864 cache: &Cache,
865 bytes: impl AsRef<[u8]>,
866 digest: impl AsRef<str>,
867) -> Result<()> {
868 cache.write_data(&bytes, &digest).await?;
870
871 let path = cache
873 .data_file(&digest)
874 .context("unable to read archive layer from cache")?;
875 let staging_dir = tempfile::tempdir()?;
876 crate::utils::unarchive(path.as_ref(), staging_dir.path()).await?;
877
878 for entry in WalkDir::new(staging_dir.path()) {
881 let entry = entry?;
882 if entry.file_type().is_file() && !entry.file_type().is_dir() {
883 let bytes = tokio::fs::read(entry.path()).await?;
884 let digest = format!("sha256:{}", sha256::hex_digest_from_bytes(&bytes));
885 if cache.data_file(&digest).is_ok() {
886 tracing::debug!(
887 "Skipping unpacked asset {:?}; file already exists",
888 entry.path()
889 );
890 } else {
891 tracing::debug!("Adding unpacked asset {:?} to cache", entry.path());
892 cache.write_data(bytes, &digest).await?;
893 }
894 }
895 }
896 Ok(())
897}
898
899fn digest_from_url(manifest_url: &str) -> Option<String> {
900 let manifest_url = Url::parse(manifest_url).ok()?;
902 let mut segments = manifest_url.path_segments()?;
903 let last = segments.next_back()?;
904 if last.contains(':') {
905 Some(last.to_owned())
906 } else {
907 None
908 }
909}
910
911fn registry_from_input(server: impl AsRef<str>) -> String {
912 let server = server.as_ref();
914 let server = match server.parse::<Url>() {
915 Ok(url) => url.host_str().unwrap_or(server).to_string(),
916 Err(_) => server.to_string(),
917 };
918 match server.as_str() {
920 "docker.io" => "index.docker.io".to_string(),
921 _ => server,
922 }
923}
924
925fn all_annotations(
926 locked_app: &LockedApp,
927 explicit: Option<BTreeMap<String, String>>,
928 predefined: InferPredefinedAnnotations,
929) -> Option<BTreeMap<String, String>> {
930 use spin_locked_app::{APP_DESCRIPTION_KEY, APP_NAME_KEY, APP_VERSION_KEY, MetadataKey};
931 const APP_AUTHORS_KEY: MetadataKey<Vec<String>> = MetadataKey::new("authors");
932
933 if predefined == InferPredefinedAnnotations::None {
934 return explicit;
935 }
936
937 let mut current = explicit.unwrap_or_default();
940
941 let authors = locked_app
942 .get_metadata(APP_AUTHORS_KEY)
943 .unwrap_or_default()
944 .unwrap_or_default();
945 if !authors.is_empty() {
946 let authors = authors.join(", ");
947 add_inferred(
948 &mut current,
949 oci_client::annotations::ORG_OPENCONTAINERS_IMAGE_AUTHORS,
950 Some(authors),
951 );
952 }
953
954 let name = locked_app.get_metadata(APP_NAME_KEY).unwrap_or_default();
955 add_inferred(
956 &mut current,
957 oci_client::annotations::ORG_OPENCONTAINERS_IMAGE_TITLE,
958 name,
959 );
960
961 let description = locked_app
962 .get_metadata(APP_DESCRIPTION_KEY)
963 .unwrap_or_default();
964 add_inferred(
965 &mut current,
966 oci_client::annotations::ORG_OPENCONTAINERS_IMAGE_DESCRIPTION,
967 description,
968 );
969
970 let version = locked_app.get_metadata(APP_VERSION_KEY).unwrap_or_default();
971 add_inferred(
972 &mut current,
973 oci_client::annotations::ORG_OPENCONTAINERS_IMAGE_VERSION,
974 version,
975 );
976
977 let created = chrono::Utc::now().to_rfc3339();
978 add_inferred(
979 &mut current,
980 oci_client::annotations::ORG_OPENCONTAINERS_IMAGE_CREATED,
981 Some(created),
982 );
983
984 Some(current)
985}
986
987fn add_inferred(map: &mut BTreeMap<String, String>, key: &str, value: Option<String>) {
988 if let Some(value) = value
989 && let std::collections::btree_map::Entry::Vacant(e) = map.entry(key.to_string())
990 {
991 e.insert(value);
992 }
993}
994
995const SPIN_LOCKED_URL: &str = "SPIN_LOCKED_URL";
996const SPIN_WORKING_DIR: &str = "SPIN_WORKING_DIR";
997
998async fn precompose_using_trigger(
999 c: &LockedComponent,
1000 locked_url: &str,
1001 working_dir: &Path,
1002) -> Result<Vec<u8>, spin_compose::ComposeError> {
1003 use spin_compose::ComposeError;
1004
1005 let Some(resolve_extras_using) = c
1006 .metadata
1007 .get("resolve-trigger-dependencies-using")
1008 .and_then(|v| v.as_str())
1009 else {
1010 return spin_compose::compose(&ComponentSourceLoaderFs, c, async |a| Ok(a)).await;
1011 };
1012
1013 let resolver_subcmd = match resolve_extras_using {
1014 "http" | "redis" => vec!["trigger".into(), resolve_extras_using.into()],
1015 _ => vec![format!("trigger-{resolve_extras_using}")],
1016 };
1017
1018 let mut cmd = tokio::process::Command::new(std::env::current_exe().unwrap());
1019 cmd.args(resolver_subcmd)
1020 .args(["--precompose-only", "--precompose-component-id"])
1021 .arg(&c.id)
1022 .stdout(std::process::Stdio::piped())
1023 .stderr(std::process::Stdio::inherit())
1024 .env("SPIN_PLUGINS_SUPPRESS_COMPATIBILITY_WARNINGS", "1")
1025 .env(SPIN_LOCKED_URL, locked_url)
1026 .env(SPIN_WORKING_DIR, working_dir);
1027
1028 let child = cmd
1029 .spawn()
1030 .map_err(|e| ComposeError::PrepareError(e.into()))?;
1031
1032 let trigger_out = child
1033 .wait_with_output()
1034 .await
1035 .map_err(|e| ComposeError::PrepareError(e.into()))?;
1036
1037 if !trigger_out.status.success() {
1038 return Err(ComposeError::PrepareError(anyhow::anyhow!(
1039 "unable to compose additional components for {} using `{}`",
1040 c.id,
1041 resolve_extras_using
1042 )));
1043 }
1044
1045 let composed = trigger_out.stdout;
1046 Ok(composed)
1047}
1048
1049#[cfg(target_os = "windows")]
1052fn portable_path(rel_path: &Path) -> PathBuf {
1053 assert!(
1054 rel_path.is_relative(),
1055 "portable_path requires paths to be relative"
1056 );
1057 let portable_path = rel_path.to_string_lossy().replace('\\', "/");
1058 PathBuf::from(portable_path)
1059}
1060
1061#[cfg(not(target_os = "windows"))]
1065fn portable_path(rel_path: &Path) -> PathBuf {
1066 rel_path.into()
1067}
1068
1069pub(crate) fn locked_app_descriptor(
1073 manifest: &OciImageManifest,
1074) -> Result<&oci_client::manifest::OciDescriptor> {
1075 let mut app_layers = manifest
1076 .layers
1077 .iter()
1078 .filter(|layer| layer.media_type == SPIN_APPLICATION_MEDIA_TYPE);
1079 match (app_layers.next(), app_layers.next()) {
1080 (None, _) => Ok(&manifest.config),
1081 (Some(layer), None) => Ok(layer),
1082 (Some(_), Some(_)) => bail!("OCI manifest contains more than one locked app layer"),
1083 }
1084}
1085
1086pub(crate) fn verify_digest(content: &[u8], digest: &str) -> Result<()> {
1088 use sha2::Digest;
1089
1090 let Some((algorithm, expected)) = digest.split_once(':') else {
1091 bail!("invalid digest {digest:?}");
1092 };
1093 let actual = match algorithm {
1094 "sha256" => format!("{:x}", sha2::Sha256::digest(content)),
1095 "sha384" => format!("{:x}", sha2::Sha384::digest(content)),
1096 "sha512" => format!("{:x}", sha2::Sha512::digest(content)),
1097 _ => bail!("unsupported digest algorithm in {digest:?}"),
1098 };
1099 if actual != expected {
1100 bail!("content does not match digest {digest} (found {algorithm}:{actual})");
1101 }
1102 Ok(())
1103}
1104
1105fn digest_cache_segment(digest: &str) -> Result<String> {
1109 let valid = digest.split_once(':').is_some_and(|(algorithm, encoded)| {
1110 !algorithm.is_empty()
1111 && !encoded.is_empty()
1112 && algorithm
1113 .bytes()
1114 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b"+._-".contains(&b))
1115 && encoded
1116 .bytes()
1117 .all(|b| b.is_ascii_alphanumeric() || b"=_-".contains(&b))
1118 });
1119 if !valid {
1120 bail!("invalid manifest digest {digest:?}");
1121 }
1122 Ok(format!("@{}", digest.replace(':', "_")))
1123}
1124
1125async fn write_atomically(path: &Path, bytes: &[u8]) -> Result<()> {
1129 let dir = path
1130 .parent()
1131 .with_context(|| format!("{} has no parent directory", quoted_path(path)))?;
1132 let mut builder = tempfile::Builder::new();
1135 #[cfg(unix)]
1136 {
1137 use std::os::unix::fs::PermissionsExt;
1138 builder.permissions(std::fs::Permissions::from_mode(0o666));
1139 }
1140 let tmp = builder
1141 .tempfile_in(dir)
1142 .with_context(|| format!("cannot create temporary file in {}", quoted_path(dir)))?;
1143 fs::write(tmp.path(), bytes).await?;
1144 tmp.persist(path)
1145 .with_context(|| format!("cannot write {}", quoted_path(path)))?;
1146 Ok(())
1147}
1148
1149#[cfg(target_os = "windows")]
1152fn fs_safe_segment(segment: &str) -> impl AsRef<Path> {
1153 segment.replace(':', "_")
1154}
1155
1156#[cfg(not(target_os = "windows"))]
1160fn fs_safe_segment(segment: &str) -> impl AsRef<Path> + '_ {
1161 segment
1162}
1163
1164#[cfg(test)]
1165mod test {
1166 use super::*;
1167 use wit_parser::{LiftLowerAbi, ManglingAndAbi};
1168
1169 #[test]
1170 fn can_parse_digest_from_manifest_url() {
1171 let manifest_url = "https://ghcr.io/v2/itowlson/osf/manifests/sha256:0a867093096e0ef01ef749b12b6e7a90e4952eda107f89a676eeedce63a8361f";
1172 let digest = digest_from_url(manifest_url).unwrap();
1173 assert_eq!(
1174 "sha256:0a867093096e0ef01ef749b12b6e7a90e4952eda107f89a676eeedce63a8361f",
1175 digest
1176 );
1177 }
1178
1179 #[test]
1180 fn can_derive_registry_from_input() {
1181 #[derive(Clone)]
1182 struct TestCase {
1183 input: &'static str,
1184 want: &'static str,
1185 }
1186 let tests: Vec<TestCase> = [
1187 TestCase {
1188 input: "docker.io",
1189 want: "index.docker.io",
1190 },
1191 TestCase {
1192 input: "index.docker.io",
1193 want: "index.docker.io",
1194 },
1195 TestCase {
1196 input: "https://ghcr.io",
1197 want: "ghcr.io",
1198 },
1199 ]
1200 .to_vec();
1201
1202 for tc in tests {
1203 assert_eq!(tc.want, registry_from_input(tc.input));
1204 }
1205 }
1206
1207 #[macro_export]
1209 #[allow(missing_docs)] macro_rules! from_json {
1211 ($($json:tt)+) => {
1212 serde_json::from_value(serde_json::json!($($json)+)).expect("valid json")
1213 };
1214 }
1215
1216 fn file_url(path: impl AsRef<Path>) -> String {
1217 Url::from_file_path(path)
1218 .expect("should convert test path to file URL")
1219 .to_string()
1220 }
1221
1222 #[tokio::test]
1223 async fn can_assemble_layers() {
1224 use spin_locked_app::locked::LockedComponent;
1225 use tokio::io::AsyncWriteExt;
1226
1227 let working_dir = tempfile::tempdir().unwrap();
1228
1229 let _ = tokio::fs::create_dir(working_dir.path().join("component1").as_path()).await;
1233 let _ = tokio::fs::create_dir(working_dir.path().join("component2").as_path()).await;
1234
1235 let mut c1 = tokio::fs::File::create(working_dir.path().join("component1.wasm"))
1237 .await
1238 .expect("should create component wasm file");
1239 c1.write_all(b"c1")
1240 .await
1241 .expect("should write component wasm contents");
1242 let mut c2 = tokio::fs::File::create(working_dir.path().join("component2.wasm"))
1243 .await
1244 .expect("should create component wasm file");
1245 c2.write_all(b"c2")
1246 .await
1247 .expect("should write component wasm contents");
1248
1249 let mut c1f1 = tokio::fs::File::create(working_dir.path().join("component1").join("bar"))
1251 .await
1252 .expect("should create component file");
1253 c1f1.write_all(b"bar")
1254 .await
1255 .expect("should write file contents");
1256 let mut c1f2 = tokio::fs::File::create(working_dir.path().join("component1").join("baz"))
1257 .await
1258 .expect("should create component file");
1259 c1f2.write_all(b"baz")
1260 .await
1261 .expect("should write file contents");
1262
1263 let mut c2f1 = tokio::fs::File::create(working_dir.path().join("component2").join("baz"))
1265 .await
1266 .expect("should create component file");
1267 c2f1.write_all(b"baz")
1268 .await
1269 .expect("should write file contents");
1270
1271 const TEST_WIT: &str = "
1273 package test:test;
1274
1275 interface a {
1276 a: func();
1277 }
1278
1279 world dep-a {
1280 export a;
1281 }
1282
1283 world root {
1284 import a;
1285 }
1286 ";
1287
1288 let root = generate_dummy_component(TEST_WIT, "root");
1289 let dep_a = generate_dummy_component(TEST_WIT, "dep-a");
1290
1291 let mut r = tokio::fs::File::create(working_dir.path().join("root.wasm"))
1292 .await
1293 .expect("should create component wasm file");
1294 r.write_all(&root)
1295 .await
1296 .expect("should write component wasm contents");
1297
1298 let mut a = tokio::fs::File::create(working_dir.path().join("dep_a.wasm"))
1299 .await
1300 .expect("should create component wasm file");
1301 a.write_all(&dep_a)
1302 .await
1303 .expect("should write component wasm contents");
1304
1305 #[derive(Clone)]
1306 struct TestCase {
1307 name: &'static str,
1308 opts: Option<ClientOpts>,
1309 locked_components: Vec<LockedComponent>,
1310 expected_layer_count: usize,
1311 expected_error: Option<&'static str>,
1312 compose_mode: ComposeMode,
1313 }
1314
1315 let tests: Vec<TestCase> = [
1316 TestCase {
1317 name: "Two component layers",
1318 opts: None,
1319 locked_components: from_json!([{
1320 "id": "component1",
1321 "source": {
1322 "content_type": "application/wasm",
1323 "source": file_url(working_dir.path().join("component1.wasm")),
1324 "digest": "digest",
1325 }},
1326 {
1327 "id": "component2",
1328 "source": {
1329 "content_type": "application/wasm",
1330 "source": file_url(working_dir.path().join("component2.wasm")),
1331 "digest": "digest",
1332 }}]),
1333 expected_layer_count: 2,
1334 expected_error: None,
1335 compose_mode: ComposeMode::Skip,
1336 },
1337 TestCase {
1338 name: "One component layer and two file layers",
1339 opts: Some(ClientOpts {
1340 content_ref_inline_max_size: 0,
1341 }),
1342 locked_components: from_json!([{
1343 "id": "component1",
1344 "source": {
1345 "content_type": "application/wasm",
1346 "source": file_url(working_dir.path().join("component1.wasm")),
1347 "digest": "digest",
1348 },
1349 "files": [
1350 {
1351 "source": file_url(working_dir.path().join("component1")),
1352 "path": working_dir.path().join("component1").join("bar").to_str().unwrap()
1353 },
1354 {
1355 "source": file_url(working_dir.path().join("component1")),
1356 "path": working_dir.path().join("component1").join("baz").to_str().unwrap()
1357 }
1358 ]
1359 }]),
1360 expected_layer_count: 3,
1361 expected_error: None,
1362 compose_mode: ComposeMode::Skip,
1363 },
1364 TestCase {
1365 name: "One component layer and one file with inlined content",
1366 opts: None,
1367 locked_components: from_json!([{
1368 "id": "component1",
1369 "source": {
1370 "content_type": "application/wasm",
1371 "source": file_url(working_dir.path().join("component1.wasm")),
1372 "digest": "digest",
1373 },
1374 "files": [
1375 {
1376 "source": file_url(working_dir.path().join("component1")),
1377 "path": working_dir.path().join("component1").join("bar").to_str().unwrap()
1378 }
1379 ]
1380 }]),
1381 expected_layer_count: 1,
1382 expected_error: None,
1383 compose_mode: ComposeMode::Skip,
1384 },
1385 TestCase {
1386 name: "One component layer and one dependency component layer skipping composition",
1387 opts: Some(ClientOpts {
1388 content_ref_inline_max_size: 0,
1389 }),
1390 locked_components: from_json!([{
1391 "id": "component1",
1392 "source": {
1393 "content_type": "application/wasm",
1394 "source": file_url(working_dir.path().join("component1.wasm")),
1395 "digest": "digest",
1396 },
1397 "dependencies": {
1398 "test:comp2": {
1399 "source": {
1400 "content_type": "application/wasm",
1401 "source": file_url(working_dir.path().join("component2.wasm")),
1402 "digest": "digest",
1403 },
1404 "export": null,
1405 }
1406 }
1407 }]),
1408 expected_layer_count: 2,
1409 expected_error: None,
1410 compose_mode: ComposeMode::Skip,
1411 },
1412 TestCase {
1413 name: "One component layer and one trigger dependency layer skipping composition",
1414 opts: Some(ClientOpts {
1415 content_ref_inline_max_size: 0,
1416 }),
1417 locked_components: from_json!([{
1418 "id": "component1",
1419 "source": {
1420 "content_type": "application/wasm",
1421 "source": file_url(working_dir.path().join("component1.wasm")),
1422 "digest": "digest",
1423 },
1424 "trigger_dependencies": {
1425 "middleware": [{
1426 "source": {
1427 "content_type": "application/wasm",
1428 "source": file_url(working_dir.path().join("component2.wasm")),
1429 "digest": "digest",
1430 },
1431 "export": null,
1432 }]
1433 }
1434 }]),
1435 expected_layer_count: 2,
1436 expected_error: None,
1437 compose_mode: ComposeMode::Skip,
1438 },
1439 TestCase {
1440 name: "Component has no source",
1441 opts: None,
1442 locked_components: from_json!([{
1443 "id": "component1",
1444 "source": {
1445 "content_type": "application/wasm",
1446 "source": "",
1447 "digest": "digest",
1448 }
1449 }]),
1450 expected_layer_count: 0,
1451 expected_error: Some("Invalid URL: \"\""),
1452 compose_mode: ComposeMode::Skip,
1453 },
1454 TestCase {
1455 name: "Duplicate component sources",
1456 opts: None,
1457 locked_components: from_json!([{
1458 "id": "component1",
1459 "source": {
1460 "content_type": "application/wasm",
1461 "source": file_url(working_dir.path().join("component1.wasm")),
1462 "digest": "digest",
1463 }},
1464 {
1465 "id": "component2",
1466 "source": {
1467 "content_type": "application/wasm",
1468 "source": file_url(working_dir.path().join("component1.wasm")),
1469 "digest": "digest",
1470 }}]),
1471 expected_layer_count: 1,
1472 expected_error: None,
1473 compose_mode: ComposeMode::Skip,
1474 },
1475 TestCase {
1476 name: "Duplicate file paths",
1477 opts: Some(ClientOpts {
1478 content_ref_inline_max_size: 0,
1479 }),
1480 locked_components: from_json!([{
1481 "id": "component1",
1482 "source": {
1483 "content_type": "application/wasm",
1484 "source": file_url(working_dir.path().join("component1.wasm")),
1485 "digest": "digest",
1486 },
1487 "files": [
1488 {
1489 "source": file_url(working_dir.path().join("component1")),
1490 "path": working_dir.path().join("component1").join("bar").to_str().unwrap()
1491 },
1492 {
1493 "source": file_url(working_dir.path().join("component1")),
1494 "path": working_dir.path().join("component1").join("baz").to_str().unwrap()
1495 }
1496 ]},
1497 {
1498 "id": "component2",
1499 "source": {
1500 "content_type": "application/wasm",
1501 "source": file_url(working_dir.path().join("component2.wasm")),
1502 "digest": "digest",
1503 },
1504 "files": [
1505 {
1506 "source": file_url(working_dir.path().join("component2")),
1507 "path": working_dir.path().join("component2").join("baz").to_str().unwrap()
1508 }
1509 ]
1510 }]),
1511 expected_layer_count: 4,
1512 expected_error: None,
1513 compose_mode: ComposeMode::Skip,
1514 },
1515 TestCase {
1516 name: "One component layer and one dependency component layer with composition",
1517 opts: Some(ClientOpts {
1518 content_ref_inline_max_size: 0,
1519 }),
1520 locked_components: from_json!([{
1521 "id": "component-with-deps",
1522 "source": {
1523 "content_type": "application/wasm",
1524 "source": file_url(working_dir.path().join("root.wasm")),
1525 "digest": "digest",
1526 },
1527 "dependencies": {
1528 "test:test/a": {
1529 "source": {
1530 "content_type": "application/wasm",
1531 "source": file_url(working_dir.path().join("dep_a.wasm")),
1532 "digest": "digest",
1533 },
1534 "export": null,
1535 }
1536 }
1537 }]),
1538 expected_layer_count: 1,
1539 expected_error: None,
1540 compose_mode: ComposeMode::All,
1541 },
1542 ]
1543 .to_vec();
1544
1545 for tc in tests {
1546 let triggers = Default::default();
1547 let metadata = Default::default();
1548 let variables = Default::default();
1549 let mut locked = LockedApp {
1550 spin_lock_version: Default::default(),
1551 components: tc.locked_components,
1552 triggers,
1553 metadata,
1554 variables,
1555 must_understand: Default::default(),
1556 host_requirements: Default::default(),
1557 };
1558
1559 let mut client = Client::new(false, Some(working_dir.path().to_path_buf()))
1560 .await
1561 .expect("should create new client");
1562 if let Some(o) = tc.opts {
1563 client.opts = o;
1564 }
1565
1566 match tc.expected_error {
1567 Some(e) => {
1568 assert_eq!(
1569 e,
1570 client
1571 .assemble_layers(&mut locked, AssemblyMode::Simple, tc.compose_mode)
1572 .await
1573 .unwrap_err()
1574 .to_string(),
1575 "{}",
1576 tc.name
1577 )
1578 }
1579 None => {
1580 assert_eq!(
1581 tc.expected_layer_count,
1582 client
1583 .assemble_layers(&mut locked, AssemblyMode::Simple, tc.compose_mode)
1584 .await
1585 .unwrap()
1586 .len(),
1587 "{}",
1588 tc.name
1589 )
1590 }
1591 }
1592 }
1593 }
1594
1595 #[tokio::test]
1600 async fn skipping_composition_pushes_trigger_dependency_layers() {
1601 use tokio::io::AsyncWriteExt;
1602
1603 let working_dir = tempfile::tempdir().unwrap();
1604
1605 for name in ["component1.wasm", "middleware.wasm"] {
1606 let mut f = tokio::fs::File::create(working_dir.path().join(name))
1607 .await
1608 .expect("should create wasm file");
1609 f.write_all(name.as_bytes())
1610 .await
1611 .expect("should write wasm contents");
1612 }
1613
1614 let mut locked = LockedApp {
1615 spin_lock_version: Default::default(),
1616 components: from_json!([{
1617 "id": "component1",
1618 "source": {
1619 "content_type": "application/wasm",
1620 "source": file_url(working_dir.path().join("component1.wasm")),
1621 "digest": "digest",
1622 },
1623 "trigger_dependencies": {
1624 "middleware": [{
1625 "source": {
1626 "content_type": "application/wasm",
1627 "source": file_url(working_dir.path().join("middleware.wasm")),
1628 "digest": "digest",
1629 },
1630 "export": null,
1631 }]
1632 }
1633 }]),
1634 triggers: Default::default(),
1635 metadata: Default::default(),
1636 variables: Default::default(),
1637 must_understand: Default::default(),
1638 host_requirements: Default::default(),
1639 };
1640
1641 let mut client = Client::new(false, Some(working_dir.path().to_path_buf()))
1642 .await
1643 .expect("should create new client");
1644 client.opts = ClientOpts {
1645 content_ref_inline_max_size: 0,
1646 };
1647
1648 let layers = client
1649 .assemble_layers(&mut locked, AssemblyMode::Simple, ComposeMode::Skip)
1650 .await
1651 .expect("should assemble layers");
1652
1653 assert_eq!(
1654 2,
1655 layers.len(),
1656 "expected one layer for the component and one for its trigger dependency"
1657 );
1658
1659 let dep = &locked.components[0].trigger_dependencies["middleware"][0];
1660 assert!(
1661 dep.source.content.source.is_none(),
1662 "trigger dependency should no longer refer to a local file, but got {:?}",
1663 dep.source.content.source
1664 );
1665 assert!(
1666 dep.source.content.digest.is_some(),
1667 "trigger dependency should refer to its pushed layer by digest"
1668 );
1669 }
1670
1671 fn generate_dummy_component(wit: &str, world: &str) -> Vec<u8> {
1672 let mut resolve = wit_parser::Resolve::default();
1673 let package_id = resolve.push_str("test", wit).expect("should parse WIT");
1674 let world_id = resolve
1675 .select_world(&[package_id], Some(world))
1676 .expect("should select world");
1677
1678 let mut wasm = wit_component::dummy_module(
1679 &resolve,
1680 world_id,
1681 ManglingAndAbi::Legacy(LiftLowerAbi::Sync),
1682 );
1683 wit_component::embed_component_metadata(
1684 &mut wasm,
1685 &resolve,
1686 world_id,
1687 wit_component::StringEncoding::UTF8,
1688 false,
1689 )
1690 .expect("should embed component metadata");
1691
1692 wit_component::ComponentEncoder::default()
1693 .validate(true)
1694 .module(&wasm)
1695 .expect("should set module")
1696 .encode()
1697 .expect("should encode component")
1698 }
1699
1700 fn annotatable_app() -> LockedApp {
1701 let mut meta_builder = spin_locked_app::values::ValuesMapBuilder::new();
1702 meta_builder
1703 .string("name", "this-is-spinal-tap")
1704 .string("version", "11.11.11")
1705 .string("description", "")
1706 .string_array("authors", vec!["Marty DiBergi", "Artie Fufkin"]);
1707 let metadata = meta_builder.build();
1708 LockedApp {
1709 spin_lock_version: Default::default(),
1710 must_understand: vec![],
1711 metadata,
1712 host_requirements: Default::default(),
1713 variables: Default::default(),
1714 triggers: Default::default(),
1715 components: Default::default(),
1716 }
1717 }
1718
1719 fn as_annotations(annotations: &[(&str, &str)]) -> Option<BTreeMap<String, String>> {
1720 Some(
1721 annotations
1722 .iter()
1723 .map(|(k, v)| (k.to_string(), v.to_string()))
1724 .collect(),
1725 )
1726 }
1727
1728 #[test]
1729 fn no_annotations_no_infer_result_is_no_annotations() {
1730 let locked_app = annotatable_app();
1731 let explicit = None;
1732 let infer = InferPredefinedAnnotations::None;
1733
1734 assert!(all_annotations(&locked_app, explicit, infer).is_none());
1735 }
1736
1737 #[test]
1738 fn explicit_annotations_no_infer_result_is_explicit_annotations() {
1739 let locked_app = annotatable_app();
1740 let explicit = as_annotations(&[("volume", "11"), ("dimensions", "feet")]);
1741 let infer = InferPredefinedAnnotations::None;
1742
1743 let annotations =
1744 all_annotations(&locked_app, explicit, infer).expect("should still have annotations");
1745 assert_eq!(2, annotations.len());
1746 assert_eq!("11", annotations.get("volume").unwrap());
1747 assert_eq!("feet", annotations.get("dimensions").unwrap());
1748 }
1749
1750 #[test]
1751 fn no_annotations_infer_all_result_is_auto_annotations() {
1752 let locked_app = annotatable_app();
1753 let explicit = None;
1754 let infer = InferPredefinedAnnotations::All;
1755
1756 let annotations =
1757 all_annotations(&locked_app, explicit, infer).expect("should now have annotations");
1758 assert_eq!(4, annotations.len());
1759 assert_eq!(
1760 "Marty DiBergi, Artie Fufkin",
1761 annotations
1762 .get(oci_client::annotations::ORG_OPENCONTAINERS_IMAGE_AUTHORS)
1763 .expect("should have authors annotation")
1764 );
1765 assert_eq!(
1766 "this-is-spinal-tap",
1767 annotations
1768 .get(oci_client::annotations::ORG_OPENCONTAINERS_IMAGE_TITLE)
1769 .expect("should have title annotation")
1770 );
1771 assert_eq!(
1772 "11.11.11",
1773 annotations
1774 .get(oci_client::annotations::ORG_OPENCONTAINERS_IMAGE_VERSION)
1775 .expect("should have version annotation")
1776 );
1777 assert!(
1778 !annotations
1779 .contains_key(oci_client::annotations::ORG_OPENCONTAINERS_IMAGE_DESCRIPTION),
1780 "empty description should not have generated annotation"
1781 );
1782 assert!(
1783 annotations.contains_key(oci_client::annotations::ORG_OPENCONTAINERS_IMAGE_CREATED),
1784 "creation annotation should have been generated"
1785 );
1786 }
1787
1788 #[test]
1789 fn explicit_annotations_infer_all_gets_both_sets() {
1790 let locked_app = annotatable_app();
1791 let explicit = as_annotations(&[("volume", "11"), ("dimensions", "feet")]);
1792 let infer = InferPredefinedAnnotations::All;
1793
1794 let annotations =
1795 all_annotations(&locked_app, explicit, infer).expect("should still have annotations");
1796 assert_eq!(6, annotations.len());
1797 assert_eq!(
1798 "11",
1799 annotations
1800 .get("volume")
1801 .expect("should have retained explicit annotation")
1802 );
1803 assert_eq!(
1804 "Marty DiBergi, Artie Fufkin",
1805 annotations
1806 .get(oci_client::annotations::ORG_OPENCONTAINERS_IMAGE_AUTHORS)
1807 .expect("should have authors annotation")
1808 );
1809 }
1810
1811 #[test]
1812 fn explicit_annotations_take_precedence_over_inferred() {
1813 let locked_app = annotatable_app();
1814 let explicit = as_annotations(&[
1815 ("volume", "11"),
1816 (
1817 oci_client::annotations::ORG_OPENCONTAINERS_IMAGE_AUTHORS,
1818 "David St Hubbins, Nigel Tufnel",
1819 ),
1820 ]);
1821 let infer = InferPredefinedAnnotations::All;
1822
1823 let annotations =
1824 all_annotations(&locked_app, explicit, infer).expect("should still have annotations");
1825 assert_eq!(
1826 5,
1827 annotations.len(),
1828 "should have one custom, one predefined explicit, and three inferred"
1829 );
1830 assert_eq!(
1831 "11",
1832 annotations
1833 .get("volume")
1834 .expect("should have retained explicit annotation")
1835 );
1836 assert_eq!(
1837 "David St Hubbins, Nigel Tufnel",
1838 annotations
1839 .get(oci_client::annotations::ORG_OPENCONTAINERS_IMAGE_AUTHORS)
1840 .expect("should have authors annotation"),
1841 "explicit authors should have taken precedence"
1842 );
1843 }
1844
1845 const DIGEST_A: &str =
1846 "sha256:0a867093096e0ef01ef749b12b6e7a90e4952eda107f89a676eeedce63a8361f";
1847 const DIGEST_B: &str =
1848 "sha256:1b867093096e0ef01ef749b12b6e7a90e4952eda107f89a676eeedce63a8361f";
1849
1850 fn descriptor(media_type: &str, digest: &str) -> oci_client::manifest::OciDescriptor {
1851 oci_client::manifest::OciDescriptor {
1852 media_type: media_type.to_owned(),
1853 digest: digest.to_owned(),
1854 ..Default::default()
1855 }
1856 }
1857
1858 #[tokio::test]
1859 async fn lockfile_path_is_keyed_by_digest() -> anyhow::Result<()> {
1860 let cache_dir = tempfile::tempdir()?;
1861 let client = Client::new(false, Some(cache_dir.path().to_owned())).await?;
1862 let repo = "example.com/org/app";
1863
1864 let a = client.lockfile_path(format!("{repo}@{DIGEST_A}")).await?;
1865 let b = client.lockfile_path(format!("{repo}@{DIGEST_B}")).await?;
1866 let latest = client.lockfile_path(repo).await?;
1867 let tagged = client.lockfile_path(format!("{repo}:v1")).await?;
1868 let tagged_and_pinned = client
1869 .lockfile_path(format!("{repo}:v1@{DIGEST_A}"))
1870 .await?;
1871
1872 assert_ne!(a, b);
1875 assert_ne!(a, latest);
1876 assert_ne!(a, tagged);
1877 assert_ne!(b, latest);
1878 assert_eq!(a, tagged_and_pinned);
1880 assert!(a.ends_with(format!("@{}/{CONFIG_FILE}", DIGEST_A.replace(':', "_"))));
1881 Ok(())
1882 }
1883
1884 #[test]
1885 fn digest_cache_segment_rejects_unsafe_digests() {
1886 assert_eq!(
1887 digest_cache_segment(DIGEST_A).unwrap(),
1888 format!("@{}", DIGEST_A.replace(':', "_"))
1889 );
1890 for digest in [
1891 "",
1892 "sha256",
1893 "sha256:",
1894 ":abc",
1895 "sha256:../../etc",
1896 "sha256:abc/def",
1897 "sha256:abc\\def",
1898 "../sha256:abc",
1899 "SHA256:abc",
1900 ] {
1901 assert!(
1902 digest_cache_segment(digest).is_err(),
1903 "digest {digest:?} should be rejected"
1904 );
1905 }
1906 }
1907
1908 #[test]
1909 fn locked_app_descriptor_prefers_locked_app_layer() {
1910 let mut manifest = OciImageManifest {
1911 config: descriptor("application/vnd.oci.image.config.v1+json", DIGEST_A),
1912 layers: vec![descriptor(WASM_LAYER_MEDIA_TYPE, DIGEST_A)],
1913 ..Default::default()
1914 };
1915 assert_eq!(locked_app_descriptor(&manifest).unwrap().digest, DIGEST_A);
1917
1918 manifest
1919 .layers
1920 .push(descriptor(SPIN_APPLICATION_MEDIA_TYPE, DIGEST_B));
1921 assert_eq!(locked_app_descriptor(&manifest).unwrap().digest, DIGEST_B);
1922
1923 manifest
1924 .layers
1925 .push(descriptor(SPIN_APPLICATION_MEDIA_TYPE, DIGEST_A));
1926 locked_app_descriptor(&manifest).expect_err("two locked app layers are ambiguous");
1927 }
1928
1929 #[test]
1930 fn verify_digest_checks_content() {
1931 let content = b"locked app";
1932 let sha256 = format!("sha256:{}", sha256::hex_digest_from_bytes(content));
1933 verify_digest(content, &sha256).unwrap();
1934 verify_digest(b"other app", &sha256).expect_err("content differs");
1935 verify_digest(content, DIGEST_A).expect_err("content differs");
1936 verify_digest(content, "md5:0123").expect_err("unsupported algorithm");
1937 verify_digest(content, "0123").expect_err("missing algorithm");
1938 }
1939
1940 #[tokio::test]
1941 async fn write_atomically_replaces_content() -> anyhow::Result<()> {
1942 let dir = tempfile::tempdir()?;
1943 let path = dir.path().join(CONFIG_FILE);
1944 write_atomically(&path, b"first").await?;
1945 write_atomically(&path, b"second").await?;
1946 assert_eq!(std::fs::read(&path)?, b"second");
1947 assert_eq!(std::fs::read_dir(dir.path())?.count(), 1);
1949 #[cfg(unix)]
1951 {
1952 use std::os::unix::fs::PermissionsExt;
1953 let reference = dir.path().join("reference");
1954 std::fs::write(&reference, b"")?;
1955 let expected = std::fs::metadata(&reference)?.permissions().mode();
1956 assert_eq!(std::fs::metadata(&path)?.permissions().mode(), expected);
1957 }
1958 Ok(())
1959 }
1960}