Skip to main content

ephemeris_release/
artifacts.rs

1//! Validated, retry-safe publication of derivation-produced release assets.
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::path::{Path, PathBuf};
5use std::process::{Command, ExitStatus};
6use std::{fs, io, iter};
7
8use ephemeris_files::RepositoryPath;
9use ephemeris_token::Token;
10use facet::Facet;
11
12use crate::ForgeRelease;
13use crate::digest::DigestAlgorithm;
14use crate::error::ForgeFailure;
15use crate::remote::{RemoteAsset, RemoteRelease};
16use crate::state::{AssetSpec, OciSpec};
17
18/// One application release to publish.
19#[derive(Clone, Debug, Eq, PartialEq)]
20pub(crate) struct Publication<'a> {
21    pub unit: String,
22    pub tag: String,
23    pub target_commit: String,
24    pub title: String,
25    pub notes: String,
26    /// The configured assets by artifact name.
27    pub expected: Vec<(&'a str, &'a AssetSpec)>,
28    /// The configured OCI artifacts by artifact name.
29    pub oci: Vec<(&'a str, &'a OciSpec)>,
30    pub version: String,
31    pub working_directory: PathBuf,
32}
33
34/// Publication or staging validation failure.
35#[derive(Debug, thiserror::Error)]
36pub(crate) enum ArtifactError {
37    /// The staged manifest is not valid JSON of the expected shape.
38    #[error("invalid artifact manifest {}", path.display())]
39    ManifestDecode {
40        /// The manifest.
41        path: PathBuf,
42        /// The decode failure.
43        #[source]
44        source: Box<facet_json::DeserializeError>,
45    },
46    /// The stage holds a different number of artifacts than configured.
47    #[error("unit {unit:?} stages {actual} artifacts, but the evaluated state declares {expected}")]
48    ArtifactCount {
49        /// The release unit.
50        unit: String,
51        /// The configured count.
52        expected: usize,
53        /// The staged count.
54        actual: usize,
55    },
56    /// A configured artifact is not staged.
57    #[error("artifact {0:?} is absent from the manifest")]
58    MissingArtifact(String),
59    /// A staged artifact disagrees with its configuration.
60    #[error("artifact {name:?} stages a different {field} than the evaluated state declares")]
61    ArtifactMismatch {
62        /// The artifact.
63        name: String,
64        /// The manifest field that differs.
65        field: &'static str,
66    },
67    /// A staged file name is unsafe or used twice.
68    #[error("artifact filename {0:?} is unsafe or duplicated")]
69    UnsafeFilename(String),
70    /// A staged artifact is not at `artifacts/<filename>`.
71    #[error("artifact {name:?} is staged at {}", path.display())]
72    UnexpectedPath {
73        /// The artifact.
74        name: String,
75        /// Where the manifest places it.
76        path: PathBuf,
77    },
78    /// A staged file cannot be read.
79    #[error("artifact I/O failed at {path:?}")]
80    Io {
81        /// The file.
82        path: PathBuf,
83        /// Why reading failed.
84        source: io::Error,
85    },
86    /// A staged package path is not a regular file.
87    #[error("staged package {} is not a file", path.display())]
88    NotAFile {
89        /// The path.
90        path: PathBuf,
91    },
92    /// The manifest has no push program for an OCI artifact.
93    #[error("oci artifact {0:?} has no push program in the manifest")]
94    MissingOciProgram(String),
95    /// A push program could not be started.
96    #[error("oci artifact {name:?} push program {} failed to start", program.display())]
97    OciSpawn {
98        /// The artifact.
99        name: String,
100        /// The push program.
101        program: PathBuf,
102        /// Why starting failed.
103        source: io::Error,
104    },
105    /// A push program exited unsuccessfully.
106    #[error("oci artifact {name:?} push failed with {status}")]
107    OciPush {
108        /// The artifact.
109        name: String,
110        /// How the program exited.
111        status: ExitStatus,
112    },
113    /// An OCI artifact needs a registry login and the state declares none.
114    #[error("oci artifact {0:?} needs a registry login, and the state declares none")]
115    MissingRegistryLogin(String),
116    /// A forge call failed.
117    #[error("remote artifact operation failed")]
118    Remote(#[from] ForgeFailure),
119    /// Verification after upload found missing or wrong assets.
120    #[error("uploaded release {tag:?} is missing required verified assets")]
121    IncompleteUpload {
122        /// The release tag.
123        tag: String,
124    },
125    /// An already published release holds other content; it is never changed.
126    #[error("published release {tag:?} differs from the locally staged artifacts")]
127    PublishedMismatch {
128        /// The release tag.
129        tag: String,
130    },
131}
132
133#[derive(Debug, Facet)]
134#[facet(tag = "kind")]
135#[repr(u8)]
136enum ManifestEntry {
137    #[facet(rename = "asset")]
138    Asset(AssetEntry),
139    #[facet(rename = "oci")]
140    Oci(OciEntry),
141    #[facet(rename = "cargo")]
142    Cargo(PackageEntry),
143    #[facet(rename = "npm")]
144    Npm(PackageEntry),
145}
146
147#[derive(Debug, Facet)]
148#[facet(deny_unknown_fields)]
149struct AssetEntry {
150    unit: String,
151    name: String,
152    package: String,
153    output: String,
154    filename: String,
155    format: String,
156    variant: String,
157    required_names: Vec<String>,
158    path: String,
159}
160
161#[derive(Debug, Facet)]
162#[facet(deny_unknown_fields)]
163struct OciEntry {
164    unit: String,
165    name: String,
166    package: String,
167    registry: String,
168    repository: String,
169    latest: bool,
170    program: String,
171}
172
173#[derive(Debug, Facet)]
174#[facet(deny_unknown_fields)]
175struct PackageEntry {
176    unit: String,
177    name: String,
178    package: String,
179    path: String,
180}
181
182/// The registry a staged package file is published to.
183#[derive(Clone, Copy, Debug, Eq, PartialEq)]
184pub(crate) enum PackageKind {
185    /// A `.crate` file for a Cargo registry.
186    Cargo,
187    /// A `.tgz` tarball for an npm registry.
188    Npm,
189}
190
191impl PackageKind {
192    /// The artifact kind name in the manifest and the release state.
193    pub(crate) const fn label(self) -> &'static str {
194        match self {
195            Self::Cargo => "cargo",
196            Self::Npm => "npm",
197        }
198    }
199
200    const fn extension(self) -> &'static str {
201        match self {
202            Self::Cargo => "crate",
203            Self::Npm => "tgz",
204        }
205    }
206
207    fn entry(self, entry: &ManifestEntry) -> Option<&PackageEntry> {
208        match entry {
209            ManifestEntry::Cargo(package) if self == Self::Cargo => Some(package),
210            ManifestEntry::Npm(package) if self == Self::Npm => Some(package),
211            ManifestEntry::Cargo(_)
212            | ManifestEntry::Npm(_)
213            | ManifestEntry::Asset(_)
214            | ManifestEntry::Oci(_) => None,
215        }
216    }
217}
218
219#[derive(Debug)]
220struct LocalAsset {
221    name: String,
222    bytes: Vec<u8>,
223    sha256: String,
224}
225
226/// The login an OCI push program authenticates to its registry with.
227#[derive(Clone, Debug)]
228pub(crate) struct OciLogin {
229    username: String,
230    password: Token,
231}
232
233impl OciLogin {
234    /// Log in as `username` with `password`.
235    pub(crate) const fn new(username: String, password: Token) -> Self {
236        Self { username, password }
237    }
238}
239
240#[derive(Debug)]
241struct OciPush {
242    name: String,
243    program: String,
244    references: Vec<String>,
245    login: OciLogin,
246}
247
248impl OciPush {
249    /// Resolve the push program and references of each expected OCI artifact.
250    ///
251    /// The references cover the unit version, and `latest` when requested.
252    fn resolve(
253        entries: &[ManifestEntry],
254        publication: &Publication<'_>,
255        login: Option<&OciLogin>,
256    ) -> Result<Vec<Self>, ArtifactError> {
257        publication
258            .oci
259            .iter()
260            .map(|&(name, spec)| {
261                let entry = entries
262                    .iter()
263                    .find_map(|entry| match entry {
264                        ManifestEntry::Oci(oci)
265                            if oci.unit == publication.unit && oci.name == name =>
266                        {
267                            Some(oci)
268                        }
269                        ManifestEntry::Oci(_)
270                        | ManifestEntry::Asset(_)
271                        | ManifestEntry::Cargo(_)
272                        | ManifestEntry::Npm(_) => None,
273                    })
274                    .ok_or_else(|| ArtifactError::MissingOciProgram(name.to_owned()))?;
275                if let Some(field) = spec.mismatch(entry) {
276                    return Err(ArtifactError::ArtifactMismatch {
277                        name: name.to_owned(),
278                        field,
279                    });
280                }
281                let login = login
282                    .ok_or_else(|| ArtifactError::MissingRegistryLogin(name.to_owned()))?
283                    .clone();
284                let tags =
285                    iter::once(publication.version.as_str()).chain(spec.latest.then_some("latest"));
286                Ok(Self {
287                    name: name.to_owned(),
288                    program: entry.program.clone(),
289                    references: tags
290                        .map(|tag| format!("{}/{}:{tag}", spec.registry, spec.repository))
291                        .collect(),
292                    login,
293                })
294            })
295            .collect()
296    }
297
298    fn run(&self, working_directory: &Path) -> Result<(), ArtifactError> {
299        for reference in &self.references {
300            let status = Command::new(&self.program)
301                .arg(reference)
302                .env("EPHEMERIS_OCI_USERNAME", &self.login.username)
303                .env("EPHEMERIS_OCI_PASSWORD", self.login.password.expose())
304                .current_dir(working_directory)
305                .status()
306                .map_err(|source| ArtifactError::OciSpawn {
307                    name: self.name.clone(),
308                    program: PathBuf::from(&self.program),
309                    source,
310                })?;
311            if !status.success() {
312                return Err(ArtifactError::OciPush {
313                    name: self.name.clone(),
314                    status,
315                });
316            }
317        }
318        Ok(())
319    }
320}
321
322/// The artifacts a publication run was given, read once for every unit.
323#[derive(Debug)]
324pub(crate) struct Stage {
325    entries: Vec<ManifestEntry>,
326    directory: PathBuf,
327}
328
329/// One unit's forge release with its local files read and checked.
330#[derive(Debug)]
331pub(crate) struct StagedRelease<'a> {
332    publication: Publication<'a>,
333    assets: Vec<LocalAsset>,
334    pushes: Vec<OciPush>,
335}
336
337impl Stage {
338    /// Read the manifest of the built artifacts in `directory`.
339    ///
340    /// # Errors
341    /// Returns an error when the manifest cannot be read or decoded.
342    pub(crate) fn read(manifest: &Path, directory: &Path) -> Result<Self, ArtifactError> {
343        Ok(Self {
344            entries: ManifestEntry::read_all(manifest)?,
345            directory: directory.to_owned(),
346        })
347    }
348
349    /// A stage without artifacts, for runs that publish none.
350    pub(crate) const fn empty() -> Self {
351        Self {
352            entries: Vec::new(),
353            directory: PathBuf::new(),
354        }
355    }
356
357    /// The staged package file of one unit.
358    ///
359    /// `name` and `package` are the artifact name and package the evaluated
360    /// state declares.
361    ///
362    /// # Errors
363    /// Returns an error when the unit does not stage exactly one package of
364    /// `kind`, when it disagrees with the declaration, or when the file is
365    /// missing.
366    pub(crate) fn package(
367        &self,
368        unit: &str,
369        kind: PackageKind,
370        name: &str,
371        package: &str,
372    ) -> Result<PathBuf, ArtifactError> {
373        let entries: Vec<_> = self
374            .entries
375            .iter()
376            .filter_map(|entry| kind.entry(entry))
377            .filter(|entry| entry.unit == unit)
378            .collect();
379        if entries.len() > 1 {
380            return Err(ArtifactError::ArtifactCount {
381                unit: unit.to_owned(),
382                expected: 1,
383                actual: entries.len(),
384            });
385        }
386        let entry = entries
387            .first()
388            .filter(|entry| entry.name == name)
389            .ok_or_else(|| ArtifactError::MissingArtifact(name.to_owned()))?;
390        if entry.package != package {
391            return Err(ArtifactError::ArtifactMismatch {
392                name: name.to_owned(),
393                field: "package",
394            });
395        }
396        if RepositoryPath::try_from(entry.path.as_str()).is_err() {
397            return Err(ArtifactError::UnsafeFilename(entry.path.clone()));
398        }
399        let expected_path = Path::new("artifacts").join(format!("{unit}.{}", kind.extension()));
400        if Path::new(&entry.path) != expected_path {
401            return Err(ArtifactError::UnexpectedPath {
402                name: name.to_owned(),
403                path: PathBuf::from(&entry.path),
404            });
405        }
406        let path = self.directory.join(&entry.path);
407        match fs::metadata(&path) {
408            Ok(metadata) if metadata.is_file() => Ok(path),
409            Ok(_) => Err(ArtifactError::NotAFile { path }),
410            Err(source) => Err(ArtifactError::Io { path, source }),
411        }
412    }
413
414    /// The bytes of the staged asset `name` of `unit`, or `None` when the
415    /// manifest has no such asset.
416    ///
417    /// # Errors
418    /// Returns an error when the asset file cannot be read.
419    pub(crate) fn asset_bytes(
420        &self,
421        unit: &str,
422        name: &str,
423    ) -> Result<Option<Vec<u8>>, ArtifactError> {
424        let Some(entry) = self.entries.iter().find_map(|entry| match entry {
425            ManifestEntry::Asset(asset) if asset.unit == unit && asset.name == name => Some(asset),
426            ManifestEntry::Asset(_)
427            | ManifestEntry::Oci(_)
428            | ManifestEntry::Cargo(_)
429            | ManifestEntry::Npm(_) => None,
430        }) else {
431            return Ok(None);
432        };
433        let path = self.directory.join(&entry.path);
434        let bytes = fs::read(&path).map_err(|source| ArtifactError::Io { path, source })?;
435        Ok(Some(bytes))
436    }
437
438    /// Check and read the staged files of one publication.
439    ///
440    /// # Errors
441    /// Returns an error when the staged files do not match the publication,
442    /// or an OCI artifact has no `login`.
443    pub(crate) fn release<'a>(
444        &self,
445        publication: Publication<'a>,
446        login: Option<&OciLogin>,
447    ) -> Result<StagedRelease<'a>, ArtifactError> {
448        let assets = LocalAsset::load_all(&self.entries, &self.directory, &publication)?;
449        let pushes = OciPush::resolve(&self.entries, &publication, login)?;
450        Ok(StagedRelease {
451            publication,
452            assets,
453            pushes,
454        })
455    }
456}
457
458/// What [`StagedRelease::prepare`] leaves to do.
459#[derive(Clone, Copy, Debug, Eq, PartialEq)]
460pub(crate) enum Prepared {
461    /// The release was published before and matches the staged files.
462    Published,
463    /// The draft with this ID holds every artifact and awaits publication.
464    Draft(u64),
465}
466
467impl Prepared {
468    /// Publish the draft that [`StagedRelease::prepare`] completed. A
469    /// published release stays as it is.
470    ///
471    /// # Errors
472    /// Returns an error when the forge call fails.
473    pub(crate) async fn publish<R: ForgeRelease>(self, remote: &R) -> Result<(), ArtifactError> {
474        match self {
475            Self::Published => Ok(()),
476            Self::Draft(id) => Ok(remote.publish(id).await.map_err(ForgeFailure::new)?),
477        }
478    }
479}
480
481impl StagedRelease<'_> {
482    /// Complete the release's artifacts: create a missing draft, replace
483    /// wrong assets on a draft, and push OCI artifacts. A published release
484    /// is only verified, and only when the unit declares artifacts.
485    ///
486    /// # Errors
487    /// Returns an error when a forge call or a push fails, or when a
488    /// published release differs from the staged files.
489    pub(crate) async fn prepare<R: ForgeRelease>(
490        &self,
491        remote: &R,
492    ) -> Result<Prepared, ArtifactError> {
493        let publication = &self.publication;
494        let id = match remote
495            .release_by_tag(&publication.tag)
496            .await
497            .map_err(ForgeFailure::new)?
498        {
499            Some(RemoteRelease {
500                id: _,
501                draft: false,
502            }) if publication.expected.is_empty() && publication.oci.is_empty() => {
503                return Ok(Prepared::Published);
504            }
505            Some(RemoteRelease { id, draft: false }) => {
506                let existing = remote.assets(id).await.map_err(ForgeFailure::new)?;
507                return if LocalAsset::all_match(&self.assets, &existing, remote).await? {
508                    Ok(Prepared::Published)
509                } else {
510                    Err(ArtifactError::PublishedMismatch {
511                        tag: publication.tag.clone(),
512                    })
513                };
514            }
515            Some(RemoteRelease { id, draft: true }) => id,
516            None => remote
517                .create_draft(
518                    &publication.tag,
519                    &publication.target_commit,
520                    &publication.title,
521                    &publication.notes,
522                )
523                .await
524                .map_err(ForgeFailure::new)?,
525        };
526        let mut kept = BTreeSet::new();
527        for asset in remote.assets(id).await.map_err(ForgeFailure::new)? {
528            let wanted = self.assets.iter().find(|local| local.name == asset.name);
529            let keep = match wanted {
530                Some(local) if !kept.contains(&asset.name) => local.matches(&asset, remote).await?,
531                Some(_) | None => false,
532            };
533            if keep {
534                kept.insert(asset.name);
535            } else {
536                remote
537                    .delete_asset(id, asset.id)
538                    .await
539                    .map_err(ForgeFailure::new)?;
540            }
541        }
542        for local in &self.assets {
543            if !kept.contains(&local.name) {
544                remote
545                    .upload(id, &local.name, &local.bytes)
546                    .await
547                    .map_err(ForgeFailure::new)?;
548            }
549        }
550        let existing = remote.assets(id).await.map_err(ForgeFailure::new)?;
551        if !LocalAsset::all_match(&self.assets, &existing, remote).await? {
552            return Err(ArtifactError::IncompleteUpload {
553                tag: publication.tag.clone(),
554            });
555        }
556        for push in &self.pushes {
557            push.run(&publication.working_directory)?;
558        }
559        Ok(Prepared::Draft(id))
560    }
561}
562
563impl LocalAsset {
564    fn load_all(
565        entries: &[ManifestEntry],
566        directory: &Path,
567        publication: &Publication<'_>,
568    ) -> Result<Vec<Self>, ArtifactError> {
569        let entries: BTreeMap<_, _> = entries
570            .iter()
571            .filter_map(|entry| match entry {
572                ManifestEntry::Asset(asset) if asset.unit == publication.unit => {
573                    Some((asset.name.clone(), asset))
574                }
575                ManifestEntry::Asset(_)
576                | ManifestEntry::Oci(_)
577                | ManifestEntry::Cargo(_)
578                | ManifestEntry::Npm(_) => None,
579            })
580            .collect();
581        if entries.len() != publication.expected.len() {
582            return Err(ArtifactError::ArtifactCount {
583                unit: publication.unit.clone(),
584                expected: publication.expected.len(),
585                actual: entries.len(),
586            });
587        }
588        let declared_names: BTreeSet<&str> = publication
589            .expected
590            .iter()
591            .map(|(_, spec)| spec.filename.as_str())
592            .collect();
593        let mut filenames = BTreeSet::new();
594        let mut result = Vec::new();
595        for &(name, spec) in &publication.expected {
596            let entry = entries
597                .get(name)
598                .ok_or_else(|| ArtifactError::MissingArtifact(name.to_owned()))?;
599            if let Some(field) = spec.mismatch(entry, &declared_names) {
600                return Err(ArtifactError::ArtifactMismatch {
601                    name: name.to_owned(),
602                    field,
603                });
604            }
605            if !filenames.insert(entry.filename.clone())
606                || RepositoryPath::try_from(entry.path.as_str()).is_err()
607            {
608                return Err(ArtifactError::UnsafeFilename(entry.filename.clone()));
609            }
610            let expected_path = Path::new("artifacts").join(&entry.filename);
611            if Path::new(&entry.path) != expected_path {
612                return Err(ArtifactError::UnexpectedPath {
613                    name: entry.name.clone(),
614                    path: PathBuf::from(&entry.path),
615                });
616            }
617            let path = directory.join(&entry.path);
618            let bytes = fs::read(&path).map_err(|source| ArtifactError::Io { path, source })?;
619            let sha256 = DigestAlgorithm::Sha256.hex(&bytes);
620            result.push(Self {
621                name: entry.filename.clone(),
622                bytes,
623                sha256,
624            });
625        }
626        Ok(result)
627    }
628
629    async fn all_match<R: ForgeRelease>(
630        local: &[Self],
631        remote_assets: &[RemoteAsset],
632        remote: &R,
633    ) -> Result<bool, ArtifactError> {
634        if local.len() != remote_assets.len() {
635            return Ok(false);
636        }
637        for artifact in local {
638            let Some(asset) = remote_assets
639                .iter()
640                .find(|asset| asset.name == artifact.name)
641            else {
642                return Ok(false);
643            };
644            if !artifact.matches(asset, remote).await? {
645                return Ok(false);
646            }
647        }
648        Ok(true)
649    }
650
651    async fn matches<R: ForgeRelease>(
652        &self,
653        asset: &RemoteAsset,
654        remote: &R,
655    ) -> Result<bool, ArtifactError> {
656        if usize::try_from(asset.size).ok() != Some(self.bytes.len()) {
657            return Ok(false);
658        }
659        let digest = if let Some(value) = asset.sha256.as_deref() {
660            value.strip_prefix("sha256:").unwrap_or(value).to_owned()
661        } else {
662            let bytes = remote.asset_bytes(asset).await.map_err(ForgeFailure::new)?;
663            DigestAlgorithm::Sha256.hex(&bytes)
664        };
665        Ok(digest == self.sha256)
666    }
667}
668
669impl ManifestEntry {
670    fn read_all(manifest: &Path) -> Result<Vec<Self>, ArtifactError> {
671        let raw = fs::read_to_string(manifest).map_err(|source| ArtifactError::Io {
672            path: manifest.to_owned(),
673            source,
674        })?;
675        facet_json::from_str(&raw).map_err(|source| ArtifactError::ManifestDecode {
676            path: manifest.to_owned(),
677            source: Box::new(source),
678        })
679    }
680}
681
682impl AssetSpec {
683    /// The first field of the staged `entry` that differs from this spec.
684    /// `declared` holds the file names of every asset of the unit.
685    fn mismatch(&self, entry: &AssetEntry, declared: &BTreeSet<&str>) -> Option<&'static str> {
686        let required: BTreeSet<&str> = self.required_names.iter().map(String::as_str).collect();
687        let staged: BTreeSet<&str> = entry.required_names.iter().map(String::as_str).collect();
688        [
689            ("package", entry.package == self.package),
690            ("output", entry.output == self.output),
691            ("filename", entry.filename == self.filename),
692            ("format", entry.format == self.format),
693            ("variant", entry.variant == self.variant),
694            (
695                "required_names",
696                staged == required && required.is_subset(declared),
697            ),
698        ]
699        .into_iter()
700        .find_map(|(field, equal)| (!equal).then_some(field))
701    }
702}
703
704impl OciSpec {
705    /// The first field of the staged `entry` that differs from this spec.
706    fn mismatch(&self, entry: &OciEntry) -> Option<&'static str> {
707        [
708            ("registry", entry.registry == self.registry),
709            ("repository", entry.repository == self.repository),
710            ("latest", entry.latest == self.latest),
711        ]
712        .into_iter()
713        .find_map(|(field, equal)| (!equal).then_some(field))
714    }
715}
716
717#[cfg(test)]
718mod tests {
719    use std::cell::RefCell;
720    use std::sync::LazyLock;
721
722    use super::*;
723
724    static ASSETS: LazyLock<[AssetSpec; 2]> = LazyLock::new(|| {
725        let required_names = vec!["prod.deb".to_owned(), "test.deb".to_owned()];
726        [
727            AssetSpec {
728                package: "app".into(),
729                output: "prod".into(),
730                filename: "prod.deb".into(),
731                format: "deb".into(),
732                variant: "production".into(),
733                required_names: required_names.clone(),
734            },
735            AssetSpec {
736                package: "app-test".into(),
737                output: "test".into(),
738                filename: "test.deb".into(),
739                format: "deb".into(),
740                variant: "testbroker".into(),
741                required_names,
742            },
743        ]
744    });
745
746    static IMAGE: LazyLock<OciSpec> = LazyLock::new(|| OciSpec {
747        package: "image".into(),
748        registry: "registry.example/team".into(),
749        repository: "app".into(),
750        latest: true,
751    });
752
753    #[derive(Default)]
754    struct Fake {
755        state: RefCell<State>,
756    }
757    #[derive(Default)]
758    struct State {
759        release: Option<RemoteRelease>,
760        assets: Vec<(RemoteAsset, Vec<u8>)>,
761        calls: Vec<String>,
762        failing_uploads: usize,
763        next_id: u64,
764    }
765
766    impl ForgeRelease for Fake {
767        type Error = io::Error;
768        async fn delete_asset(&self, _: u64, asset_id: u64) -> Result<(), Self::Error> {
769            let mut state = self.state.borrow_mut();
770            let index = state
771                .assets
772                .iter()
773                .position(|(asset, _)| asset.id == asset_id)
774                .ok_or_else(|| io::Error::other("no such asset"))?;
775            let (asset, _) = state.assets.remove(index);
776            state.calls.push(format!("delete:{}", asset.name));
777            drop(state);
778            Ok(())
779        }
780        async fn release_by_tag(&self, _: &str) -> Result<Option<RemoteRelease>, Self::Error> {
781            Ok(self.state.borrow().release)
782        }
783        async fn create_draft(
784            &self,
785            _: &str,
786            _: &str,
787            _: &str,
788            _: &str,
789        ) -> Result<u64, Self::Error> {
790            let mut state = self.state.borrow_mut();
791            state.calls.push("draft".into());
792            state.release = Some(RemoteRelease { id: 1, draft: true });
793            drop(state);
794            Ok(1)
795        }
796        async fn assets(&self, _: u64) -> Result<Vec<RemoteAsset>, Self::Error> {
797            Ok(self
798                .state
799                .borrow()
800                .assets
801                .iter()
802                .map(|(asset, _)| asset.clone())
803                .collect())
804        }
805        async fn upload(&self, _: u64, filename: &str, bytes: &[u8]) -> Result<(), Self::Error> {
806            let mut state = self.state.borrow_mut();
807            if state.failing_uploads > 0 {
808                state.failing_uploads = state.failing_uploads.saturating_sub(1);
809                return Err(io::Error::other("upload interrupted"));
810            }
811            state.calls.push(format!("upload:{filename}"));
812            let digest = DigestAlgorithm::Sha256.hex(bytes);
813            state.next_id = state.next_id.saturating_add(1);
814            let id = state.next_id.saturating_add(100);
815            state.assets.push((
816                RemoteAsset {
817                    id,
818                    name: filename.into(),
819                    size: u64::try_from(bytes.len()).unwrap_or(u64::MAX),
820                    sha256: Some(format!("sha256:{digest}")),
821                    download_url: String::new(),
822                },
823                bytes.to_vec(),
824            ));
825            drop(state);
826            Ok(())
827        }
828        async fn asset_bytes(&self, asset: &RemoteAsset) -> Result<Vec<u8>, Self::Error> {
829            Ok(self
830                .state
831                .borrow()
832                .assets
833                .iter()
834                .find(|(candidate, _)| candidate.id == asset.id)
835                .map(|(_, bytes)| bytes.clone())
836                .unwrap_or_default())
837        }
838        async fn publish(&self, _: u64) -> Result<(), Self::Error> {
839            self.state.borrow_mut().calls.push("publish".into());
840            Ok(())
841        }
842    }
843
844    async fn publish(
845        manifest: &Path,
846        directory: &Path,
847        publication: &Publication<'_>,
848        remote: &Fake,
849    ) -> Result<(), ArtifactError> {
850        let login = OciLogin::new("bot".into(), Token::new("secret"));
851        let release =
852            Stage::read(manifest, directory)?.release(publication.clone(), Some(&login))?;
853        release.prepare(remote).await?.publish(remote).await
854    }
855
856    fn asset(id: u64, name: &str, bytes: &[u8]) -> (RemoteAsset, Vec<u8>) {
857        (
858            RemoteAsset {
859                id,
860                name: name.into(),
861                size: u64::try_from(bytes.len()).unwrap(),
862                sha256: None,
863                download_url: String::new(),
864            },
865            bytes.to_vec(),
866        )
867    }
868
869    fn fixture() -> (tempfile::TempDir, PathBuf, Publication<'static>) {
870        let directory = tempfile::tempdir().unwrap();
871        fs::create_dir_all(directory.path().join("artifacts")).unwrap();
872        fs::write(directory.path().join("artifacts/prod.deb"), b"prod").unwrap();
873        fs::write(directory.path().join("artifacts/test.deb"), b"test").unwrap();
874        let manifest = directory.path().join("manifest.json");
875        fs::write(&manifest, r#"[{"kind":"asset","unit":"app","name":"production","package":"app","output":"prod","filename":"prod.deb","format":"deb","variant":"production","required_names":["prod.deb","test.deb"],"path":"artifacts/prod.deb"},{"kind":"asset","unit":"app","name":"testbroker","package":"app-test","output":"test","filename":"test.deb","format":"deb","variant":"testbroker","required_names":["prod.deb","test.deb"],"path":"artifacts/test.deb"}]"#).unwrap();
876        let publication = Publication {
877            unit: "app".into(),
878            tag: "app-v1-1".into(),
879            target_commit: "abc".into(),
880            title: "App".into(),
881            notes: String::new(),
882            expected: vec![("production", &ASSETS[0]), ("testbroker", &ASSETS[1])],
883            oci: Vec::new(),
884            version: "1".into(),
885            working_directory: directory.path().to_owned(),
886        };
887        (directory, manifest, publication)
888    }
889
890    #[tokio::test]
891    async fn verifies_all_outputs_before_creating_a_draft_then_uploads_and_publishes() {
892        let (directory, manifest, publication) = fixture();
893        let remote = Fake::default();
894        publish(&manifest, directory.path(), &publication, &remote)
895            .await
896            .unwrap();
897        assert_eq!(
898            remote.state.borrow_mut().calls,
899            ["draft", "upload:prod.deb", "upload:test.deb", "publish"]
900        );
901    }
902
903    #[tokio::test]
904    async fn a_stale_staged_field_names_the_mismatch_before_any_remote_call() {
905        let (directory, manifest, publication) = fixture();
906        let raw = fs::read_to_string(&manifest).unwrap();
907        fs::write(
908            &manifest,
909            raw.replace(r#""variant":"production""#, r#""variant":"old""#),
910        )
911        .unwrap();
912        let remote = Fake::default();
913        assert!(matches!(
914            publish(&manifest, directory.path(), &publication, &remote).await,
915            Err(ArtifactError::ArtifactMismatch { name, field })
916                if name == "production" && field == "variant"
917        ));
918        assert_eq!(remote.state.borrow_mut().calls, Vec::<String>::new());
919    }
920
921    #[tokio::test]
922    async fn staged_required_names_must_equal_the_configured_ones() {
923        let (directory, manifest, publication) = fixture();
924        let raw = fs::read_to_string(&manifest).unwrap();
925        fs::write(
926            &manifest,
927            raw.replacen(
928                r#""required_names":["prod.deb","test.deb"]"#,
929                r#""required_names":["prod.deb"]"#,
930                1,
931            ),
932        )
933        .unwrap();
934        let remote = Fake::default();
935        assert!(matches!(
936            publish(&manifest, directory.path(), &publication, &remote).await,
937            Err(ArtifactError::ArtifactMismatch { name, field })
938                if name == "production" && field == "required_names"
939        ));
940    }
941
942    #[tokio::test]
943    async fn a_missing_required_output_causes_no_remote_calls() {
944        let (directory, manifest, publication) = fixture();
945        fs::remove_file(directory.path().join("artifacts/test.deb")).unwrap();
946        let remote = Fake::default();
947        assert!(
948            publish(&manifest, directory.path(), &publication, &remote)
949                .await
950                .is_err()
951        );
952        assert_eq!(remote.state.borrow_mut().calls, Vec::<String>::new());
953    }
954
955    #[tokio::test]
956    async fn retries_verify_existing_bytes_and_never_change_a_published_release() {
957        let (directory, manifest, publication) = fixture();
958        let remote = Fake::default();
959        publish(&manifest, directory.path(), &publication, &remote)
960            .await
961            .unwrap();
962        remote.state.borrow_mut().release.as_mut().unwrap().draft = false;
963        remote.state.borrow_mut().calls.clear();
964        publish(&manifest, directory.path(), &publication, &remote)
965            .await
966            .unwrap();
967        assert_eq!(remote.state.borrow_mut().calls, Vec::<String>::new());
968        remote.state.borrow_mut().assets[0].1 = b"wrng".to_vec();
969        remote.state.borrow_mut().assets[0].0.sha256 = None;
970        assert!(matches!(
971            publish(&manifest, directory.path(), &publication, &remote).await,
972            Err(ArtifactError::PublishedMismatch { tag }) if tag == "app-v1-1"
973        ));
974        assert_eq!(remote.state.borrow_mut().calls, Vec::<String>::new());
975    }
976
977    #[tokio::test]
978    async fn a_published_release_of_a_unit_without_artifacts_is_not_inspected() {
979        let (directory, manifest, mut publication) = fixture();
980        fs::write(&manifest, "[]").unwrap();
981        publication.expected.clear();
982        let remote = Fake {
983            state: RefCell::new(State {
984                release: Some(RemoteRelease {
985                    id: 1,
986                    draft: false,
987                }),
988                assets: vec![asset(1, "hand.txt", b"hand")],
989                ..State::default()
990            }),
991        };
992        publish(&manifest, directory.path(), &publication, &remote)
993            .await
994            .unwrap();
995        assert_eq!(remote.state.borrow_mut().calls, Vec::<String>::new());
996    }
997
998    #[tokio::test]
999    async fn a_rerun_after_a_failed_upload_repairs_a_draft_with_wrong_and_extra_assets() {
1000        let (directory, manifest, publication) = fixture();
1001        let remote = Fake {
1002            state: RefCell::new(State {
1003                release: Some(RemoteRelease { id: 1, draft: true }),
1004                assets: vec![
1005                    asset(1, "prod.deb", b"prod"),
1006                    asset(2, "test.deb", b"tset"),
1007                    asset(3, "stale.deb", b"stale"),
1008                ],
1009                failing_uploads: 1,
1010                ..State::default()
1011            }),
1012        };
1013        assert!(matches!(
1014            publish(&manifest, directory.path(), &publication, &remote).await,
1015            Err(ArtifactError::Remote(_))
1016        ));
1017        assert_eq!(
1018            remote.state.borrow_mut().calls,
1019            ["delete:test.deb", "delete:stale.deb"]
1020        );
1021        remote.state.borrow_mut().calls.clear();
1022        publish(&manifest, directory.path(), &publication, &remote)
1023            .await
1024            .unwrap();
1025        assert_eq!(
1026            remote.state.borrow_mut().calls,
1027            ["upload:test.deb", "publish"]
1028        );
1029        let names: Vec<_> = remote
1030            .state
1031            .borrow()
1032            .assets
1033            .iter()
1034            .map(|(asset, bytes)| (asset.name.clone(), bytes.clone()))
1035            .collect();
1036        assert_eq!(
1037            names,
1038            [
1039                ("prod.deb".to_owned(), b"prod".to_vec()),
1040                ("test.deb".to_owned(), b"test".to_vec())
1041            ]
1042        );
1043    }
1044
1045    #[cfg(unix)]
1046    fn with_oci_program(
1047        directory: &Path,
1048        manifest: &Path,
1049        publication: &mut Publication<'static>,
1050        script: &str,
1051    ) {
1052        use std::os::unix::fs::PermissionsExt as _;
1053
1054        let program = directory.join("push");
1055        fs::write(&program, format!("#!/bin/sh\n{script}\n")).unwrap();
1056        fs::set_permissions(&program, fs::Permissions::from_mode(0o755)).unwrap();
1057        let raw = fs::read_to_string(manifest).unwrap();
1058        let oci = format!(
1059            r#"{{"kind":"oci","unit":"app","name":"image","package":"image","registry":"registry.example/team","repository":"app","latest":true,"program":{program:?}}}"#
1060        );
1061        fs::write(manifest, format!("{},{oci}]", raw.trim_end_matches(']'))).unwrap();
1062        publication.oci.push(("image", &IMAGE));
1063    }
1064
1065    #[cfg(unix)]
1066    #[tokio::test]
1067    async fn oci_pushes_run_after_the_uploads_and_before_the_release_is_published() {
1068        let (directory, manifest, mut publication) = fixture();
1069        with_oci_program(
1070            directory.path(),
1071            &manifest,
1072            &mut publication,
1073            "printf '%s\\n' \"$1\" >> \"$(dirname \"$0\")/pushed\"",
1074        );
1075        let remote = Fake::default();
1076        publish(&manifest, directory.path(), &publication, &remote)
1077            .await
1078            .unwrap();
1079        assert_eq!(
1080            remote.state.borrow_mut().calls,
1081            ["draft", "upload:prod.deb", "upload:test.deb", "publish"]
1082        );
1083        assert_eq!(
1084            fs::read_to_string(directory.path().join("pushed")).unwrap(),
1085            "registry.example/team/app:1\nregistry.example/team/app:latest\n"
1086        );
1087    }
1088
1089    #[cfg(unix)]
1090    #[tokio::test]
1091    async fn a_failed_oci_push_leaves_the_release_a_draft() {
1092        let (directory, manifest, mut publication) = fixture();
1093        with_oci_program(directory.path(), &manifest, &mut publication, "exit 3");
1094        let remote = Fake::default();
1095        let result = publish(&manifest, directory.path(), &publication, &remote).await;
1096        assert!(matches!(
1097            result,
1098            Err(ArtifactError::OciPush { name, status })
1099                if name == "image" && status.code() == Some(3)
1100        ));
1101        assert_eq!(
1102            remote.state.borrow_mut().calls,
1103            ["draft", "upload:prod.deb", "upload:test.deb"]
1104        );
1105    }
1106
1107    #[cfg(unix)]
1108    #[tokio::test]
1109    async fn a_published_release_is_not_pushed_again() {
1110        let (directory, manifest, mut publication) = fixture();
1111        let remote = Fake::default();
1112        publish(&manifest, directory.path(), &publication, &remote)
1113            .await
1114            .unwrap();
1115        remote.state.borrow_mut().release.as_mut().unwrap().draft = false;
1116        with_oci_program(
1117            directory.path(),
1118            &manifest,
1119            &mut publication,
1120            "touch \"$(dirname \"$0\")/pushed\"",
1121        );
1122        publish(&manifest, directory.path(), &publication, &remote)
1123            .await
1124            .unwrap();
1125        assert!(!directory.path().join("pushed").exists());
1126    }
1127}